From 7631591f307a84c5893a21c32ef224f2b4a92c9c Mon Sep 17 00:00:00 2001 From: squid Date: Sun, 19 Jul 2026 22:00:14 +0200 Subject: [PATCH] Wire action-group variables, sudo, and command path resolution through the API and UI. Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely. --- .../internal/api/action_groups_handlers.go | 138 +- .../api/action_groups_handlers_test.go | 391 ++++++ service/internal/api/actions_handlers.go | 42 +- service/internal/api/actions_handlers_test.go | 11 +- service/internal/api/auth_audit_test.go | 25 + service/internal/api/authz.go | 5 + service/internal/api/groups_handlers.go | 27 +- service/internal/api/nodes_handlers_test.go | 57 + .../internal/api/nodes_resolve_commands.go | 142 ++ service/internal/api/router.go | 1 + service/internal/api/setup_auth_handlers.go | 33 + service/internal/auth/resolve_commands.go | 101 ++ .../internal/auth/resolve_commands_test.go | 58 + service/internal/auth/ssh_run.go | 13 +- webui/src/App.css | 235 +++- webui/src/App.test.tsx | 3 + webui/src/App.tsx | 27 + webui/src/NodeDetailPanel.tsx | 1222 ++++++++++++++--- webui/src/actionSudoers.test.ts | 188 +++ webui/src/actionSudoers.ts | 194 +++ webui/src/api/client.ts | 44 + webui/src/index.css | 2 + 22 files changed, 2685 insertions(+), 274 deletions(-) create mode 100644 service/internal/api/nodes_resolve_commands.go create mode 100644 service/internal/auth/resolve_commands.go create mode 100644 service/internal/auth/resolve_commands_test.go create mode 100644 webui/src/actionSudoers.test.ts create mode 100644 webui/src/actionSudoers.ts diff --git a/service/internal/api/action_groups_handlers.go b/service/internal/api/action_groups_handlers.go index a3fbeb9..1acecac 100644 --- a/service/internal/api/action_groups_handlers.go +++ b/service/internal/api/action_groups_handlers.go @@ -9,6 +9,7 @@ import ( "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/runner" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) @@ -50,14 +51,20 @@ type createActionGroupItemRequest struct { Kind settings.ActionKind `json:"kind"` Body string `json:"body"` Env []settings.ActionEnvVar `json:"env"` + RequiresSudo bool `json:"requires_sudo"` + SetVariable string `json:"set_variable"` + RunIf string `json:"run_if"` } type patchActionGroupItemRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - Kind *settings.ActionKind `json:"kind"` - Body *string `json:"body"` - Env *[]settings.ActionEnvVar `json:"env"` + Name *string `json:"name"` + Description *string `json:"description"` + Kind *settings.ActionKind `json:"kind"` + Body *string `json:"body"` + Env *[]settings.ActionEnvVar `json:"env"` + RequiresSudo *bool `json:"requires_sudo"` + SetVariable *string `json:"set_variable"` + RunIf *string `json:"run_if"` } type reorderActionGroupItemsRequest struct { @@ -325,32 +332,55 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request return } item := store.Groups[groupIndex].Items[itemIndex] - if item.Source != settings.ActionItemSourceLocal { + editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil + if item.Source != settings.ActionItemSourceLocal && editingBodyFields { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"}) return } - if payload.Name != nil { - item.Name = strings.TrimSpace(*payload.Name) + if item.Source == settings.ActionItemSourceLocal { + if payload.Name != nil { + item.Name = strings.TrimSpace(*payload.Name) + } + if payload.Description != nil { + item.Description = strings.TrimSpace(*payload.Description) + } + if payload.Kind != nil { + item.Kind = *payload.Kind + } + if payload.Body != nil { + item.Body = strings.TrimSpace(*payload.Body) + } + if payload.Env != nil { + item.Env = *payload.Env + } + if payload.RequiresSudo != nil { + item.RequiresSudo = *payload.RequiresSudo + } + if item.Env == nil { + item.Env = []settings.ActionEnvVar{} + } + if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } } - if payload.Description != nil { - item.Description = strings.TrimSpace(*payload.Description) + + if payload.SetVariable != nil { + setVariable, err := normalizeSetVariable(*payload.SetVariable) + if err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + item.SetVariable = setVariable } - if payload.Kind != nil { - item.Kind = *payload.Kind - } - if payload.Body != nil { - item.Body = strings.TrimSpace(*payload.Body) - } - if payload.Env != nil { - item.Env = *payload.Env - } - if item.Env == nil { - item.Env = []settings.ActionEnvVar{} - } - if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil { - writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) - return + if payload.RunIf != nil { + runIf, err := normalizeRunIf(*payload.RunIf) + if err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + item.RunIf = runIf } store.Groups[groupIndex].Items[itemIndex] = item @@ -425,6 +455,7 @@ func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request item.Kind = libraryAction.Kind item.Body = libraryAction.Body item.Env = env + item.RequiresSudo = libraryAction.RequiresSudo store.Groups[groupIndex].Items[itemIndex] = item store.Groups[groupIndex].UpdatedAt = time.Now().UTC() @@ -534,7 +565,7 @@ func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request } func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) { - if err := app.authorizeJobsRun(request); err != nil { + if err := authorizeActionsExecute(request, app.ConfigDir); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } @@ -565,7 +596,7 @@ func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http } func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) { - if err := app.authorizeJobsRun(request); err != nil { + if err := authorizeActionsExecute(request, app.ConfigDir); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } @@ -700,6 +731,14 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct if err != nil { return settings.NodeActionItem{}, err } + setVariable, err := normalizeSetVariable(payload.SetVariable) + if err != nil { + return settings.NodeActionItem{}, err + } + runIf, err := normalizeRunIf(payload.RunIf) + if err != nil { + return settings.NodeActionItem{}, err + } switch payload.Source { case settings.ActionItemSourceLibrary: libraryID := strings.TrimSpace(payload.LibraryActionID) @@ -711,6 +750,8 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct Source: settings.ActionItemSourceLibrary, LibraryActionID: libraryID, Env: []settings.ActionEnvVar{}, + SetVariable: setVariable, + RunIf: runIf, }, nil case settings.ActionItemSourceLocal: env := payload.Env @@ -723,19 +764,48 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct return settings.NodeActionItem{}, err } return settings.NodeActionItem{ - ID: itemID, - Source: settings.ActionItemSourceLocal, - Name: name, - Description: strings.TrimSpace(payload.Description), - Kind: payload.Kind, - Body: body, - Env: env, + ID: itemID, + Source: settings.ActionItemSourceLocal, + Name: name, + Description: strings.TrimSpace(payload.Description), + Kind: payload.Kind, + Body: body, + Env: env, + RequiresSudo: payload.RequiresSudo, + SetVariable: setVariable, + RunIf: runIf, }, nil default: return settings.NodeActionItem{}, errors.New("source must be library or local") } } +func normalizeSetVariable(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "$") { + trimmed = strings.TrimPrefix(trimmed, "$") + trimmed = strings.TrimSpace(trimmed) + } + if err := runner.ValidateVariableName(trimmed); err != nil { + return "", err + } + return trimmed, nil +} + +func normalizeRunIf(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + if err := runner.ValidateCondition(trimmed); err != nil { + return "", err + } + return trimmed, nil +} + func validateSchedule(schedule settings.ActionGroupSchedule) error { switch schedule.Kind { case settings.ActionGroupScheduleInterval: diff --git a/service/internal/api/action_groups_handlers_test.go b/service/internal/api/action_groups_handlers_test.go index 21ddc9f..3a7f1d9 100644 --- a/service/internal/api/action_groups_handlers_test.go +++ b/service/internal/api/action_groups_handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -168,6 +169,9 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) { if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" { t.Fatalf("cloned item = %#v", clonedItem) } + if clonedItem.RequiresSudo { + t.Fatalf("builtin uptime clone should not require sudo: %#v", clonedItem) + } // Forbidden without actions.create for a limited user session is covered below via groups. listReq := withSession( @@ -191,6 +195,218 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) { } } +func TestActionGroupItemVariablesCreateAndPatch(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + node := seedNodeForActions(t, configDir) + if _, err := settings.LoadActionsOrSeed(configDir); err != nil { + t.Fatalf("LoadActionsOrSeed: %v", err) + } + router := NewRouter(configDir) + + createBody, _ := json.Marshal(map[string]any{ + "name": "Vars", + "schedule": map[string]any{ + "enabled": false, + "kind": "interval", + "times": []any{}, + }, + }) + createReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups", + bytes.NewReader(createBody), + ), + cookie, + ) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var created actionGroupResponse + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create: %v", err) + } + groupID := created.Group.ID + + addLocalBody, _ := json.Marshal(map[string]any{ + "source": "local", + "name": "Count", + "kind": "shell", + "body": "echo 3", + "env": []any{}, + "set_variable": "UpdateCount", + "run_if": "", + }) + addLocalReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", + bytes.NewReader(addLocalBody), + ), + cookie, + ) + addLocalRec := httptest.NewRecorder() + router.ServeHTTP(addLocalRec, addLocalReq) + if addLocalRec.Code != http.StatusOK { + t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String()) + } + var withLocal actionGroupResponse + if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil { + t.Fatalf("decode local: %v", err) + } + if withLocal.Group.Items[0].SetVariable != "UpdateCount" { + t.Fatalf("set_variable = %#v", withLocal.Group.Items[0]) + } + localID := withLocal.Group.Items[0].ID + + addLibBody, _ := json.Marshal(map[string]any{ + "source": "library", + "library_action_id": settings.BuiltinActionUptimeID, + "set_variable": "$Bad-Name", + }) + badLibReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", + bytes.NewReader(addLibBody), + ), + cookie, + ) + badLibRec := httptest.NewRecorder() + router.ServeHTTP(badLibRec, badLibReq) + if badLibRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String()) + } + + addLibOKBody, _ := json.Marshal(map[string]any{ + "source": "library", + "library_action_id": settings.BuiltinActionUptimeID, + }) + addLibReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", + bytes.NewReader(addLibOKBody), + ), + cookie, + ) + addLibRec := httptest.NewRecorder() + router.ServeHTTP(addLibRec, addLibReq) + if addLibRec.Code != http.StatusOK { + t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String()) + } + var withLib actionGroupResponse + _ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib) + var libID string + for _, item := range withLib.Group.Items { + if item.Source == settings.ActionItemSourceLibrary { + libID = item.ID + break + } + } + if libID == "" { + t.Fatal("library item missing") + } + + patchLibBody, _ := json.Marshal(map[string]any{ + "run_if": "if $UpdateCount >= 1", + "set_variable": "UptimeOut", + }) + patchLibReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID, + bytes.NewReader(patchLibBody), + ), + cookie, + ) + patchLibRec := httptest.NewRecorder() + router.ServeHTTP(patchLibRec, patchLibReq) + if patchLibRec.Code != http.StatusOK { + t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String()) + } + var patchedLib actionGroupResponse + _ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib) + var libItem settings.NodeActionItem + for _, item := range patchedLib.Group.Items { + if item.ID == libID { + libItem = item + break + } + } + if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" { + t.Fatalf("library vars = %#v", libItem) + } + + badBodyPatch, _ := json.Marshal(map[string]any{ + "body": "echo no", + }) + badBodyReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID, + bytes.NewReader(badBodyPatch), + ), + cookie, + ) + badBodyRec := httptest.NewRecorder() + router.ServeHTTP(badBodyRec, badBodyReq) + if badBodyRec.Code != http.StatusBadRequest { + t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code) + } + + badCondBody, _ := json.Marshal(map[string]any{ + "run_if": "not valid", + }) + badCondReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID, + bytes.NewReader(badCondBody), + ), + cookie, + ) + badCondRec := httptest.NewRecorder() + router.ServeHTTP(badCondRec, badCondReq) + if badCondRec.Code != http.StatusBadRequest { + t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String()) + } + + sudoLocalBody, _ := json.Marshal(map[string]any{ + "requires_sudo": true, + }) + sudoLocalReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID, + bytes.NewReader(sudoLocalBody), + ), + cookie, + ) + sudoLocalRec := httptest.NewRecorder() + router.ServeHTTP(sudoLocalRec, sudoLocalReq) + if sudoLocalRec.Code != http.StatusOK { + t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String()) + } + var withSudo actionGroupResponse + _ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo) + foundSudo := false + for _, item := range withSudo.Group.Items { + if item.ID == localID { + foundSudo = true + if !item.RequiresSudo { + t.Fatalf("expected requires_sudo on local item, got %#v", item) + } + } + } + if !foundSudo { + t.Fatal("local item missing after sudo patch") + } +} + func TestActionGroupsForbiddenWithoutPermission(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) @@ -222,3 +438,178 @@ func TestActionGroupsForbiddenWithoutPermission(t *testing.T) { t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String()) } } + +func TestActionGroupRunRequiresActionsExecute(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + node := seedNodeForActions(t, configDir) + if _, err := settings.LoadActionsOrSeed(configDir); err != nil { + t.Fatalf("LoadActionsOrSeed: %v", err) + } + + // Create a group while still admin, then strip execute (+ jobs.run so + // migration does not re-grant actions.execute). + router := NewRouter(configDir) + createBody, _ := json.Marshal(map[string]any{ + "name": "RunAuth", + "schedule": map[string]any{ + "enabled": false, + "kind": "interval", + "times": []any{}, + }, + }) + createReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups", + bytes.NewReader(createBody), + ), + cookie, + ) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var created actionGroupResponse + _ = json.Unmarshal(createRec.Body.Bytes(), &created) + + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"} + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + runReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run", + nil, + ), + cookie, + ) + runRec := httptest.NewRecorder() + router.ServeHTTP(runRec, runReq) + if runRec.Code != http.StatusForbidden { + t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String()) + } + if !strings.Contains(runRec.Body.String(), "actions.execute") { + t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String()) + } +} + +func TestActionGroupCloneCopiesRequiresSudo(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + node := seedNodeForActions(t, configDir) + if _, err := settings.LoadActionsOrSeed(configDir); err != nil { + t.Fatalf("LoadActionsOrSeed: %v", err) + } + router := NewRouter(configDir) + + createActionBody, _ := json.Marshal(map[string]any{ + "name": "Restart svc", + "description": "", + "kind": "shell", + "body": "/usr/bin/systemctl restart myservice", + "env": []any{}, + "requires_sudo": true, + }) + createActionReq := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)), + cookie, + ) + createActionRec := httptest.NewRecorder() + router.ServeHTTP(createActionRec, createActionReq) + if createActionRec.Code != http.StatusOK { + t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String()) + } + var actionsPayload actionsResponseTest + if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil { + t.Fatalf("decode actions: %v", err) + } + var libraryID string + for _, action := range actionsPayload.Actions { + if action.Name == "Restart svc" { + libraryID = action.ID + if !action.RequiresSudo { + t.Fatalf("library action missing requires_sudo: %#v", action) + } + break + } + } + if libraryID == "" { + t.Fatal("library action not found") + } + + createGroupBody, _ := json.Marshal(map[string]any{ + "name": "SudoGrp", + "schedule": map[string]any{ + "enabled": false, + "kind": "interval", + "times": []any{}, + }, + }) + createGroupReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups", + bytes.NewReader(createGroupBody), + ), + cookie, + ) + createGroupRec := httptest.NewRecorder() + router.ServeHTTP(createGroupRec, createGroupReq) + if createGroupRec.Code != http.StatusOK { + t.Fatalf("create group status=%d", createGroupRec.Code) + } + var createdGroup actionGroupResponse + _ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup) + + addItemBody, _ := json.Marshal(map[string]any{ + "source": "library", + "library_action_id": libraryID, + }) + addItemReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items", + bytes.NewReader(addItemBody), + ), + cookie, + ) + addItemRec := httptest.NewRecorder() + router.ServeHTTP(addItemRec, addItemReq) + if addItemRec.Code != http.StatusOK { + t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String()) + } + var withItem actionGroupResponse + _ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem) + itemID := withItem.Group.Items[0].ID + + cloneReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items/"+itemID+"/clone", + nil, + ), + cookie, + ) + cloneRec := httptest.NewRecorder() + router.ServeHTTP(cloneRec, cloneReq) + if cloneRec.Code != http.StatusOK { + t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String()) + } + var cloned actionGroupResponse + _ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned) + clonedItem := cloned.Group.Items[0] + if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo { + t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem) + } + if clonedItem.Body != "/usr/bin/systemctl restart myservice" { + t.Fatalf("cloned body = %q", clonedItem.Body) + } +} diff --git a/service/internal/api/actions_handlers.go b/service/internal/api/actions_handlers.go index 95432d1..e6b4ff7 100644 --- a/service/internal/api/actions_handlers.go +++ b/service/internal/api/actions_handlers.go @@ -17,18 +17,20 @@ type actionsResponse struct { } type createActionRequest struct { - Name string `json:"name"` - Description string `json:"description"` - Kind settings.ActionKind `json:"kind"` - Body string `json:"body"` - Env []settings.ActionEnvVar `json:"env"` + Name string `json:"name"` + Description string `json:"description"` + Kind settings.ActionKind `json:"kind"` + Body string `json:"body"` + Env []settings.ActionEnvVar `json:"env"` + RequiresSudo bool `json:"requires_sudo"` } type patchActionRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - Body *string `json:"body"` - Env *[]settings.ActionEnvVar `json:"env"` + Name *string `json:"name"` + Description *string `json:"description"` + Body *string `json:"body"` + Env *[]settings.ActionEnvVar `json:"env"` + RequiresSudo *bool `json:"requires_sudo"` } var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) @@ -136,6 +138,9 @@ func actionsPatchHandler(configDir string) http.HandlerFunc { if payload.Env != nil { updated.Env = *payload.Env } + if payload.RequiresSudo != nil { + updated.RequiresSudo = *payload.RequiresSudo + } updated.UpdatedAt = time.Now().UTC() if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil { @@ -215,15 +220,16 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) { now := time.Now().UTC() return settings.Action{ - ID: actionID, - Name: name, - Description: description, - Kind: payload.Kind, - Body: body, - Env: env, - Builtin: false, - CreatedAt: now, - UpdatedAt: now, + ID: actionID, + Name: name, + Description: description, + Kind: payload.Kind, + Body: body, + Env: env, + RequiresSudo: payload.RequiresSudo, + Builtin: false, + CreatedAt: now, + UpdatedAt: now, }, nil } diff --git a/service/internal/api/actions_handlers_test.go b/service/internal/api/actions_handlers_test.go index 0b9cd52..3794e22 100644 --- a/service/internal/api/actions_handlers_test.go +++ b/service/internal/api/actions_handlers_test.go @@ -51,7 +51,8 @@ func TestActionsCreatePatchDelete(t *testing.T) { "description": "Print the target IP", "kind": "shell", "body": "echo {{node.ip}}", - "env": [{"name": "APP_HOME", "value": "/opt/app"}] + "env": [{"name": "APP_HOME", "value": "/opt/app"}], + "requires_sudo": true }`) createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie) createRec := httptest.NewRecorder() @@ -71,8 +72,11 @@ func TestActionsCreatePatchDelete(t *testing.T) { if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell { t.Fatalf("custom = %#v", custom) } + if !custom.RequiresSudo { + t.Fatalf("expected requires_sudo true, got %#v", custom) + } - patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`) + patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}","requires_sudo":false}`) patchReq := withSession( httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)), cookie, @@ -94,6 +98,9 @@ func TestActionsCreatePatchDelete(t *testing.T) { if action.Name != "Echo target" || action.Body != "echo {{node.host}}" { t.Fatalf("patched = %#v", action) } + if action.RequiresSudo { + t.Fatalf("expected requires_sudo false after patch, got %#v", action) + } } } if !found { diff --git a/service/internal/api/auth_audit_test.go b/service/internal/api/auth_audit_test.go index d391f92..ee88a3a 100644 --- a/service/internal/api/auth_audit_test.go +++ b/service/internal/api/auth_audit_test.go @@ -201,6 +201,31 @@ func TestMigrateLogsReadPermission(t *testing.T) { } } +func TestMigrateActionsExecutePermission(t *testing.T) { + payload := settings.Settings{ + Groups: []settings.Group{ + {Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}}, + {Name: "already", Permissions: []string{"jobs.run", "actions.execute"}}, + {Name: "viewers", Permissions: []string{"jobs.read"}}, + }, + } + if !migrateActionsExecutePermission(&payload) { + t.Fatal("expected migration to change settings") + } + if !containsPermission(payload.Groups[0].Permissions, "actions.execute") { + t.Fatal("runners should gain actions.execute from jobs.run") + } + if len(payload.Groups[1].Permissions) != 2 { + t.Fatal("already should be unchanged") + } + if containsPermission(payload.Groups[2].Permissions, "actions.execute") { + t.Fatal("viewers should not gain actions.execute") + } + if migrateActionsExecutePermission(&payload) { + t.Fatal("second migration should be a no-op") + } +} + func containsPermission(permissions []string, wanted string) bool { for _, permission := range permissions { if permission == wanted { diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go index 5e939da..5aab6b0 100644 --- a/service/internal/api/authz.go +++ b/service/internal/api/authz.go @@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required") var errActionsCreateRequired = errors.New("permission actions.create required") var errActionsUpdateRequired = errors.New("permission actions.update required") var errActionsDeleteRequired = errors.New("permission actions.delete required") +var errActionsExecuteRequired = errors.New("permission actions.execute required") var errJobsRunRequired = errors.New("permission jobs.run required") var errLogsReadRequired = errors.New("permission logs.read required") @@ -162,6 +163,10 @@ func authorizeActionsDelete(request *http.Request, configDir string) error { return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired) } +func authorizeActionsExecute(request *http.Request, configDir string) error { + return authorizeNamedPermission(request, configDir, "actions.execute", errActionsExecuteRequired) +} + func (app *App) authorizeJobsRun(request *http.Request) error { user, ok := UserFromContext(request.Context()) if !ok { diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index e98b913..6e3203a 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -22,19 +22,20 @@ type upsertGroupRequest struct { } var allowedPermissions = map[string]struct{}{ - "nodes.read": {}, - "nodes.exec": {}, - "nodes.update": {}, - "nodes.delete": {}, - "jobs.read": {}, - "jobs.run": {}, - "actions.create": {}, - "actions.update": {}, - "actions.delete": {}, - "users.manage": {}, - "secrets.manage": {}, - "roles.manage": {}, - "logs.read": {}, + "nodes.read": {}, + "nodes.exec": {}, + "nodes.update": {}, + "nodes.delete": {}, + "jobs.read": {}, + "jobs.run": {}, + "actions.create": {}, + "actions.update": {}, + "actions.delete": {}, + "actions.execute": {}, + "users.manage": {}, + "secrets.manage": {}, + "roles.manage": {}, + "logs.read": {}, } func groupsGetHandler(configDir string) http.HandlerFunc { diff --git a/service/internal/api/nodes_handlers_test.go b/service/internal/api/nodes_handlers_test.go index cf62965..fabb603 100644 --- a/service/internal/api/nodes_handlers_test.go +++ b/service/internal/api/nodes_handlers_test.go @@ -357,6 +357,63 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) { } } +func TestNodesResolveCommandsRejectsInvalidNames(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + createBody := []byte(`{ + "id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "kind":"container", + "name":"resolve-test", + "host_ip":"127.0.0.1", + "group_name":"Administrators", + "generate":{"algorithm":"ed25519"} + }`) + createRequest := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), + cookie, + ) + createRecorder := httptest.NewRecorder() + router.ServeHTTP(createRecorder, createRequest) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String()) + } + + badBody := []byte(`{"names":["/usr/bin/apt"]}`) + badRequest := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands", + bytes.NewReader(badBody), + ), + cookie, + ) + badRecorder := httptest.NewRecorder() + router.ServeHTTP(badRecorder, badRequest) + if badRecorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", badRecorder.Code, badRecorder.Body.String()) + } + if !strings.Contains(badRecorder.Body.String(), "basename") { + t.Fatalf("expected basename error, got %s", badRecorder.Body.String()) + } + + emptyBody := []byte(`{"names":[]}`) + emptyRequest := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands", + bytes.NewReader(emptyBody), + ), + cookie, + ) + emptyRecorder := httptest.NewRecorder() + router.ServeHTTP(emptyRecorder, emptyRequest) + if emptyRecorder.Code != http.StatusBadRequest { + t.Fatalf("empty status = %d body=%s", emptyRecorder.Code, emptyRecorder.Body.String()) + } +} + func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) diff --git a/service/internal/api/nodes_resolve_commands.go b/service/internal/api/nodes_resolve_commands.go new file mode 100644 index 0000000..1f10b3b --- /dev/null +++ b/service/internal/api/nodes_resolve_commands.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type resolveCommandsRequest struct { + Names []string `json:"names"` +} + +type resolveCommandsResponse struct { + Paths map[string]string `json:"paths"` + Missing []string `json:"missing"` +} + +const resolveCommandsTimeout = 30 * time.Second + +func (app *App) nodesResolveCommandsHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesExec(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + if err := app.requireKey(); err != nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) + return + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + nodeID := strings.TrimSpace(request.PathValue("id")) + if nodeID == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"}) + return + } + + var payload resolveCommandsRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + names, err := auth.ValidateCommandNames(payload.Names) + if err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + var node settings.Node + found := false + for _, candidate := range store.Nodes { + if candidate.ID != nodeID { + continue + } + node = candidate + found = true + break + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) + return + } + if !userCanAccessNode(user, settingsPayload.Groups, node) { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) + return + } + + keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + var keyEntry settings.NodeKeyEntry + keyFound := false + for _, entry := range keyStore.Keys { + if entry.NodeID == node.ID { + keyEntry = entry + keyFound = true + break + } + } + if !keyFound { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{ + Error: "private key not found for node", + }) + return + } + + remoteCommand := auth.BuildResolveCommandsRemote(names) + result, runErr := auth.RunSSHShell( + node.HostIP, + node.Username, + keyEntry.PrivateKey, + keyEntry.Passphrase, + remoteCommand, + nil, + resolveCommandsTimeout, + ) + if runErr != nil { + writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: runErr.Error()}) + return + } + if result.ExitCode != 0 { + message := strings.TrimSpace(result.Stderr) + if message == "" { + message = "resolve-commands failed on node" + } + writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: message}) + return + } + + paths, missing := auth.ParseResolveCommandsOutput(names, result.Stdout) + if missing == nil { + missing = []string{} + } + writeJSON(writer, http.StatusOK, resolveCommandsResponse{ + Paths: paths, + Missing: missing, + }) +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 81d2e2c..350452a 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -64,6 +64,7 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) { mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler) mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler) mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler) mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler) diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index 6be7acb..4b0f7bb 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -511,6 +511,7 @@ func allPermissionList() []string { "actions.create", "actions.update", "actions.delete", + "actions.execute", "users.manage", "secrets.manage", "roles.manage", @@ -540,7 +541,14 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) { payload.Groups = []settings.Group{} } payload.Network = settings.EffectiveNetwork(payload.Network) + changed := false if migrateLogsReadPermission(&payload) { + changed = true + } + if migrateActionsExecutePermission(&payload) { + changed = true + } + if changed { if err := settings.SaveSettings(configDir, payload); err != nil { return settings.Settings{}, err } @@ -572,3 +580,28 @@ func migrateLogsReadPermission(payload *settings.Settings) bool { } return changed } + +// migrateActionsExecutePermission grants actions.execute to groups that +// previously could run actions via jobs.run. Returns true when settings changed. +func migrateActionsExecutePermission(payload *settings.Settings) bool { + changed := false + for index := range payload.Groups { + group := &payload.Groups[index] + hasActionsExecute := false + hasJobsRun := false + for _, permission := range group.Permissions { + switch permission { + case "actions.execute": + hasActionsExecute = true + case "jobs.run": + hasJobsRun = true + } + } + if hasActionsExecute || !hasJobsRun { + continue + } + group.Permissions = append(group.Permissions, "actions.execute") + changed = true + } + return changed +} diff --git a/service/internal/auth/resolve_commands.go b/service/internal/auth/resolve_commands.go new file mode 100644 index 0000000..5bd66ad --- /dev/null +++ b/service/internal/auth/resolve_commands.go @@ -0,0 +1,101 @@ +package auth + +import ( + "fmt" + "strings" +) + +const MaxResolveCommandNames = 32 + +// ValidateCommandNames checks relative command basenames for resolve-commands. +func ValidateCommandNames(names []string) ([]string, error) { + if len(names) == 0 { + return nil, fmt.Errorf("names is required") + } + if len(names) > MaxResolveCommandNames { + return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames) + } + + cleaned := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + return nil, fmt.Errorf("command name must not be empty") + } + if strings.Contains(name, "/") { + return nil, fmt.Errorf("command name must be a basename without /") + } + if len(name) > 64 { + return nil, fmt.Errorf("command name too long") + } + for _, runeValue := range name { + if (runeValue >= 'a' && runeValue <= 'z') || + (runeValue >= 'A' && runeValue <= 'Z') || + (runeValue >= '0' && runeValue <= '9') || + runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' { + continue + } + return nil, fmt.Errorf("command name contains invalid characters") + } + if _, exists := seen[name]; exists { + continue + } + seen[name] = struct{}{} + cleaned = append(cleaned, name) + } + if len(cleaned) == 0 { + return nil, fmt.Errorf("names is required") + } + return cleaned, nil +} + +// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines. +func BuildResolveCommandsRemote(names []string) string { + var builder strings.Builder + for _, name := range names { + quoted := shellQuote(name) + builder.WriteString("path=$(command -v -- ") + builder.WriteString(quoted) + builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ") + builder.WriteString(quoted) + builder.WriteString(" \"$path\"; ") + } + return builder.String() +} + +// ParseResolveCommandsOutput parses name=path lines into found paths and missing names. +func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) { + paths = make(map[string]string, len(names)) + wanted := make(map[string]struct{}, len(names)) + for _, name := range names { + wanted[name] = struct{}{} + } + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, path, ok := strings.Cut(line, "=") + if !ok { + continue + } + name = strings.TrimSpace(name) + path = strings.TrimSpace(path) + if _, ok := wanted[name]; !ok { + continue + } + if path == "" || !strings.HasPrefix(path, "/") { + continue + } + paths[name] = path + } + + for _, name := range names { + if _, ok := paths[name]; !ok { + missing = append(missing, name) + } + } + return paths, missing +} diff --git a/service/internal/auth/resolve_commands_test.go b/service/internal/auth/resolve_commands_test.go new file mode 100644 index 0000000..ddbaf3f --- /dev/null +++ b/service/internal/auth/resolve_commands_test.go @@ -0,0 +1,58 @@ +package auth + +import ( + "fmt" + "strings" + "testing" +) + +func TestValidateCommandNames(t *testing.T) { + cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"}) + if err != nil { + t.Fatalf("valid names: %v", err) + } + if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" { + t.Fatalf("cleaned = %#v", cleaned) + } + + if _, err := ValidateCommandNames(nil); err == nil { + t.Fatal("expected error for empty names") + } + if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil { + t.Fatal("expected error for path") + } + if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil { + t.Fatal("expected error for metacharacters") + } + + tooMany := make([]string, MaxResolveCommandNames+1) + for index := range tooMany { + tooMany[index] = fmt.Sprintf("cmd%d", index) + } + if _, err := ValidateCommandNames(tooMany); err == nil { + t.Fatal("expected error for too many names") + } +} + +func TestParseResolveCommandsOutput(t *testing.T) { + paths, missing := ParseResolveCommandsOutput( + []string{"apt", "missing", "systemctl"}, + "apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n", + ) + if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" { + t.Fatalf("paths = %#v", paths) + } + if len(missing) != 1 || missing[0] != "missing" { + t.Fatalf("missing = %#v", missing) + } +} + +func TestBuildResolveCommandsRemote(t *testing.T) { + remote := BuildResolveCommandsRemote([]string{"apt"}) + if !strings.Contains(remote, "command -v -- 'apt'") { + t.Fatalf("remote = %q", remote) + } + if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") { + t.Fatalf("remote missing printf = %q", remote) + } +} diff --git a/service/internal/auth/ssh_run.go b/service/internal/auth/ssh_run.go index b7537f6..eaea4b9 100644 --- a/service/internal/auth/ssh_run.go +++ b/service/internal/auth/ssh_run.go @@ -32,11 +32,12 @@ func RunSSHShell( if strings.TrimSpace(command) == "" { return SSHRunResult{}, fmt.Errorf("command is required") } - fullCommand := prependEnvExports(env, command) + fullCommand := PrependEnvExports(env, command) return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout) } // RunSSHScript uploads and runs a multi-line script via bash -s on the remote host. +// When requiresSudo is true, the remote command is "sudo -n bash -s". func RunSSHScript( hostIP string, username string, @@ -45,6 +46,7 @@ func RunSSHScript( scriptBody string, env map[string]string, timeout time.Duration, + requiresSudo bool, ) (SSHRunResult, error) { if strings.TrimSpace(scriptBody) == "" { return SSHRunResult{}, fmt.Errorf("script body is required") @@ -95,7 +97,11 @@ func RunSSHScript( session.Stderr = &stderrBuf session.Stdin = strings.NewReader(scriptBody) - remoteCommand := prependEnvExports(env, "bash -s") + bashCommand := "bash -s" + if requiresSudo { + bashCommand = "sudo -n bash -s" + } + remoteCommand := PrependEnvExports(env, bashCommand) done := make(chan error, 1) go func() { done <- session.Run(remoteCommand) @@ -207,7 +213,8 @@ func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, err return result, err } -func prependEnvExports(env map[string]string, command string) string { +// PrependEnvExports builds the remote command string with optional export PREFIX; command. +func PrependEnvExports(env map[string]string, command string) string { if len(env) == 0 { return command } diff --git a/webui/src/App.css b/webui/src/App.css index d94fbbb..6ceabc6 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -1431,7 +1431,6 @@ .node-action-add-row, .node-action-group-heading, .node-action-group-toolbar, -.node-action-item-heading, .node-action-item-toolbar { display: flex; flex-wrap: wrap; @@ -1439,6 +1438,78 @@ align-items: center; } +.node-action-item-heading { + display: flex; + flex-wrap: nowrap; + gap: 0.75rem; + align-items: center; + width: 100%; +} + +.node-action-item-heading > span:first-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-action-item-toolbar { + flex: 0 0 auto; + flex-wrap: nowrap; + margin-left: auto; + justify-content: flex-end; +} + +.node-action-reorder { + display: inline-flex; + align-items: center; + gap: 0.15rem; +} + +.node-action-reorder-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + margin: 0; + padding: 0; + border: 1px solid var(--ctp-surface1); + border-radius: 0.25rem; + background: var(--ctp-mantle); + color: var(--ctp-overlay1); + cursor: pointer; + line-height: 0; +} + +.node-action-reorder-btn:hover:not(:disabled), +.node-action-reorder-btn:focus-visible:not(:disabled) { + color: var(--ctp-text); + background: var(--ctp-surface0); +} + +.node-action-reorder-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.node-action-group-heading { + justify-content: space-between; + width: 100%; +} + +.node-action-group-heading > input, +.node-action-group-heading > strong { + flex: 1 1 auto; + min-width: 0; +} + +.node-action-group-toolbar { + margin-left: auto; + justify-content: flex-end; +} + .node-action-group-list { list-style: none; margin: 1rem 0 0; @@ -1454,6 +1525,145 @@ background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent); } +.node-action-group-layout { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.node-action-group-main { + flex: 1 1 auto; + min-width: 0; +} + +.node-action-sudoers { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 0 0 22rem; + max-width: 26rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + background: var(--ctp-mantle); + position: sticky; + top: 1rem; +} + +.node-action-sudoers:not([open]) { + padding: 0.35rem 0.65rem; + gap: 0; +} + +.node-action-sudoers-summary { + display: flex; + align-items: center; + gap: 0.45rem; + cursor: pointer; + list-style: none; +} + +.node-action-sudoers-summary::-webkit-details-marker { + display: none; +} + +.node-action-sudoers-summary::marker { + content: ''; +} + +.node-action-sudoers h4 { + margin: 0; + font-size: 0.95rem; +} + +.node-action-sudoers-alert { + flex-shrink: 0; + width: 1em; + height: 1em; + color: var(--ctp-yellow); +} + +.node-action-sudoers-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.node-action-sudoers-resolve { + margin: 0.5rem 0 0; +} + +button.resolve-paths-needed { + color: var(--ctp-yellow); + border-color: var(--ctp-yellow); +} + +.copyable-code { + display: inline-flex; + align-items: flex-start; + gap: 0.35rem; + max-width: 100%; + padding: 0.25rem 0.35rem 0.25rem 0.45rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.3rem; + background: var(--ctp-crust); +} + +.copyable-code code { + flex: 1 1 auto; + min-width: 0; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.35; +} + +.copyable-code-copy { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0.15rem; + border: none; + border-radius: 0.2rem; + background: transparent; + color: var(--ctp-overlay1); + cursor: pointer; + line-height: 0; +} + +.copyable-code-copy:hover, +.copyable-code-copy:focus-visible { + color: var(--ctp-text); + background: var(--ctp-surface0); +} + +.copyable-code-icon { + display: block; +} + +.node-action-sudoers-list .copyable-code { + display: flex; + width: 100%; +} + +@media (max-width: 52rem) { + .node-action-group-layout { + flex-direction: column; + } + + .node-action-sudoers { + flex: 1 1 auto; + max-width: none; + width: 100%; + position: static; + } +} + .node-action-schedule { border: 1px solid var(--ctp-surface1); border-radius: 0.4rem; @@ -1470,6 +1680,13 @@ gap: 0.65rem; } +.node-action-item-editing { + padding: 0.65rem 0.75rem; + border: 1px solid var(--ctp-mauve); + border-radius: 0.35rem; + background: var(--ctp-mantle); +} + .node-action-item-edit, .node-action-add-custom, .node-action-adders { @@ -1478,7 +1695,12 @@ margin-top: 0.5rem; } -.schedule-times-editor, +.schedule-times-editor { + display: grid; + gap: 0.65rem; + width: 100%; +} + .schedule-time-row, .schedule-weekdays { display: flex; @@ -1487,6 +1709,10 @@ align-items: center; } +.schedule-time-row { + width: 100%; +} + .checkbox-row { display: inline-flex; align-items: center; @@ -1583,3 +1809,8 @@ button.danger { color: var(--ctp-red); } + +button.save-dirty { + color: var(--ctp-green); + border-color: var(--ctp-green); +} diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index a1510a0..4402ada 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -41,6 +41,7 @@ function stubFetchOk() { 'actions.create', 'actions.update', 'actions.delete', + 'actions.execute', 'users.manage', 'secrets.manage', 'roles.manage', @@ -93,6 +94,7 @@ function stubFetchOk() { 'actions.create', 'actions.update', 'actions.delete', + 'actions.execute', 'users.manage', 'secrets.manage', 'roles.manage', @@ -828,6 +830,7 @@ describe('App', () => { 'actions.create', 'actions.update', 'actions.delete', + 'actions.execute', 'users.manage', 'secrets.manage', 'roles.manage', diff --git a/webui/src/App.tsx b/webui/src/App.tsx index da85816..e09a173 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -149,6 +149,7 @@ const DEFAULT_PERMISSIONS = [ 'actions.create', 'actions.update', 'actions.delete', + 'actions.execute', 'users.manage', 'secrets.manage', 'roles.manage', @@ -1118,6 +1119,7 @@ function ActionsConfigTab() { const [draftDescription, setDraftDescription] = useState('') const [draftBody, setDraftBody] = useState('') const [draftEnv, setDraftEnv] = useState([emptyEnvRow()]) + const [draftRequiresSudo, setDraftRequiresSudo] = useState(false) const [isSaving, setIsSaving] = useState(false) const isEditing = editingActionId !== null @@ -1169,6 +1171,7 @@ function ActionsConfigTab() { setDraftDescription('') setDraftBody('') setDraftEnv([emptyEnvRow()]) + setDraftRequiresSudo(false) setSubmitError(null) } @@ -1185,6 +1188,7 @@ function ActionsConfigTab() { setDraftDescription('') setDraftBody('') setDraftEnv([emptyEnvRow()]) + setDraftRequiresSudo(false) setSubmitError(null) } @@ -1203,6 +1207,7 @@ function ActionsConfigTab() { ? action.env.map((envVar) => ({ ...envVar })) : [emptyEnvRow()], ) + setDraftRequiresSudo(Boolean(action.requires_sudo)) setSubmitError(null) } @@ -1250,6 +1255,7 @@ function ActionsConfigTab() { description: draftDescription, body: draftBody, env, + requires_sudo: draftRequiresSudo, }) : await createAction({ name: draftName, @@ -1257,6 +1263,7 @@ function ActionsConfigTab() { kind: editorKind, body: draftBody, env, + requires_sudo: draftRequiresSudo, }) setActions([...response.actions]) clearEditor() @@ -1342,6 +1349,11 @@ function ActionsConfigTab() { {action.builtin ? ( built-in ) : null} + {action.requires_sudo ? ( + + sudo + + ) : null}
{action.description || 'No description'} @@ -1521,8 +1533,23 @@ function ActionsConfigTab() { onChange={(event) => setDraftBody(event.target.value)} /> )} +

+ Do not include a leading sudo; check Requires Sudo + instead so ClusterCanvas runs with sudo -n. +

+ +

diff --git a/webui/src/NodeDetailPanel.tsx b/webui/src/NodeDetailPanel.tsx index f5d454d..ec73dda 100644 --- a/webui/src/NodeDetailPanel.tsx +++ b/webui/src/NodeDetailPanel.tsx @@ -23,9 +23,16 @@ import { patchActionGroup, patchActionGroupItem, reorderActionGroupItems, + resolveNodeCommands, runActionGroup, runActionGroupItem, } from './api/client' +import { + collectRelativeCommandNames, + collectRunsAsPreviews, + collectSudoersLines, + itemRequiresSudo, +} from './actionSudoers' import { SECTION_TITLES } from './documentTitle' import { type NodeDetailTabId, @@ -62,6 +69,187 @@ function itemDisplayName( return libraryAction?.name || `Library ${item.library_action_id || 'action'}` } +const DRAFT_ITEM_ID_PREFIX = 'draft-' + +function isDraftItemId(itemId: string): boolean { + return itemId.startsWith(DRAFT_ITEM_ID_PREFIX) +} + +function newDraftItemId(): string { + return `${DRAFT_ITEM_ID_PREFIX}${crypto.randomUUID()}` +} + +function cloneActionItems( + items: ReadonlyArray, +): NodeActionItem[] { + return items.map((item) => ({ + ...item, + env: item.env ? item.env.map((envVar) => ({ ...envVar })) : item.env, + })) +} + +function cloneSchedule(schedule: ActionGroupSchedule): ActionGroupSchedule { + return { + enabled: schedule.enabled, + kind: schedule.kind, + every_seconds: schedule.every_seconds, + times: (schedule.times || []).map((entry) => ({ + time: entry.time, + days_of_week: entry.days_of_week + ? [...entry.days_of_week] + : entry.days_of_week, + })), + } +} + +function schedulesEqual( + left: ActionGroupSchedule, + right: ActionGroupSchedule, +): boolean { + return JSON.stringify(cloneSchedule(left)) === JSON.stringify(cloneSchedule(right)) +} + +function actionItemsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return JSON.stringify(cloneActionItems(left)) === JSON.stringify(cloneActionItems(right)) +} + +function normalizeItemForCompare(item: NodeActionItem): unknown { + return { + id: item.id, + source: item.source, + library_action_id: item.library_action_id || '', + name: item.name || '', + description: item.description || '', + kind: item.kind || '', + body: item.body || '', + env: item.env || [], + requires_sudo: Boolean(item.requires_sudo), + set_variable: item.set_variable || '', + run_if: item.run_if || '', + } +} + +function itemFieldsEqual(left: NodeActionItem, right: NodeActionItem): boolean { + return ( + JSON.stringify(normalizeItemForCompare(left)) === + JSON.stringify(normalizeItemForCompare(right)) + ) +} + +function CopyIcon() { + return ( + + ) +} + +function ReorderToTopIcon() { + return ( + + ) +} + +function ReorderUpIcon() { + return ( + + ) +} + +function ReorderDownIcon() { + return ( + + ) +} + +function ReorderToBottomIcon() { + return ( + + ) +} + +function CopyableCode({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + return ( + + {text} + + + ) +} + export function NodeDetailPanel({ section, nodeId, @@ -135,7 +323,7 @@ export function NodeDetailPanel({ const canCreate = Boolean(me?.permissions?.includes('actions.create')) const canUpdate = Boolean(me?.permissions?.includes('actions.update')) const canDelete = Boolean(me?.permissions?.includes('actions.delete')) - const canRun = Boolean(me?.permissions?.includes('jobs.run')) + const canRun = Boolean(me?.permissions?.includes('actions.execute')) const canReadLogs = Boolean(me?.permissions?.includes('logs.read')) return ( @@ -182,6 +370,7 @@ export function NodeDetailPanel({ {activeTab === 'actions' ? ( (null) const [lastRun, setLastRun] = useState(null) + const [editingGroupId, setEditingGroupId] = useState(null) const libraryById = new Map(library.map((action) => [action.id, action])) @@ -293,11 +485,12 @@ function NodeActionsTab({ setBusyKey('create-group') setError(null) try { - await createActionGroup(nodeId, { + const response = await createActionGroup(nodeId, { name, schedule: defaultSchedule(), }) setNewGroupName('') + setEditingGroupId(response.group.id) await reload() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create group') @@ -352,6 +545,7 @@ function NodeActionsTab({

  • { + setGroups(nextGroups) + if ( + editingGroupId && + !nextGroups.some((entry) => entry.id === editingGroupId) + ) { + setEditingGroupId(null) + } + }} + onEditModeChange={(editing) => { + setEditingGroupId(editing ? group.id : null) + }} onRunComplete={setLastRun} />
  • @@ -381,9 +587,45 @@ function NodeActionsTab({ {lastRun.actions.map((actionResult) => (
    - {actionResult.action_name} (exit {actionResult.exit_code}) + {actionResult.action_name} + {actionResult.skipped + ? actionResult.skip_reason + ? ` — ${actionResult.skip_reason}` + : ' (skipped)' + : ` (exit ${actionResult.exit_code})`} {actionResult.error ? ` — ${actionResult.error}` : ''} + {actionResult.set_variable + ? ` · $${actionResult.set_variable}=${actionResult.variable_value ?? ''}` + : ''} + {actionResult.skipped ? ( +

    + {actionResult.skip_reason + ? actionResult.skip_reason + : 'Skipped'} +

    + ) : null} + {actionResult.remote_command || actionResult.ssh_username || actionResult.kind ? ( +

    + {actionResult.kind ? ( + <> + kind {actionResult.kind} + + ) : null} + {actionResult.ssh_username ? ( + <> + {actionResult.kind ? ' · ' : null} + ssh {actionResult.ssh_username} + + ) : null} + {actionResult.remote_command ? ( + <> + {(actionResult.kind || actionResult.ssh_username) ? ' · ' : null} + runs as {actionResult.remote_command} + + ) : null} +

    + ) : null} {actionResult.stdout ? (
    {actionResult.stdout}
    ) : null} @@ -402,6 +644,7 @@ function NodeActionsTab({ function ActionGroupEditor({ nodeId, + nodeUsername, group, library, libraryById, @@ -412,11 +655,14 @@ function ActionGroupEditor({ busyKey, setBusyKey, setError, + startInEditMode = false, onGroupChange, onGroupsChange, + onEditModeChange, onRunComplete, }: { nodeId: string + nodeUsername: string group: NodeActionGroup library: ReadonlyArray libraryById: Map @@ -427,36 +673,321 @@ function ActionGroupEditor({ busyKey: string | null setBusyKey: (key: string | null) => void setError: (message: string | null) => void + startInEditMode?: boolean onGroupChange: (group: NodeActionGroup) => void onGroupsChange: (groups: ReadonlyArray) => void + onEditModeChange: (editing: boolean) => void onRunComplete: (run: ActionGroupRunRecord) => void }) { + const [isEditing, setIsEditing] = useState(startInEditMode) const [nameDraft, setNameDraft] = useState(group.name) const [scheduleDraft, setScheduleDraft] = useState( group.schedule || defaultSchedule(), ) + const [itemsDraft, setItemsDraft] = useState(() => + cloneActionItems(group.items), + ) const [libraryPick, setLibraryPick] = useState('') const [customName, setCustomName] = useState('') const [customBody, setCustomBody] = useState('') const [customKind, setCustomKind] = useState<'shell' | 'script'>('shell') + const [customRequiresSudo, setCustomRequiresSudo] = useState(false) + const [customSetVariable, setCustomSetVariable] = useState('') + const [customRunIf, setCustomRunIf] = useState('') const [editingItemId, setEditingItemId] = useState(null) const [editName, setEditName] = useState('') const [editBody, setEditBody] = useState('') + const [editRequiresSudo, setEditRequiresSudo] = useState(false) + const [editSetVariable, setEditSetVariable] = useState('') + const [editRunIf, setEditRunIf] = useState('') + const [resolvedPaths, setResolvedPaths] = useState>({}) + const [resolveMissing, setResolveMissing] = useState([]) + const [resolveBusy, setResolveBusy] = useState(false) + const [resolveError, setResolveError] = useState(null) + const [sudoersOpen, setSudoersOpen] = useState(isEditing) + + const displayItems = isEditing ? itemsDraft : group.items + const isDirty = + isEditing && + (nameDraft.trim() !== group.name || + !schedulesEqual(scheduleDraft, group.schedule || defaultSchedule()) || + !actionItemsEqual(itemsDraft, group.items)) + + const sudoersLines = collectSudoersLines( + displayItems, + libraryById, + nodeUsername, + resolvedPaths, + ) + const runsAsPreviews = collectRunsAsPreviews( + displayItems, + libraryById, + resolvedPaths, + ) + const relativeCommandNames = collectRelativeCommandNames( + displayItems, + libraryById, + ) + const needsAbsolutePaths = relativeCommandNames.some( + (name) => !resolvedPaths[name], + ) + const showSudoersPanel = displayItems.some((item) => + itemRequiresSudo(item, libraryById), + ) + + async function handleResolvePaths() { + if (relativeCommandNames.length === 0) { + setResolvedPaths({}) + setResolveMissing([]) + setResolveError(null) + return + } + setResolveBusy(true) + setResolveError(null) + try { + const response = await resolveNodeCommands(nodeId, relativeCommandNames) + setResolvedPaths(response.paths || {}) + setResolveMissing([...(response.missing || [])]) + } catch (err) { + setResolveError( + err instanceof Error ? err.message : 'Failed to resolve paths', + ) + } finally { + setResolveBusy(false) + } + } + + function syncDraftsFromGroup(nextGroup: NodeActionGroup) { + setNameDraft(nextGroup.name) + setScheduleDraft(cloneSchedule(nextGroup.schedule || defaultSchedule())) + setItemsDraft(cloneActionItems(nextGroup.items)) + setEditingItemId(null) + } useEffect(() => { - setNameDraft(group.name) - setScheduleDraft(group.schedule || defaultSchedule()) + if (!isEditing || !isDirty) { + syncDraftsFromGroup(group) + } + // Only re-sync when the saved group identity/payload changes while clean. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [group]) - async function saveSchedule() { + useEffect(() => { + setIsEditing(startInEditMode) + if (startInEditMode) { + syncDraftsFromGroup(group) + } else { + setEditingItemId(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startInEditMode]) + + useEffect(() => { + setSudoersOpen(isEditing) + }, [isEditing]) + + function enterEditMode() { + syncDraftsFromGroup(group) + setIsEditing(true) + onEditModeChange(true) + } + + function revertDrafts() { + syncDraftsFromGroup(group) + } + + function exitEditMode() { + if (isDirty) { + if ( + !window.confirm( + 'Discard unsaved changes to this action group?', + ) + ) { + return + } + revertDrafts() + } + setIsEditing(false) + setEditingItemId(null) + onEditModeChange(false) + } + + async function saveGroupEdits() { setBusyKey(`save-${group.id}`) setError(null) try { - const response = await patchActionGroup(nodeId, group.id, { - name: nameDraft.trim(), - schedule: scheduleDraft, - }) - onGroupChange(response.group) + let latest = group + const baselineItems = group.items + const draftItems = itemsDraft + + if (canUpdate) { + const patched = await patchActionGroup(nodeId, group.id, { + name: nameDraft.trim(), + schedule: scheduleDraft, + }) + latest = patched.group + } + + if (canDelete) { + for (const baselineItem of baselineItems) { + if (!draftItems.some((item) => item.id === baselineItem.id)) { + const response = await deleteActionGroupItem( + nodeId, + group.id, + baselineItem.id, + ) + latest = response.group + } + } + } + + const idMap = new Map() + for (const draftItem of draftItems) { + if (!isDraftItemId(draftItem.id)) { + idMap.set(draftItem.id, draftItem.id) + } + } + + for (const draftItem of draftItems) { + const baselineItem = baselineItems.find( + (item) => item.id === draftItem.id, + ) + if (!baselineItem) { + continue + } + if ( + baselineItem.source === 'library' && + draftItem.source === 'local' + ) { + const cloned = await cloneActionGroupItem( + nodeId, + group.id, + draftItem.id, + ) + latest = cloned.group + const localAfterClone = latest.items.find( + (item) => item.id === draftItem.id, + ) + if ( + localAfterClone && + !itemFieldsEqual( + { ...localAfterClone, source: 'local' }, + draftItem, + ) + ) { + const patched = await patchActionGroupItem( + nodeId, + group.id, + draftItem.id, + { + name: draftItem.name, + body: draftItem.body, + requires_sudo: Boolean(draftItem.requires_sudo), + set_variable: draftItem.set_variable || '', + run_if: draftItem.run_if || '', + }, + ) + latest = patched.group + } + continue + } + if (itemFieldsEqual(baselineItem, draftItem)) { + continue + } + if (!canUpdate) { + continue + } + const patch = + draftItem.source === 'local' + ? { + name: draftItem.name, + body: draftItem.body, + requires_sudo: Boolean(draftItem.requires_sudo), + set_variable: draftItem.set_variable || '', + run_if: draftItem.run_if || '', + } + : { + set_variable: draftItem.set_variable || '', + run_if: draftItem.run_if || '', + } + const patched = await patchActionGroupItem( + nodeId, + group.id, + draftItem.id, + patch, + ) + latest = patched.group + } + + if (canCreate) { + for (const draftItem of draftItems) { + if (!isDraftItemId(draftItem.id)) { + continue + } + const beforeIds = new Set(latest.items.map((item) => item.id)) + const created = + draftItem.source === 'library' + ? await createActionGroupItem(nodeId, group.id, { + source: 'library', + library_action_id: draftItem.library_action_id, + set_variable: draftItem.set_variable, + run_if: draftItem.run_if, + }) + : await createActionGroupItem(nodeId, group.id, { + source: 'local', + name: draftItem.name, + description: draftItem.description || '', + kind: draftItem.kind || 'shell', + body: draftItem.body || '', + env: draftItem.env || [], + requires_sudo: Boolean(draftItem.requires_sudo), + set_variable: draftItem.set_variable, + run_if: draftItem.run_if, + }) + latest = created.group + const createdItem = latest.items.find( + (item) => !beforeIds.has(item.id), + ) + if (createdItem) { + idMap.set(draftItem.id, createdItem.id) + if ( + draftItem.source === 'library' && + (draftItem.set_variable || draftItem.run_if) + ) { + const patched = await patchActionGroupItem( + nodeId, + group.id, + createdItem.id, + { + set_variable: draftItem.set_variable || '', + run_if: draftItem.run_if || '', + }, + ) + latest = patched.group + } + } + } + } + + const orderedIds = draftItems + .map((item) => idMap.get(item.id)) + .filter((itemId): itemId is string => Boolean(itemId)) + if ( + orderedIds.length > 0 && + (canUpdate || canCreate) && + (orderedIds.length !== latest.items.length || + orderedIds.some((itemId, index) => latest.items[index]?.id !== itemId)) + ) { + const reordered = await reorderActionGroupItems( + nodeId, + group.id, + orderedIds, + ) + latest = reordered.group + } + + onGroupChange(latest) + syncDraftsFromGroup(latest) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save group') } finally { @@ -480,71 +1011,152 @@ function ActionGroupEditor({ } } - async function handleAddLibrary() { + function handleAddLibrary() { if (!libraryPick) { return } - setBusyKey(`add-lib-${group.id}`) - setError(null) - try { - const response = await createActionGroupItem(nodeId, group.id, { + setItemsDraft((previous) => [ + ...previous, + { + id: newDraftItemId(), source: 'library', library_action_id: libraryPick, - }) - onGroupChange(response.group) - setLibraryPick('') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add action') - } finally { - setBusyKey(null) - } + }, + ]) + setLibraryPick('') } - async function handleAddCustom() { - setBusyKey(`add-custom-${group.id}`) - setError(null) - try { - const response = await createActionGroupItem(nodeId, group.id, { + function handleAddCustom() { + setItemsDraft((previous) => [ + ...previous, + { + id: newDraftItemId(), source: 'local', name: customName.trim(), description: '', kind: customKind, body: customBody.trim(), env: [], - }) - onGroupChange(response.group) - setCustomName('') - setCustomBody('') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add custom action') - } finally { - setBusyKey(null) + requires_sudo: customRequiresSudo || undefined, + set_variable: customSetVariable.trim() || undefined, + run_if: customRunIf.trim() || undefined, + }, + ]) + setCustomName('') + setCustomBody('') + setCustomRequiresSudo(false) + setCustomSetVariable('') + setCustomRunIf('') + } + + function moveItem( + itemId: string, + placement: 'top' | 'up' | 'down' | 'bottom', + ) { + setItemsDraft((previous) => { + const index = previous.findIndex((item) => item.id === itemId) + if (index < 0) { + return previous + } + let nextIndex = index + switch (placement) { + case 'top': + nextIndex = 0 + break + case 'up': + nextIndex = index - 1 + break + case 'down': + nextIndex = index + 1 + break + case 'bottom': + nextIndex = previous.length - 1 + break + } + if ( + nextIndex < 0 || + nextIndex >= previous.length || + nextIndex === index + ) { + return previous + } + const reordered = [...previous] + const [moved] = reordered.splice(index, 1) + reordered.splice(nextIndex, 0, moved) + return reordered + }) + } + + function handleRemoveItem(item: NodeActionItem) { + const label = itemDisplayName(item, libraryById) + if (!window.confirm(`Remove action “${label}” from this group?`)) { + return + } + setItemsDraft((previous) => + previous.filter((entry) => entry.id !== item.id), + ) + if (editingItemId === item.id) { + setEditingItemId(null) } } - async function moveItem(itemId: string, direction: -1 | 1) { - const ids = group.items.map((item) => item.id) - const index = ids.indexOf(itemId) - const nextIndex = index + direction - if (index < 0 || nextIndex < 0 || nextIndex >= ids.length) { + function handleCloneToLocal(item: NodeActionItem) { + const libraryAction = libraryById.get(item.library_action_id || '') + if (!libraryAction) { + setError('Library action not found') return } - const reordered = [...ids] - const [moved] = reordered.splice(index, 1) - reordered.splice(nextIndex, 0, moved) - setBusyKey(`order-${group.id}`) - setError(null) - try { - const response = await reorderActionGroupItems(nodeId, group.id, reordered) - onGroupChange(response.group) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to reorder') - } finally { - setBusyKey(null) - } + setItemsDraft((previous) => + previous.map((entry) => + entry.id === item.id + ? { + id: entry.id, + source: 'local', + name: libraryAction.name, + description: libraryAction.description, + kind: libraryAction.kind, + body: libraryAction.body, + env: libraryAction.env.map((envVar) => ({ ...envVar })), + requires_sudo: libraryAction.requires_sudo || undefined, + set_variable: entry.set_variable, + run_if: entry.run_if, + } + : entry, + ), + ) + } + + function handleApplyItemEdit(item: NodeActionItem) { + setItemsDraft((previous) => + previous.map((entry) => { + if (entry.id !== item.id) { + return entry + } + if (entry.source === 'local') { + return { + ...entry, + name: editName, + body: editBody, + requires_sudo: editRequiresSudo || undefined, + set_variable: editSetVariable.trim() || undefined, + run_if: editRunIf.trim() || undefined, + } + } + return { + ...entry, + set_variable: editSetVariable.trim() || undefined, + run_if: editRunIf.trim() || undefined, + } + }), + ) + setEditingItemId(null) } async function handleRunGroup() { + if (isDirty) { + setError('Save or undo changes before running this group.') + return + } setBusyKey(`run-group-${group.id}`) setError(null) try { @@ -560,9 +1172,10 @@ function ActionGroupEditor({ } return ( -
    +
    +
    - {canUpdate ? ( + {isEditing && canUpdate ? ( {group.name} )}
    - {canRun ? ( + + {isEditing && (canUpdate || canCreate || canDelete) ? ( - ) : null} - {canUpdate ? ( - ) : null} + {isEditing ? ( + + ) : null} + {isEditing ? ( + + ) : canUpdate || canCreate ? ( + + ) : null} {canDelete ? (
    + {isEditing && isDirty ? ( +

    Unsaved changes — Save to keep, or Undo to revert.

    + ) : null} + {group.last_run_at ? (

    Last run: {new Date(group.last_run_at).toLocaleString()} @@ -618,7 +1259,10 @@ function ActionGroupEditor({

    Never run

    )} -
    +
    Schedule