mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-06-17 08:57:30 +02:00
Merge branch 'main' into feature/526-average-resample
This commit is contained in:
@@ -170,6 +170,7 @@ func setup(t *testing.T) *api.RestAPI {
|
||||
|
||||
archiver.Start(repository.GetJobRepository(), context.Background())
|
||||
|
||||
t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
|
||||
auth.Init(&cfg)
|
||||
} else {
|
||||
|
||||
@@ -398,11 +398,6 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/jobs/edit_meta/": {
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Edit key value pairs in metadata json of job specified by jobID, StartTime and Cluster\nIf a key already exists its content will be overwritten",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -413,7 +408,7 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Job add and modify"
|
||||
],
|
||||
"summary": "Edit meta-data json by request",
|
||||
"summary": "Edit meta-data json of job identified by request",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Specifies job and payload to add or update",
|
||||
@@ -456,12 +451,17 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/jobs/edit_meta/{id}": {
|
||||
"patch": {
|
||||
"description": "Edit key value pairs in job metadata json\nIf a key already exists its content will be overwritten",
|
||||
"description": "Edit key value pairs in job metadata json of job specified by database id\nIf a key already exists its content will be overwritten",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -471,7 +471,7 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Job add and modify"
|
||||
],
|
||||
"summary": "Edit meta-data json",
|
||||
"summary": "Edit meta-data json of job identified by database id",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -481,7 +481,7 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Kay value pair to add",
|
||||
"description": "Metadata Key value pair to add or update",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -743,6 +743,64 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/jobs/used_nodes": {
|
||||
"get": {
|
||||
"description": "Get a map of cluster names to lists of unique hostnames that are currently in use by running jobs that started before the specified timestamp.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Job query"
|
||||
],
|
||||
"summary": "Lists used nodes by cluster",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Unix timestamp to filter jobs (jobs with start_time \u003c ts)",
|
||||
"name": "ts",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Map of cluster names to hostname lists",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.GetUsedNodesAPIResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/jobs/{id}": {
|
||||
"get": {
|
||||
"description": "Job to get is specified by database ID\nReturns full job resource information according to 'Job' scheme and all metrics according to 'JobData'.",
|
||||
@@ -965,8 +1023,11 @@ const docTemplate = `{
|
||||
"/api/user/{id}": {
|
||||
"post": {
|
||||
"description": "Allows admins to add/remove roles and projects for a user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/plain"
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"User"
|
||||
@@ -981,35 +1042,26 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role to add",
|
||||
"name": "add-role",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Role to remove",
|
||||
"name": "remove-role",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Project to add",
|
||||
"name": "add-project",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Project to remove",
|
||||
"name": "remove-project",
|
||||
"in": "formData"
|
||||
"description": "Single Field Changes",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.UpdateUserAPIRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success message",
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/api.DefaultAPIResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
@@ -1381,63 +1433,6 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/healthcheck/": {
|
||||
"get": {
|
||||
"description": "This endpoint allows the users to check if a node is healthy",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"healthcheck"
|
||||
],
|
||||
"summary": "HealthCheck endpoint",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Selector",
|
||||
"name": "selector",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Debug dump",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/jobs/tag_job/{id}": {
|
||||
"delete": {
|
||||
"description": "Removes tag(s) from a job specified by DB ID. Name and Type of Tag(s) must match.\nTag Scope is required for matching, options: \"global\", \"admin\". Private tags can not be deleted via API.\nIf tagged job is already finished: Tag will be removed from respective archive files.",
|
||||
@@ -1940,6 +1935,31 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.UpdateUserAPIRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"add-role": {
|
||||
"description": "Role to add to user $ID",
|
||||
"type": "string",
|
||||
"example": "user"
|
||||
},
|
||||
"remove-role": {
|
||||
"description": "Role to remove from user $ID",
|
||||
"type": "string",
|
||||
"example": "user"
|
||||
},
|
||||
"add-project": {
|
||||
"description": "Project to add to user $ID managed array",
|
||||
"type": "string",
|
||||
"example": "abcd100"
|
||||
},
|
||||
"remove-project": {
|
||||
"description": "Project to remove from user $ID managed array",
|
||||
"type": "string",
|
||||
"example": "abcd100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.DefaultAPIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2043,6 +2063,52 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.GetUsedNodesAPIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"usedNodes": {
|
||||
"description": "Map of cluster names to lists of used node hostnames",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.JobMetaRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"jobId"
|
||||
],
|
||||
"properties": {
|
||||
"cluster": {
|
||||
"description": "Cluster of job",
|
||||
"type": "string",
|
||||
"example": "fritz"
|
||||
},
|
||||
"jobId": {
|
||||
"description": "Cluster Job ID of job",
|
||||
"type": "integer",
|
||||
"example": 123000
|
||||
},
|
||||
"payload": {
|
||||
"description": "Content to Add to Job Meta_Data",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/api.EditMetaRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"startTime": {
|
||||
"description": "Start Time of job as epoch",
|
||||
"type": "integer",
|
||||
"example": 1649723812
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.JobMetricWithName": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2175,13 +2241,6 @@ const docTemplate = `{
|
||||
"format": "float64"
|
||||
}
|
||||
},
|
||||
"exclusive": {
|
||||
"description": "for backwards compatibility",
|
||||
"type": "integer",
|
||||
"maximum": 2,
|
||||
"minimum": 0,
|
||||
"example": 1
|
||||
},
|
||||
"footprint": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
||||
@@ -166,6 +166,10 @@ func (api *RestAPI) getJobs(rw http.ResponseWriter, r *http.Request) {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
if x < 1 {
|
||||
handleError(fmt.Errorf("page must be >= 1"), http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
page.Page = x
|
||||
case "items-per-page":
|
||||
x, err := strconv.Atoi(vals[0])
|
||||
@@ -173,6 +177,10 @@ func (api *RestAPI) getJobs(rw http.ResponseWriter, r *http.Request) {
|
||||
handleError(err, http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
if x < 1 {
|
||||
handleError(fmt.Errorf("items-per-page must be >= 1"), http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
page.ItemsPerPage = x
|
||||
case "with-metadata":
|
||||
withMetadata = true
|
||||
|
||||
@@ -78,6 +78,12 @@ type NatsAPI struct {
|
||||
jobCh chan natsMessage
|
||||
// nodeCh receives node state messages for processing by worker goroutines.
|
||||
nodeCh chan natsMessage
|
||||
// stop signals worker goroutines and subscription callbacks to stop.
|
||||
// Closing it (via Shutdown) makes workers exit and callbacks drop further
|
||||
// messages instead of blocking; the channels are never closed so in-flight
|
||||
// callbacks can never send on a closed channel.
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewNatsAPI creates a new NatsAPI instance with channel-based worker pools.
|
||||
@@ -99,6 +105,7 @@ func NewNatsAPI() *NatsAPI {
|
||||
JobRepository: repository.GetJobRepository(),
|
||||
jobCh: make(chan natsMessage, jobConc),
|
||||
nodeCh: make(chan natsMessage, nodeConc),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start worker goroutines
|
||||
@@ -112,17 +119,36 @@ func NewNatsAPI() *NatsAPI {
|
||||
return api
|
||||
}
|
||||
|
||||
// Shutdown stops the worker goroutines and tells subscription callbacks to stop
|
||||
// enqueueing. It is safe to call multiple times. Callers must ensure the NATS
|
||||
// client is closed first so no new callbacks are invoked.
|
||||
func (api *NatsAPI) Shutdown() {
|
||||
api.stopOnce.Do(func() {
|
||||
close(api.stop)
|
||||
})
|
||||
}
|
||||
|
||||
// jobWorker processes job event messages from the job channel.
|
||||
func (api *NatsAPI) jobWorker() {
|
||||
for msg := range api.jobCh {
|
||||
api.handleJobEvent(msg.subject, msg.data)
|
||||
for {
|
||||
select {
|
||||
case <-api.stop:
|
||||
return
|
||||
case msg := <-api.jobCh:
|
||||
api.handleJobEvent(msg.subject, msg.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nodeWorker processes node state messages from the node channel.
|
||||
func (api *NatsAPI) nodeWorker() {
|
||||
for msg := range api.nodeCh {
|
||||
api.handleNodeState(msg.subject, msg.data)
|
||||
for {
|
||||
select {
|
||||
case <-api.stop:
|
||||
return
|
||||
case msg := <-api.nodeCh:
|
||||
api.handleNodeState(msg.subject, msg.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,18 +166,27 @@ func (api *NatsAPI) StartSubscriptions() error {
|
||||
s := config.Keys.APISubjects
|
||||
|
||||
if err := client.Subscribe(s.SubjectJobEvent, func(subject string, data []byte) {
|
||||
api.jobCh <- natsMessage{subject: subject, data: data}
|
||||
select {
|
||||
case api.jobCh <- natsMessage{subject: subject, data: data}:
|
||||
case <-api.stop:
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := client.Subscribe(s.SubjectNodeState, func(subject string, data []byte) {
|
||||
api.nodeCh <- natsMessage{subject: subject, data: data}
|
||||
select {
|
||||
case api.nodeCh <- natsMessage{subject: subject, data: data}:
|
||||
case <-api.stop:
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cclog.Info("NATS API subscriptions started")
|
||||
cclog.Warnf("NATS API subscriptions started on subjects %q and %q — these are UNAUTHENTICATED: "+
|
||||
"anyone with publish rights on the broker can start/stop jobs and update node state. "+
|
||||
"Restrict publish ACLs on the NATS broker to trusted producers only.",
|
||||
s.SubjectJobEvent, s.SubjectNodeState)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ func setupNatsTest(t *testing.T) *NatsAPI {
|
||||
|
||||
archiver.Start(repository.GetJobRepository(), context.Background())
|
||||
|
||||
t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
|
||||
auth.Init(&cfg)
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,8 @@ func (api *RestAPI) MountAPIRoutes(r chi.Router) {
|
||||
// REST API Uses TokenAuth
|
||||
// User List
|
||||
r.Get("/users/", api.getUsers)
|
||||
// User Edit
|
||||
r.Post("/user/{id}", api.updateUserByRequest)
|
||||
// Cluster List
|
||||
r.Get("/clusters/", api.getClusters)
|
||||
// Slurm node state
|
||||
@@ -152,7 +154,7 @@ func (api *RestAPI) MountConfigAPIRoutes(r chi.Router) {
|
||||
r.Put("/config/users/", api.createUser)
|
||||
r.Get("/config/users/", api.getUsers)
|
||||
r.Delete("/config/users/", api.deleteUser)
|
||||
r.Post("/config/user/{id}", api.updateUser)
|
||||
r.Post("/config/user/{id}", api.updateUserByForm)
|
||||
r.Post("/config/notice/", api.editNotice)
|
||||
r.Get("/config/taggers/", api.getTaggers)
|
||||
r.Post("/config/taggers/run/", api.runTagger)
|
||||
|
||||
@@ -24,6 +24,14 @@ type APIReturnedUser struct {
|
||||
Projects []string `json:"projects"`
|
||||
}
|
||||
|
||||
// UpdateUserAPIRequest model
|
||||
type UpdateUserAPIRequest struct {
|
||||
NewRole string `json:"add-role" example:"user"` // Role to add to user $ID
|
||||
DelRole string `json:"remove-role" example:"user"` // Role to remove from user $ID
|
||||
NewProj string `json:"add-project" example:"abcd100"` // Project to add to user $ID managed array
|
||||
DelProj string `json:"remove-project" example:"abcd100"` // Project to remove from user $ID managed array
|
||||
}
|
||||
|
||||
// getUsers godoc
|
||||
// @summary Returns a list of users
|
||||
// @tags User
|
||||
@@ -58,22 +66,74 @@ func (api *RestAPI) getUsers(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// updateUser godoc
|
||||
// updateUserByRequest godoc
|
||||
// @summary Update user roles and projects
|
||||
// @tags User
|
||||
// @description Allows admins to add/remove roles and projects for a user
|
||||
// @produce plain
|
||||
// @param id path string true "Username"
|
||||
// @param add-role formData string false "Role to add"
|
||||
// @param remove-role formData string false "Role to remove"
|
||||
// @param add-project formData string false "Project to add"
|
||||
// @param remove-project formData string false "Project to remove"
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path string true "Username"
|
||||
// @param request body api.UpdateUserAPIRequest true "Single Field Changes"
|
||||
// @success 200 {string} string "Success message"
|
||||
// @failure 403 {object} api.ErrorResponse "Forbidden"
|
||||
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
|
||||
// @security ApiKeyAuth
|
||||
// @router /api/user/{id} [post]
|
||||
func (api *RestAPI) updateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *RestAPI) updateUserByRequest(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||
handleError(fmt.Errorf("only admins are allowed to update a user"), http.StatusForbidden, rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Get Values
|
||||
var req UpdateUserAPIRequest
|
||||
if err := decode(r.Body, &req); err != nil {
|
||||
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Handle role updates
|
||||
if req.NewRole != "" {
|
||||
if err := repository.GetUserRepository().AddRole(r.Context(), chi.URLParam(r, "id"), req.NewRole); err != nil {
|
||||
handleError(fmt.Errorf("adding role failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(rw).Encode(DefaultAPIResponse{Message: fmt.Sprintf("Add Role Success for user %s", chi.URLParam(r, "id"))}); err != nil {
|
||||
cclog.Errorf("Failed to encode response: %v", err)
|
||||
}
|
||||
} else if req.DelRole != "" {
|
||||
if err := repository.GetUserRepository().RemoveRole(r.Context(), chi.URLParam(r, "id"), req.DelRole); err != nil {
|
||||
handleError(fmt.Errorf("removing role failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(rw).Encode(DefaultAPIResponse{Message: fmt.Sprintf("Remove Role Success for user %s", chi.URLParam(r, "id"))}); err != nil {
|
||||
cclog.Errorf("Failed to encode response: %v", err)
|
||||
}
|
||||
} else if req.NewProj != "" {
|
||||
if err := repository.GetUserRepository().AddProject(r.Context(), chi.URLParam(r, "id"), req.NewProj); err != nil {
|
||||
handleError(fmt.Errorf("adding project failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(rw).Encode(DefaultAPIResponse{Message: fmt.Sprintf("Add Project Success for user %s", chi.URLParam(r, "id"))}); err != nil {
|
||||
cclog.Errorf("Failed to encode response: %v", err)
|
||||
}
|
||||
} else if req.DelProj != "" {
|
||||
if err := repository.GetUserRepository().RemoveProject(r.Context(), chi.URLParam(r, "id"), req.DelProj); err != nil {
|
||||
handleError(fmt.Errorf("removing project failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(rw).Encode(DefaultAPIResponse{Message: fmt.Sprintf("Remove Project Success for user %s", chi.URLParam(r, "id"))}); err != nil {
|
||||
cclog.Errorf("Failed to encode response: %v", err)
|
||||
}
|
||||
} else {
|
||||
handleError(fmt.Errorf("no operation specified: must provide add-role, remove-role, add-project, or remove-project"), http.StatusBadRequest, rw)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *RestAPI) updateUserByForm(rw http.ResponseWriter, r *http.Request) {
|
||||
// SecuredCheck() only worked with TokenAuth: Removed
|
||||
|
||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||
|
||||
@@ -222,6 +222,13 @@ func TriggerArchiving(job *schema.Job) {
|
||||
func Shutdown(timeout time.Duration) error {
|
||||
cclog.Info("Initiating archiver shutdown...")
|
||||
|
||||
// Guard against Shutdown being called when Start was never run: closing a nil
|
||||
// channel and receiving from a nil workerDone would panic/block forever.
|
||||
if archiveChannel == nil {
|
||||
cclog.Warn("Archiver shutdown called but archiver was never started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close channel to signal no more jobs will be accepted
|
||||
close(archiveChannel)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -164,12 +165,17 @@ func (auth *Authentication) AuthViaSession(
|
||||
return nil, errors.New("invalid session data")
|
||||
}
|
||||
|
||||
authSourceInt, ok := session.Values["authSource"].(int)
|
||||
if !ok {
|
||||
authSourceInt = int(schema.AuthViaLocalPassword)
|
||||
}
|
||||
|
||||
return &schema.User{
|
||||
Username: username,
|
||||
Projects: projects,
|
||||
Roles: roles,
|
||||
AuthType: schema.AuthSession,
|
||||
AuthSource: -1,
|
||||
AuthSource: schema.AuthSource(authSourceInt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,24 +188,37 @@ func Init(authCfg *json.RawMessage) {
|
||||
|
||||
sessKey := os.Getenv("SESSION_KEY")
|
||||
if sessKey == "" {
|
||||
cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
cclog.Fatal("Error while initializing authentication -> failed to generate random bytes for session key")
|
||||
if !config.Keys.DisableAuthentication {
|
||||
cclog.Fatal("environment variable 'SESSION_KEY' not set: refusing to start with an ephemeral session key. " +
|
||||
"Set SESSION_KEY in .env (base64-encoded 32 random bytes); a random key would invalidate all sessions on every restart " +
|
||||
"and prevent sessions from validating across replicas.")
|
||||
}
|
||||
authInstance.sessionStore = sessions.NewCookieStore(bytes)
|
||||
// Authentication is disabled: no user sessions are issued, so an
|
||||
// ephemeral random key is sufficient and SESSION_KEY is not required.
|
||||
ephemeralKey := make([]byte, 32)
|
||||
if _, err := rand.Read(ephemeralKey); err != nil {
|
||||
cclog.Fatalf("Error while initializing authentication -> generating ephemeral session key failed: %v", err)
|
||||
}
|
||||
authInstance.sessionStore = sessions.NewCookieStore(ephemeralKey)
|
||||
} else {
|
||||
bytes, err := base64.StdEncoding.DecodeString(sessKey)
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(sessKey)
|
||||
if err != nil {
|
||||
cclog.Fatal("Error while initializing authentication -> decoding session key failed")
|
||||
}
|
||||
authInstance.sessionStore = sessions.NewCookieStore(bytes)
|
||||
authInstance.sessionStore = sessions.NewCookieStore(keyBytes)
|
||||
}
|
||||
|
||||
if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil {
|
||||
authInstance.SessionMaxAge = d
|
||||
}
|
||||
|
||||
// When authentication is disabled no authenticators are required; the
|
||||
// session store created above is enough for the server to run with a
|
||||
// valid (non-nil) auth instance.
|
||||
if config.Keys.DisableAuthentication {
|
||||
return
|
||||
}
|
||||
|
||||
if authCfg == nil {
|
||||
return
|
||||
}
|
||||
@@ -319,10 +338,12 @@ func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
session.Options.Secure = false
|
||||
}
|
||||
session.Options.SameSite = http.SameSiteStrictMode
|
||||
session.Options.SameSite = http.SameSiteLaxMode
|
||||
session.Options.HttpOnly = true
|
||||
session.Values["username"] = user.Username
|
||||
session.Values["projects"] = user.Projects
|
||||
session.Values["roles"] = user.Roles
|
||||
session.Values["authSource"] = int(user.AuthSource)
|
||||
if err := auth.sessionStore.Save(r, rw, session); err != nil {
|
||||
cclog.Warnf("session save failed: %s", err.Error())
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
@@ -382,9 +403,12 @@ func (auth *Authentication) Login(
|
||||
cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
|
||||
ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
|
||||
|
||||
if r.FormValue("redirect") != "" {
|
||||
http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
if redirect := r.FormValue("redirect"); redirect != "" {
|
||||
if u, perr := url.Parse(redirect); perr == nil && u.Scheme == "" && u.Host == "" {
|
||||
http.RedirectHandler(redirect, http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
cclog.Warnf("login redirect rejected (not a relative path): %q", redirect)
|
||||
}
|
||||
|
||||
http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
|
||||
@@ -626,7 +650,7 @@ func securedCheck(user *schema.User, r *http.Request) error {
|
||||
}
|
||||
// If SplitHostPort fails, IPAddress is already just a host (no port)
|
||||
|
||||
// If nothing declared in config: Continue
|
||||
// If nothing declared in config: Continue // FIXME: Allow All If Not Declared?
|
||||
if len(config.Keys.APIAllowedIPs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -119,22 +120,26 @@ func (ja *JWTCookieSessionAuthenticator) Login(
|
||||
rawtoken = jwtCookie.Value
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
|
||||
if t.Method != jwt.SigningMethodEdDSA {
|
||||
return nil, errors.New("only Ed25519/EdDSA supported")
|
||||
}
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}))
|
||||
|
||||
unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string)
|
||||
if success && unvalidatedIssuer == jc.TrustedIssuer {
|
||||
// The (unvalidated) issuer seems to be the expected one,
|
||||
// use public cross login key from config
|
||||
return ja.publicKeyCrossLogin, nil
|
||||
}
|
||||
unverified, _, perr := parser.ParseUnverified(rawtoken, jwt.MapClaims{})
|
||||
if perr != nil {
|
||||
cclog.Warn("JWT cookie session: error while parsing token")
|
||||
return nil, perr
|
||||
}
|
||||
issuer, _ := unverified.Claims.(jwt.MapClaims)["iss"].(string)
|
||||
|
||||
// No cross login key configured or issuer not expected
|
||||
// Try own key
|
||||
return ja.publicKey, nil
|
||||
})
|
||||
var key any
|
||||
switch issuer {
|
||||
case jc.TrustedIssuer:
|
||||
key = ja.publicKeyCrossLogin
|
||||
case "":
|
||||
key = ja.publicKey
|
||||
default:
|
||||
return nil, fmt.Errorf("untrusted JWT issuer: %q", issuer)
|
||||
}
|
||||
|
||||
token, err := parser.Parse(rawtoken, func(*jwt.Token) (any, error) { return key, nil })
|
||||
if err != nil {
|
||||
cclog.Warn("JWT cookie session: error while parsing token")
|
||||
return nil, err
|
||||
|
||||
@@ -25,15 +25,21 @@ type JWTSessionAuthenticator struct {
|
||||
var _ Authenticator = (*JWTSessionAuthenticator)(nil)
|
||||
|
||||
func (ja *JWTSessionAuthenticator) Init() error {
|
||||
if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" {
|
||||
bytes, err := base64.StdEncoding.DecodeString(pubKey)
|
||||
if err != nil {
|
||||
cclog.Warn("Could not decode cross login JWT HS512 key")
|
||||
return err
|
||||
}
|
||||
ja.loginTokenKey = bytes
|
||||
pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY")
|
||||
if pubKey == "" {
|
||||
// Without a configured key the HMAC verification below would run against
|
||||
// an empty key, which lets anyone forge a valid token. Refuse to register
|
||||
// the authenticator in that case so JWT session login is simply disabled.
|
||||
return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled")
|
||||
}
|
||||
|
||||
bytes, err := base64.StdEncoding.DecodeString(pubKey)
|
||||
if err != nil {
|
||||
cclog.Warn("Could not decode cross login JWT HS512 key")
|
||||
return err
|
||||
}
|
||||
ja.loginTokenKey = bytes
|
||||
|
||||
cclog.Info("JWT Session authenticator successfully registered")
|
||||
return nil
|
||||
}
|
||||
@@ -60,6 +66,12 @@ func (ja *JWTSessionAuthenticator) Login(
|
||||
|
||||
token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (any, error) {
|
||||
if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 {
|
||||
// Defense in depth: an empty key would verify any HMAC signature.
|
||||
// Init() already refuses to register without a key, so this should
|
||||
// never trigger, but guard explicitly rather than trust the chain.
|
||||
if len(ja.loginTokenKey) == 0 {
|
||||
return nil, errors.New("HS login key not configured")
|
||||
}
|
||||
return ja.loginTokenKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg())
|
||||
|
||||
@@ -79,7 +79,7 @@ func NewOIDC(a *Authentication) *OIDC {
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
|
||||
}
|
||||
|
||||
oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a}
|
||||
@@ -162,36 +162,76 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
projects := make([]string, 0)
|
||||
// projects is populated below from ID token claims
|
||||
var projects []string
|
||||
|
||||
// Extract custom claims from userinfo
|
||||
var claims struct {
|
||||
// Extract profile claims from userinfo (username, name)
|
||||
var userInfoClaims struct {
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := userInfo.Claims(&userInfoClaims); err != nil {
|
||||
cclog.Errorf("failed to extract userinfo claims: %s", err.Error())
|
||||
http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract role claims from the ID token.
|
||||
// Keycloak includes realm_access and resource_access in the ID token (JWT),
|
||||
// but NOT in the UserInfo endpoint response by default.
|
||||
var idTokenClaims struct {
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
// Keycloak realm-level roles
|
||||
RealmAccess struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"realm_access"`
|
||||
// Keycloak client-level roles
|
||||
ResourceAccess struct {
|
||||
Client struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"clustercockpit"`
|
||||
// Keycloak client-level roles: map from client-id to role list
|
||||
ResourceAccess map[string]struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"resource_access"`
|
||||
// Custom multi-valued user attribute mapped via a Keycloak User Attribute mapper
|
||||
Projects []string `json:"projects"`
|
||||
}
|
||||
if err := userInfo.Claims(&claims); err != nil {
|
||||
cclog.Errorf("failed to extract claims: %s", err.Error())
|
||||
http.Error(rw, "Failed to extract user claims", http.StatusInternalServerError)
|
||||
if err := idToken.Claims(&idTokenClaims); err != nil {
|
||||
cclog.Errorf("failed to extract ID token claims: %s", err.Error())
|
||||
http.Error(rw, "Failed to extract ID token claims", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Username == "" {
|
||||
cclog.Debugf("OIDC userinfo claims: username=%q name=%q", userInfoClaims.Username, userInfoClaims.Name)
|
||||
cclog.Debugf("OIDC ID token realm_access roles: %v", idTokenClaims.RealmAccess.Roles)
|
||||
cclog.Debugf("OIDC ID token resource_access: %v", idTokenClaims.ResourceAccess)
|
||||
cclog.Debugf("OIDC ID token projects: %v", idTokenClaims.Projects)
|
||||
|
||||
projects = idTokenClaims.Projects
|
||||
if projects == nil {
|
||||
projects = []string{}
|
||||
}
|
||||
|
||||
// Prefer username from userInfo; fall back to ID token claim
|
||||
username := userInfoClaims.Username
|
||||
if username == "" {
|
||||
username = idTokenClaims.Username
|
||||
}
|
||||
name := userInfoClaims.Name
|
||||
if name == "" {
|
||||
name = idTokenClaims.Name
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
http.Error(rw, "Username claim missing from OIDC provider", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge roles from both client-level and realm-level access
|
||||
oidcRoles := append(claims.ResourceAccess.Client.Roles, claims.RealmAccess.Roles...)
|
||||
// Collect roles from realm_access (realm roles) in the ID token
|
||||
oidcRoles := append([]string{}, idTokenClaims.RealmAccess.Roles...)
|
||||
|
||||
// Also collect roles from resource_access (client roles) for all clients
|
||||
for clientID, access := range idTokenClaims.ResourceAccess {
|
||||
cclog.Debugf("OIDC ID token resource_access[%q] roles: %v", clientID, access.Roles)
|
||||
oidcRoles = append(oidcRoles, access.Roles...)
|
||||
}
|
||||
|
||||
roleSet := make(map[string]bool)
|
||||
for _, r := range oidcRoles {
|
||||
@@ -217,8 +257,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user := &schema.User{
|
||||
Username: claims.Username,
|
||||
Name: claims.Name,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Roles: roles,
|
||||
Projects: projects,
|
||||
AuthSource: schema.AuthViaOIDC,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ package graph
|
||||
// This file will be automatically regenerated based on the schema, any resolver
|
||||
// implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -682,6 +682,11 @@ func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
// Use request-scoped cache: multiple aliases with same (filter, groupBy)
|
||||
// but different sortBy/page hit the DB only once.
|
||||
if cache := getStatsGroupCache(ctx); cache != nil {
|
||||
// Ensure the sort field is computed even if not in the GraphQL selection,
|
||||
// because sortAndPageStats will sort by it in memory.
|
||||
if sortBy != nil {
|
||||
reqFields[sortByFieldName(*sortBy)] = true
|
||||
}
|
||||
key := statsCacheKey(filter, groupBy, reqFields)
|
||||
var allStats []*model.JobsStatistics
|
||||
allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
|
||||
|
||||
@@ -107,6 +107,33 @@ func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggr
|
||||
return sorted
|
||||
}
|
||||
|
||||
// sortByFieldName maps a SortByAggregate enum to the corresponding reqFields key.
|
||||
// This ensures the DB computes the column that sortAndPageStats will sort by.
|
||||
func sortByFieldName(sortBy model.SortByAggregate) string {
|
||||
switch sortBy {
|
||||
case model.SortByAggregateTotaljobs:
|
||||
return "totalJobs"
|
||||
case model.SortByAggregateTotalusers:
|
||||
return "totalUsers"
|
||||
case model.SortByAggregateTotalwalltime:
|
||||
return "totalWalltime"
|
||||
case model.SortByAggregateTotalnodes:
|
||||
return "totalNodes"
|
||||
case model.SortByAggregateTotalnodehours:
|
||||
return "totalNodeHours"
|
||||
case model.SortByAggregateTotalcores:
|
||||
return "totalCores"
|
||||
case model.SortByAggregateTotalcorehours:
|
||||
return "totalCoreHours"
|
||||
case model.SortByAggregateTotalaccs:
|
||||
return "totalAccs"
|
||||
case model.SortByAggregateTotalacchours:
|
||||
return "totalAccHours"
|
||||
default:
|
||||
return "totalJobs"
|
||||
}
|
||||
}
|
||||
|
||||
// statsFieldGetter returns a function that extracts the sortable int field
|
||||
// from a JobsStatistics struct for the given sort key.
|
||||
func statsFieldGetter(sortBy model.SortByAggregate) func(*model.JobsStatistics) int {
|
||||
|
||||
@@ -236,4 +236,3 @@ func (ccms *CCMetricStore) buildNodeQueries(
|
||||
|
||||
return queries, assignedScope, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,10 @@ func DefaultConfig() *RepositoryConfig {
|
||||
MaxIdleConnections: 4,
|
||||
ConnectionMaxLifetime: time.Hour,
|
||||
ConnectionMaxIdleTime: 10 * time.Minute,
|
||||
MinRunningJobDuration: 600, // 10 minutes
|
||||
DbCacheSizeMB: 2048, // 2GB per connection
|
||||
DbSoftHeapLimitMB: 16384, // 16GB process-wide
|
||||
BusyTimeoutMs: 60000, // 60 seconds
|
||||
MinRunningJobDuration: 600, // 10 minutes
|
||||
DbCacheSizeMB: 2048, // 2GB per connection
|
||||
DbSoftHeapLimitMB: 16384, // 16GB process-wide
|
||||
BusyTimeoutMs: 60000, // 60 seconds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,15 @@ func (r *JobRepository) QueryJobs(
|
||||
}
|
||||
|
||||
if page != nil && page.ItemsPerPage != -1 {
|
||||
// -1 is the only valid non-positive value ("load all"); reject other
|
||||
// non-positive values so that uint64(page.ItemsPerPage) cannot underflow
|
||||
// into a huge limit. Clamp Page to >= 1 to avoid the same on the offset.
|
||||
if page.ItemsPerPage < 1 {
|
||||
return nil, fmt.Errorf("invalid items-per-page value: %d", page.ItemsPerPage)
|
||||
}
|
||||
p := max(page.Page, 1)
|
||||
limit := uint64(page.ItemsPerPage)
|
||||
query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
|
||||
query = query.Offset((uint64(p) - 1) * limit).Limit(limit)
|
||||
}
|
||||
|
||||
for _, f := range filters {
|
||||
@@ -280,11 +287,11 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select
|
||||
|
||||
// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
|
||||
func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if cond.From != 1 && cond.To != 0 {
|
||||
if cond.From > 0 && cond.To > 0 {
|
||||
return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1 && cond.To == 0 {
|
||||
} else if cond.From > 0 && cond.To == 0 {
|
||||
return query.Where(field+" >= ?", cond.From)
|
||||
} else if cond.From == 1 && cond.To != 0 {
|
||||
} else if cond.From == 0 && cond.To > 0 {
|
||||
return query.Where(field+" <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
@@ -293,11 +300,11 @@ func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuild
|
||||
|
||||
// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
|
||||
func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if cond.From != 1.0 && cond.To != 0.0 {
|
||||
if cond.From > 0.0 && cond.To > 0.0 {
|
||||
return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1.0 && cond.To == 0.0 {
|
||||
} else if cond.From > 0.0 && cond.To == 0.0 {
|
||||
return query.Where(field+" >= ?", cond.From)
|
||||
} else if cond.From == 1.0 && cond.To != 0.0 {
|
||||
} else if cond.From == 0.0 && cond.To > 0.0 {
|
||||
return query.Where(field+" <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
@@ -336,14 +343,24 @@ func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBui
|
||||
}
|
||||
}
|
||||
|
||||
// validMetricName guards metric/footprint names that are interpolated into the
|
||||
// json_extract() path of footprint queries. SQLite treats double-quoted strings
|
||||
// as string literals, so an unescaped name (e.g. containing a `"`) would allow
|
||||
// SQL injection. Legitimate metric names only use these characters.
|
||||
var validMetricName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
|
||||
|
||||
// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
|
||||
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
|
||||
if !validMetricName.MatchString(jsonField) {
|
||||
cclog.Warnf("buildFloatJSONCondition: rejecting invalid metric name %q", jsonField)
|
||||
return query.Where("0 = 1")
|
||||
}
|
||||
query = query.Where("JSON_VALID(footprint)")
|
||||
if cond.From != 1.0 && cond.To != 0.0 {
|
||||
if cond.From > 0.0 && cond.To > 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
|
||||
} else if cond.From != 1.0 && cond.To == 0.0 {
|
||||
} else if cond.From > 0.0 && cond.To == 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
|
||||
} else if cond.From == 1.0 && cond.To != 0.0 {
|
||||
} else if cond.From == 0.0 && cond.To > 0.0 {
|
||||
return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
|
||||
} else {
|
||||
return query
|
||||
|
||||
@@ -909,6 +909,13 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
|
||||
filters []*model.JobFilter,
|
||||
bins *int,
|
||||
) (*model.MetricHistoPoints, error) {
|
||||
// The metric name is interpolated into the json_extract() path of the SQL
|
||||
// below. SQLite parses double-quoted strings as literals, so reject anything
|
||||
// that is not a plain metric identifier to prevent SQL injection.
|
||||
if !validMetricName.MatchString(metric) {
|
||||
return nil, fmt.Errorf("invalid metric name: %q", metric)
|
||||
}
|
||||
|
||||
// Peak value defines the upper bound for binning: values are distributed across
|
||||
// bins from 0 to peak. First try to get peak from filtered cluster, otherwise
|
||||
// scan all clusters to find the maximum peak value.
|
||||
|
||||
@@ -311,26 +311,33 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
|
||||
LeftJoin("jobtag jt ON t.id = jt.tag_id").
|
||||
GroupBy("t.tag_type, t.tag_name")
|
||||
|
||||
// Build scope list for filtering
|
||||
var scopeBuilder strings.Builder
|
||||
scopeBuilder.WriteString(`"global"`)
|
||||
// Build scope list for filtering. Values are parameterized rather than
|
||||
// interpolated because user.Username originates from external identity
|
||||
// providers (OIDC/LDAP) and must not be trusted as SQL.
|
||||
scopes := []string{"global"}
|
||||
if user != nil {
|
||||
scopeBuilder.WriteString(`,"`)
|
||||
scopeBuilder.WriteString(user.Username)
|
||||
scopeBuilder.WriteString(`"`)
|
||||
scopes = append(scopes, user.Username)
|
||||
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
|
||||
scopeBuilder.WriteString(`,"admin"`)
|
||||
scopes = append(scopes, "admin")
|
||||
}
|
||||
}
|
||||
q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
|
||||
q = q.Where(sq.Eq{"t.tag_scope": scopes})
|
||||
|
||||
// Handle Job Ownership
|
||||
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
|
||||
// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
|
||||
// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
|
||||
} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
|
||||
// Build ("project1", "project2", ...) list of variable length directly in SQL string
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
|
||||
} else if user != nil && user.HasRole(schema.RoleManager) && len(user.Projects) > 0 { // MANAGER: Count own jobs plus project's jobs
|
||||
// Build a parameterized ("?", "?", ...) placeholder list for the
|
||||
// variable-length project set instead of interpolating values into SQL.
|
||||
args := make([]any, 0, len(user.Projects)+1)
|
||||
args = append(args, user.Username)
|
||||
placeholders := make([]string, len(user.Projects))
|
||||
for i, p := range user.Projects {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, p)
|
||||
}
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN ("+strings.Join(placeholders, ",")+"))", args...)
|
||||
} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
|
||||
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -210,6 +211,12 @@ func (r *UserRepository) AddUserIfNotExists(user *schema.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func sortedRoles(roles []string) []string {
|
||||
cp := append([]string{}, roles...)
|
||||
sort.Strings(cp)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) error {
|
||||
// user contains updated info -> Apply to dbUser
|
||||
// --- Simple Name Update ---
|
||||
@@ -279,6 +286,15 @@ func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) erro
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: sync any remaining role differences not covered above ---
|
||||
// This handles admin role assignment/removal and any other combinations that
|
||||
// the specific branches above do not cover (e.g. user→admin, admin→user).
|
||||
if !reflect.DeepEqual(sortedRoles(dbUser.Roles), sortedRoles(user.Roles)) {
|
||||
if err := updateRoles(user.Roles); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,9 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if query.Get("cluster") != "" {
|
||||
filterPresets["cluster"] = query.Get("cluster")
|
||||
}
|
||||
if query.Get("subCluster") != "" {
|
||||
filterPresets["subCluster"] = query.Get("subCluster")
|
||||
}
|
||||
if query.Get("partition") != "" {
|
||||
filterPresets["partition"] = query.Get("partition")
|
||||
}
|
||||
@@ -309,7 +312,7 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if parts[0] == "lessthan" {
|
||||
lt, lte := strconv.Atoi(parts[1])
|
||||
if lte == nil {
|
||||
filterPresets["numNodes"] = map[string]int{"from": 1, "to": lt}
|
||||
filterPresets["numNodes"] = map[string]int{"from": 0, "to": lt}
|
||||
}
|
||||
} else if parts[0] == "morethan" {
|
||||
mt, mte := strconv.Atoi(parts[1])
|
||||
@@ -331,7 +334,7 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if parts[0] == "lessthan" {
|
||||
lt, lte := strconv.Atoi(parts[1])
|
||||
if lte == nil {
|
||||
filterPresets["numHWThreads"] = map[string]int{"from": 1, "to": lt}
|
||||
filterPresets["numHWThreads"] = map[string]int{"from": 0, "to": lt}
|
||||
}
|
||||
} else if parts[0] == "morethan" {
|
||||
mt, mte := strconv.Atoi(parts[1])
|
||||
@@ -353,7 +356,7 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if parts[0] == "lessthan" {
|
||||
lt, lte := strconv.Atoi(parts[1])
|
||||
if lte == nil {
|
||||
filterPresets["numAccelerators"] = map[string]int{"from": 1, "to": lt}
|
||||
filterPresets["numAccelerators"] = map[string]int{"from": 0, "to": lt}
|
||||
}
|
||||
} else if parts[0] == "morethan" {
|
||||
mt, mte := strconv.Atoi(parts[1])
|
||||
@@ -409,7 +412,7 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if parts[0] == "lessthan" {
|
||||
lt, lte := strconv.Atoi(parts[1])
|
||||
if lte == nil {
|
||||
filterPresets["energy"] = map[string]int{"from": 1, "to": lt}
|
||||
filterPresets["energy"] = map[string]int{"from": 0, "to": lt}
|
||||
}
|
||||
} else if parts[0] == "morethan" {
|
||||
mt, mte := strconv.Atoi(parts[1])
|
||||
@@ -435,7 +438,7 @@ func buildFilterPresets(query url.Values) map[string]any {
|
||||
if lte == nil {
|
||||
statEntry := map[string]any{
|
||||
"field": parts[0],
|
||||
"from": 1,
|
||||
"from": 0,
|
||||
"to": lt,
|
||||
}
|
||||
statList = append(statList, statEntry)
|
||||
|
||||
@@ -363,7 +363,7 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
||||
for _, m := range ri.metrics {
|
||||
stats, ok := jobStats[m]
|
||||
if !ok {
|
||||
cclog.Errorf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
|
||||
cclog.Debugf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID)
|
||||
skipRule = true
|
||||
break
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
||||
for _, r := range ri.requirements {
|
||||
ok, err := expr.Run(r, env)
|
||||
if err != nil {
|
||||
cclog.Errorf("error running requirement for rule %s: %#v", tag, err)
|
||||
cclog.Debugf("error running requirement for rule %s: %#v", tag, err)
|
||||
requirementsMet = false
|
||||
break
|
||||
}
|
||||
@@ -407,7 +407,7 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
||||
for _, v := range ri.variables {
|
||||
value, err := expr.Run(v.expr, env)
|
||||
if err != nil {
|
||||
cclog.Errorf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
|
||||
cclog.Debugf("error evaluating variable %s for rule %s: %#v", v.name, tag, err)
|
||||
varError = true
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user