From 32af91fd30abe66ade688e1ee93cd2f5b0e4f28f Mon Sep 17 00:00:00 2001 From: squid Date: Sun, 19 Jul 2026 20:01:11 +0200 Subject: [PATCH] Add per-node action groups with schedules, runs, and logs. Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI. --- service/cmd/server/main.go | 7 +- .../internal/api/action_groups_handlers.go | 801 +++++++++++ .../api/action_groups_handlers_test.go | 224 ++++ service/internal/api/authz.go | 17 + service/internal/api/router.go | 24 +- service/internal/api/setup_auth_handlers.go | 14 +- service/internal/auth/placeholders.go | 53 + service/internal/auth/placeholders_test.go | 18 + service/internal/auth/ssh_run.go | 228 ++++ service/internal/runner/executor.go | 355 +++++ service/internal/runner/executor_test.go | 53 + service/internal/settings/action_logs.go | 174 +++ .../internal/settings/node_action_groups.go | 227 ++++ .../settings/node_action_groups_test.go | 160 +++ service/internal/settings/path.go | 4 +- service/internal/settings/types.go | 110 ++ webui/src/App.css | 226 +++- webui/src/App.tsx | 47 +- webui/src/NodeDetailPanel.tsx | 1182 +++++++++++++++++ webui/src/api/client.ts | 339 +++++ webui/src/navigation.test.ts | 42 +- webui/src/navigation.ts | 64 +- 22 files changed, 4337 insertions(+), 32 deletions(-) create mode 100644 service/internal/api/action_groups_handlers.go create mode 100644 service/internal/api/action_groups_handlers_test.go create mode 100644 service/internal/auth/placeholders.go create mode 100644 service/internal/auth/placeholders_test.go create mode 100644 service/internal/auth/ssh_run.go create mode 100644 service/internal/runner/executor.go create mode 100644 service/internal/runner/executor_test.go create mode 100644 service/internal/settings/action_logs.go create mode 100644 service/internal/settings/node_action_groups.go create mode 100644 service/internal/settings/node_action_groups_test.go create mode 100644 webui/src/NodeDetailPanel.tsx diff --git a/service/cmd/server/main.go b/service/cmd/server/main.go index f4445c8..40a02b1 100644 --- a/service/cmd/server/main.go +++ b/service/cmd/server/main.go @@ -36,9 +36,10 @@ func main() { listenHost := settings.ListenHost(configDir) listenAddr := listenHost + ":" + port + handler, app := api.NewRouterWithApp(configDir) server := &http.Server{ Addr: listenAddr, - Handler: api.NewRouter(configDir), + Handler: handler, ReadHeaderTimeout: 5 * time.Second, } @@ -53,6 +54,10 @@ func main() { signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM) <-stopSignals + if app.Executor != nil { + app.Executor.StopScheduler() + } + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/service/internal/api/action_groups_handlers.go b/service/internal/api/action_groups_handlers.go new file mode 100644 index 0000000..a3fbeb9 --- /dev/null +++ b/service/internal/api/action_groups_handlers.go @@ -0,0 +1,801 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type actionGroupsResponse struct { + Groups []settings.NodeActionGroup `json:"groups"` +} + +type actionGroupResponse struct { + Group settings.NodeActionGroup `json:"group"` +} + +type actionRunResponse struct { + Run settings.ActionGroupRunRecord `json:"run"` +} + +type actionLogsListResponse struct { + Files []settings.ActionLogFileInfo `json:"files"` +} + +type actionLogFileResponse struct { + Log settings.ActionLogFile `json:"log"` +} + +type createActionGroupRequest struct { + Name string `json:"name"` + Schedule *settings.ActionGroupSchedule `json:"schedule"` +} + +type patchActionGroupRequest struct { + Name *string `json:"name"` + Schedule *settings.ActionGroupSchedule `json:"schedule"` +} + +type createActionGroupItemRequest struct { + Source settings.ActionItemSource `json:"source"` + LibraryActionID string `json:"library_action_id"` + Name string `json:"name"` + Description string `json:"description"` + Kind settings.ActionKind `json:"kind"` + Body string `json:"body"` + Env []settings.ActionEnvVar `json:"env"` +} + +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"` +} + +type reorderActionGroupItemsRequest struct { + ItemIDs []string `json:"item_ids"` +} + +func (app *App) actionGroupsListHandler(writer http.ResponseWriter, request *http.Request) { + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)}) +} + +func (app *App) actionGroupsCreateHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsCreate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + + var payload createActionGroupRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + name := strings.TrimSpace(payload.Name) + if name == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"}) + return + } + + schedule := settings.ActionGroupSchedule{ + Enabled: false, + Kind: settings.ActionGroupScheduleInterval, + Times: []settings.ActionGroupTimeSpec{}, + } + if payload.Schedule != nil { + schedule = *payload.Schedule + if schedule.Times == nil { + schedule.Times = []settings.ActionGroupTimeSpec{} + } + } + if err := validateSchedule(schedule); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + + groupID, err := auth.NewUUID() + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + now := time.Now().UTC() + group := settings.NodeActionGroup{ + ID: groupID, + NodeID: node.ID, + Name: name, + Schedule: schedule, + Items: []settings.NodeActionItem{}, + CreatedAt: now, + UpdatedAt: now, + } + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + store.Groups = append(store.Groups, group) + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group}) +} + +func (app *App) actionGroupsPatchHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + + var payload patchActionGroupRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + index := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if index < 0 || store.Groups[index].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + + group := store.Groups[index] + if payload.Name != nil { + name := strings.TrimSpace(*payload.Name) + if name == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"}) + return + } + group.Name = name + } + if payload.Schedule != nil { + schedule := *payload.Schedule + if schedule.Times == nil { + schedule.Times = []settings.ActionGroupTimeSpec{} + } + if err := validateSchedule(schedule); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + group.Schedule = schedule + } + group.UpdatedAt = time.Now().UTC() + store.Groups[index] = group + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group}) +} + +func (app *App) actionGroupsDeleteHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsDelete(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + index := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if index < 0 || store.Groups[index].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + store.Groups = append(store.Groups[:index], store.Groups[index+1:]...) + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)}) +} + +func (app *App) actionGroupItemsCreateHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsCreate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + + var payload createActionGroupItemRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + item, err := buildNodeActionItem(payload) + if err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + index := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if index < 0 || store.Groups[index].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + + if item.Source == settings.ActionItemSourceLibrary { + library, libErr := settings.LoadActionsOrSeed(app.ConfigDir) + if libErr != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: libErr.Error()}) + return + } + found := false + for _, action := range library.Actions { + if action.ID == item.LibraryActionID { + found = true + break + } + } + if !found { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"}) + return + } + } + + store.Groups[index].Items = append(store.Groups[index].Items, item) + store.Groups[index].UpdatedAt = time.Now().UTC() + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[index]}) +} + +func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + itemID := strings.TrimSpace(request.PathValue("iid")) + + var payload patchActionGroupItemRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID) + if itemIndex < 0 { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"}) + return + } + item := store.Groups[groupIndex].Items[itemIndex] + if item.Source != settings.ActionItemSourceLocal { + 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 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 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 + } + + store.Groups[groupIndex].Items[itemIndex] = item + store.Groups[groupIndex].UpdatedAt = time.Now().UTC() + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]}) +} + +func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsCreate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + itemID := strings.TrimSpace(request.PathValue("iid")) + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID) + if itemIndex < 0 { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"}) + return + } + item := store.Groups[groupIndex].Items[itemIndex] + if item.Source != settings.ActionItemSourceLibrary { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "only library actions can be cloned"}) + return + } + + library, err := settings.LoadActionsOrSeed(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + var libraryAction settings.Action + found := false + for _, action := range library.Actions { + if action.ID == item.LibraryActionID { + libraryAction = action + found = true + break + } + } + if !found { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"}) + return + } + + env := libraryAction.Env + if env == nil { + env = []settings.ActionEnvVar{} + } + item.Source = settings.ActionItemSourceLocal + item.LibraryActionID = "" + item.Name = libraryAction.Name + item.Description = libraryAction.Description + item.Kind = libraryAction.Kind + item.Body = libraryAction.Body + item.Env = env + + store.Groups[groupIndex].Items[itemIndex] = item + store.Groups[groupIndex].UpdatedAt = time.Now().UTC() + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]}) +} + +func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsDelete(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + itemID := strings.TrimSpace(request.PathValue("iid")) + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID) + if itemIndex < 0 { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"}) + return + } + items := store.Groups[groupIndex].Items + store.Groups[groupIndex].Items = append(items[:itemIndex], items[itemIndex+1:]...) + store.Groups[groupIndex].UpdatedAt = time.Now().UTC() + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]}) +} + +func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request *http.Request) { + if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + + var payload reorderActionGroupItemsRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + + current := store.Groups[groupIndex].Items + if len(payload.ItemIDs) != len(current) { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "item_ids must include every item exactly once"}) + return + } + byID := make(map[string]settings.NodeActionItem, len(current)) + for _, item := range current { + byID[item.ID] = item + } + reordered := make([]settings.NodeActionItem, 0, len(payload.ItemIDs)) + seen := make(map[string]struct{}, len(payload.ItemIDs)) + for _, itemID := range payload.ItemIDs { + item, exists := byID[itemID] + if !exists { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "unknown item id in order list"}) + return + } + if _, dup := seen[itemID]; dup { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "duplicate item id in order list"}) + return + } + seen[itemID] = struct{}{} + reordered = append(reordered, item) + } + store.Groups[groupIndex].Items = reordered + store.Groups[groupIndex].UpdatedAt = time.Now().UTC() + if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]}) +} + +func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeJobsRun(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + group, found := app.findGroupForNode(node.ID, groupID) + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + if app.Executor == nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"}) + return + } + actor := "" + if user, ok := UserFromContext(request.Context()); ok { + actor = user.Username + } + record, err := app.Executor.RunGroup(group, settings.ActionRunTriggerManual, actor) + if err != nil { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionRunResponse{Run: record}) +} + +func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeJobsRun(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + groupID := strings.TrimSpace(request.PathValue("gid")) + itemID := strings.TrimSpace(request.PathValue("iid")) + group, found := app.findGroupForNode(node.ID, groupID) + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"}) + return + } + itemIndex := settings.FindNodeActionItemIndex(group.Items, itemID) + if itemIndex < 0 { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"}) + return + } + if app.Executor == nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"}) + return + } + actor := "" + if user, ok := UserFromContext(request.Context()); ok { + actor = user.Username + } + record, err := app.Executor.RunItem(group, group.Items[itemIndex], settings.ActionRunTriggerManual, actor) + if err != nil { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionRunResponse{Run: record}) +} + +func (app *App) actionLogsListHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeLogsRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + files, err := settings.ListActionLogFiles(app.ConfigDir, node.ID) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + writeJSON(writer, http.StatusOK, actionLogsListResponse{Files: files}) +} + +func (app *App) actionLogsGetHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeLogsRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + node, ok := app.loadAccessibleNode(writer, request) + if !ok { + return + } + filename := strings.TrimSpace(request.PathValue("filename")) + logFile, err := settings.ReadActionLogFileByName(app.ConfigDir, node.ID, filename) + if err != nil { + if strings.Contains(err.Error(), "invalid log filename") { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + if errors.Is(err, os.ErrNotExist) { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"}) + return + } + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"}) + return + } + writeJSON(writer, http.StatusOK, actionLogFileResponse{Log: logFile}) +} + +func (app *App) loadAccessibleNode(writer http.ResponseWriter, request *http.Request) (settings.Node, bool) { + if err := app.authorizeNodesRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return settings.Node{}, false + } + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return settings.Node{}, false + } + nodeID := strings.TrimSpace(request.PathValue("id")) + if nodeID == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"}) + return settings.Node{}, false + } + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return settings.Node{}, false + } + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return settings.Node{}, false + } + for _, node := range store.Nodes { + if node.ID != nodeID { + continue + } + if !userCanAccessNode(user, settingsPayload.Groups, node) { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) + return settings.Node{}, false + } + return node, true + } + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) + return settings.Node{}, false +} + +func (app *App) findGroupForNode(nodeID string, groupID string) (settings.NodeActionGroup, bool) { + store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir) + if err != nil { + return settings.NodeActionGroup{}, false + } + index := settings.FindNodeActionGroupIndex(store.Groups, groupID) + if index < 0 || store.Groups[index].NodeID != nodeID { + return settings.NodeActionGroup{}, false + } + return store.Groups[index], true +} + +func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeActionItem, error) { + itemID, err := auth.NewUUID() + if err != nil { + return settings.NodeActionItem{}, err + } + switch payload.Source { + case settings.ActionItemSourceLibrary: + libraryID := strings.TrimSpace(payload.LibraryActionID) + if libraryID == "" { + return settings.NodeActionItem{}, errors.New("library_action_id is required") + } + return settings.NodeActionItem{ + ID: itemID, + Source: settings.ActionItemSourceLibrary, + LibraryActionID: libraryID, + Env: []settings.ActionEnvVar{}, + }, nil + case settings.ActionItemSourceLocal: + env := payload.Env + if env == nil { + env = []settings.ActionEnvVar{} + } + name := strings.TrimSpace(payload.Name) + body := strings.TrimSpace(payload.Body) + if err := validateActionFields(name, payload.Kind, body, env); err != nil { + 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, + }, nil + default: + return settings.NodeActionItem{}, errors.New("source must be library or local") + } +} + +func validateSchedule(schedule settings.ActionGroupSchedule) error { + switch schedule.Kind { + case settings.ActionGroupScheduleInterval: + if schedule.Enabled && schedule.EverySeconds < 1 { + return errors.New("every_seconds must be at least 1 when interval schedule is enabled") + } + return nil + case settings.ActionGroupScheduleTimes: + if schedule.Enabled && len(schedule.Times) == 0 { + return errors.New("at least one time is required when times schedule is enabled") + } + for _, spec := range schedule.Times { + hour, minute, ok := parseScheduleHHMM(spec.Time) + if !ok { + return errors.New("time must be HH:MM") + } + _ = hour + _ = minute + for _, day := range spec.DaysOfWeek { + if day < 0 || day > 6 { + return errors.New("days_of_week values must be 0-6") + } + } + } + return nil + case "": + return errors.New("schedule kind is required") + default: + return errors.New("schedule kind must be interval or times") + } +} + +func parseScheduleHHMM(value string) (hour int, minute int, ok bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, false + } + if _, err := fmtSscanf(parts[0], &hour); err != nil { + return 0, 0, false + } + if _, err := fmtSscanf(parts[1], &minute); err != nil { + return 0, 0, false + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, 0, false + } + return hour, minute, true +} + +func fmtSscanf(input string, dest *int) (int, error) { + n := 0 + for _, ch := range input { + if ch < '0' || ch > '9' { + return 0, errors.New("not a number") + } + n = n*10 + int(ch-'0') + } + if input == "" { + return 0, errors.New("empty") + } + *dest = n + return 1, nil +} diff --git a/service/internal/api/action_groups_handlers_test.go b/service/internal/api/action_groups_handlers_test.go new file mode 100644 index 0000000..21ddc9f --- /dev/null +++ b/service/internal/api/action_groups_handlers_test.go @@ -0,0 +1,224 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +func seedNodeForActions(t *testing.T, configDir string) settings.Node { + t.Helper() + node := settings.Node{ + ID: "11111111-1111-4111-8111-111111111111", + Kind: settings.NodeKindContainer, + Name: "box-1", + HostIP: "127.0.0.1", + Username: "root", + GroupName: settings.AdministratorsGroupName, + PublicKey: "ssh-ed25519 AAAA", + KeyAlgo: "ed25519", + CreatedAt: time.Now().UTC(), + } + if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil { + t.Fatalf("SaveNodes: %v", err) + } + return node +} + +func TestActionGroupsCRUDCloneReorderAndAuthz(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": "Nightly", + "schedule": map[string]any{ + "enabled": false, + "kind": "interval", + "every_seconds": 3600, + "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 + + addLibBody, _ := json.Marshal(map[string]any{ + "source": "library", + "library_action_id": settings.BuiltinActionUptimeID, + }) + addReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", + bytes.NewReader(addLibBody), + ), + cookie, + ) + addRec := httptest.NewRecorder() + router.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusOK { + t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String()) + } + var withItem actionGroupResponse + if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil { + t.Fatalf("decode add: %v", err) + } + if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary { + t.Fatalf("items = %#v", withItem.Group.Items) + } + itemID := withItem.Group.Items[0].ID + + addCustomBody, _ := json.Marshal(map[string]any{ + "source": "local", + "name": "Echo", + "kind": "shell", + "body": "echo hi", + "env": []any{}, + }) + addCustomReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items", + bytes.NewReader(addCustomBody), + ), + cookie, + ) + addCustomRec := httptest.NewRecorder() + router.ServeHTTP(addCustomRec, addCustomReq) + if addCustomRec.Code != http.StatusOK { + t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String()) + } + var withTwo actionGroupResponse + _ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo) + if len(withTwo.Group.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items)) + } + customID := withTwo.Group.Items[1].ID + + orderBody, _ := json.Marshal(map[string]any{ + "item_ids": []string{customID, itemID}, + }) + orderReq := withSession( + httptest.NewRequest( + http.MethodPut, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order", + bytes.NewReader(orderBody), + ), + cookie, + ) + orderRec := httptest.NewRecorder() + router.ServeHTTP(orderRec, orderReq) + if orderRec.Code != http.StatusOK { + t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String()) + } + var reordered actionGroupResponse + _ = json.Unmarshal(orderRec.Body.Bytes(), &reordered) + if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID { + t.Fatalf("order = %#v", reordered.Group.Items) + } + + cloneReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/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) + var clonedItem settings.NodeActionItem + for _, item := range cloned.Group.Items { + if item.ID == itemID { + clonedItem = item + break + } + } + if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" { + t.Fatalf("cloned item = %#v", clonedItem) + } + + // Forbidden without actions.create for a limited user session is covered below via groups. + listReq := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil), + cookie, + ) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status=%d", listRec.Code) + } + + logsReq := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil), + cookie, + ) + logsRec := httptest.NewRecorder() + router.ServeHTTP(logsRec, logsReq) + if logsRec.Code != http.StatusOK { + t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String()) + } +} + +func TestActionGroupsForbiddenWithoutPermission(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + node := seedNodeForActions(t, configDir) + + // Downgrade Administrators permissions to nodes.read only. + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + payload.Groups[0].Permissions = []string{"nodes.read"} + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + router := NewRouter(configDir) + body, _ := json.Marshal(map[string]any{"name": "Nope"}) + req := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/"+node.ID+"/action-groups", + bytes.NewReader(body), + ), + cookie, + ) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go index 89f5570..5e939da 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 errJobsRunRequired = errors.New("permission jobs.run required") var errLogsReadRequired = errors.New("permission logs.read required") // permissionsForUser returns the union of permissions from the user's groups. @@ -160,3 +161,19 @@ func authorizeActionsUpdate(request *http.Request, configDir string) error { func authorizeActionsDelete(request *http.Request, configDir string) error { return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired) } + +func (app *App) authorizeJobsRun(request *http.Request) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + payload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + if !userHasPermission(user, payload.Groups, "jobs.run") { + return errJobsRunRequired + } + return nil +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index e33e139..81d2e2c 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -8,11 +8,20 @@ const defaultAllowedOrigin = "http://localhost:5173" // NewRouter builds the HTTP API with setup gating, auth, and network middleware. func NewRouter(configDir string) http.Handler { + handler, _ := NewRouterWithApp(configDir) + return handler +} + +// NewRouterWithApp builds the router and returns the App for lifecycle control (scheduler stop). +func NewRouterWithApp(configDir string) (http.Handler, *App) { app, err := newApp(configDir) if err != nil { // newApp currently always succeeds; keep signature for future. panic(err) } + if app.Executor != nil { + app.Executor.StartScheduler() + } mux := http.NewServeMux() mux.HandleFunc("GET /health", HealthHandler) @@ -55,8 +64,21 @@ func NewRouter(configDir string) http.Handler { 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("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) + mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsDeleteHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items", app.actionGroupItemsCreateHandler) + mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsPatchHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/clone", app.actionGroupItemsCloneHandler) + mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsDeleteHandler) + mux.HandleFunc("PUT /api/v1/nodes/{id}/action-groups/{gid}/items/order", app.actionGroupItemsOrderHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/run", app.actionGroupsRunHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler) + mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler) + mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler) mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler) mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler) - return app.withCORS(app.withMiddleware(mux)) + return app.withCORS(app.withMiddleware(mux)), app } diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index ce02240..6be7acb 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -11,6 +11,7 @@ import ( "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/runner" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) @@ -79,6 +80,7 @@ type App struct { ConfigDir string Key []byte Sessions *auth.SessionManager + Executor *runner.Executor pendingTOTPMu sync.Mutex pendingTOTP map[string]string // username -> secret (setup only) @@ -88,17 +90,21 @@ func newApp(configDir string) (*App, error) { key, err := settings.KeyFromEnv() if err != nil { // Key is required for setup complete / auth; status still works without it. - return &App{ + app := &App{ ConfigDir: configDir, pendingTOTP: map[string]string{}, - }, nil + Executor: runner.NewExecutor(configDir, nil), + } + return app, nil } - return &App{ + app := &App{ ConfigDir: configDir, Key: key, Sessions: auth.NewSessionManager(configDir, key), pendingTOTP: map[string]string{}, - }, nil + Executor: runner.NewExecutor(configDir, key), + } + return app, nil } func (app *App) requireKey() error { diff --git a/service/internal/auth/placeholders.go b/service/internal/auth/placeholders.go new file mode 100644 index 0000000..aecf953 --- /dev/null +++ b/service/internal/auth/placeholders.go @@ -0,0 +1,53 @@ +package auth + +import ( + "regexp" + "strings" +) + +var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`) + +// PlaceholderContext holds values used to expand action body placeholders. +type PlaceholderContext struct { + Host string + IP string + Username string + Env map[string]string + Secrets map[string]string +} + +// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc. +// Unknown or unresolved secrets expand to empty strings. +func ExpandPlaceholders(body string, ctx PlaceholderContext) string { + return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string { + submatches := placeholderPattern.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + key := strings.TrimSpace(submatches[1]) + switch key { + case "cc.host", "node.host": + return ctx.Host + case "cc.ip", "node.ip": + return ctx.IP + case "node.username": + return ctx.Username + default: + if strings.HasPrefix(key, "env.") { + name := strings.TrimPrefix(key, "env.") + if ctx.Env != nil { + return ctx.Env[name] + } + return "" + } + if strings.HasPrefix(key, "secret.") { + name := strings.TrimPrefix(key, "secret.") + if ctx.Secrets != nil { + return ctx.Secrets[name] + } + return "" + } + return "" + } + }) +} diff --git a/service/internal/auth/placeholders_test.go b/service/internal/auth/placeholders_test.go new file mode 100644 index 0000000..739440c --- /dev/null +++ b/service/internal/auth/placeholders_test.go @@ -0,0 +1,18 @@ +package auth + +import "testing" + +func TestExpandPlaceholders(t *testing.T) { + body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}" + got := ExpandPlaceholders(body, PlaceholderContext{ + Host: "web-1", + IP: "10.0.0.5", + Username: "deploy", + Env: map[string]string{"FOO": "bar"}, + Secrets: map[string]string{}, + }) + want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown=" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/service/internal/auth/ssh_run.go b/service/internal/auth/ssh_run.go new file mode 100644 index 0000000..b7537f6 --- /dev/null +++ b/service/internal/auth/ssh_run.go @@ -0,0 +1,228 @@ +package auth + +import ( + "bytes" + "fmt" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +const defaultSSHRunTimeout = 5 * time.Minute + +// SSHRunResult captures stdout, stderr, and exit status from a remote command. +type SSHRunResult struct { + Stdout string + Stderr string + ExitCode int +} + +// RunSSHShell runs a single shell command on the remote host over SSH. +func RunSSHShell( + hostIP string, + username string, + privateKeyPEM string, + passphrase string, + command string, + env map[string]string, + timeout time.Duration, +) (SSHRunResult, error) { + if strings.TrimSpace(command) == "" { + return SSHRunResult{}, fmt.Errorf("command is required") + } + 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. +func RunSSHScript( + hostIP string, + username string, + privateKeyPEM string, + passphrase string, + scriptBody string, + env map[string]string, + timeout time.Duration, +) (SSHRunResult, error) { + if strings.TrimSpace(scriptBody) == "" { + return SSHRunResult{}, fmt.Errorf("script body is required") + } + + hostIP = strings.TrimSpace(hostIP) + username = strings.TrimSpace(username) + if hostIP == "" { + return SSHRunResult{}, fmt.Errorf("host IP is required") + } + if username == "" { + return SSHRunResult{}, fmt.Errorf("username is required") + } + if timeout <= 0 { + timeout = defaultSSHRunTimeout + } + + signer, err := parseSigner(privateKeyPEM, passphrase) + if err != nil { + return SSHRunResult{}, err + } + + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: sshDialTimeout, + } + + address := net.JoinHostPort(hostIP, defaultSSHPort) + client, err := ssh.Dial("tcp", address, config) + if err != nil { + return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return SSHRunResult{}, fmt.Errorf("ssh session: %w", err) + } + defer session.Close() + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + session.Stdout = &stdoutBuf + session.Stderr = &stderrBuf + session.Stdin = strings.NewReader(scriptBody) + + remoteCommand := prependEnvExports(env, "bash -s") + done := make(chan error, 1) + go func() { + done <- session.Run(remoteCommand) + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case err := <-done: + return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err) + case <-timer.C: + _ = session.Close() + return SSHRunResult{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + ExitCode: -1, + }, fmt.Errorf("ssh command timed out after %s", timeout) + } +} + +func runSSHSession( + hostIP string, + username string, + privateKeyPEM string, + passphrase string, + command string, + timeout time.Duration, +) (SSHRunResult, error) { + hostIP = strings.TrimSpace(hostIP) + username = strings.TrimSpace(username) + if hostIP == "" { + return SSHRunResult{}, fmt.Errorf("host IP is required") + } + if username == "" { + return SSHRunResult{}, fmt.Errorf("username is required") + } + if timeout <= 0 { + timeout = defaultSSHRunTimeout + } + + signer, err := parseSigner(privateKeyPEM, passphrase) + if err != nil { + return SSHRunResult{}, err + } + + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: sshDialTimeout, + } + + address := net.JoinHostPort(hostIP, defaultSSHPort) + client, err := ssh.Dial("tcp", address, config) + if err != nil { + return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return SSHRunResult{}, fmt.Errorf("ssh session: %w", err) + } + defer session.Close() + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + session.Stdout = &stdoutBuf + session.Stderr = &stderrBuf + + done := make(chan error, 1) + go func() { + done <- session.Run(command) + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case err := <-done: + return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err) + case <-timer.C: + _ = session.Close() + return SSHRunResult{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + ExitCode: -1, + }, fmt.Errorf("ssh command timed out after %s", timeout) + } +} + +func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) { + result := SSHRunResult{ + Stdout: stdout, + Stderr: stderr, + ExitCode: 0, + } + if err == nil { + return result, nil + } + if exitErr, ok := err.(*ssh.ExitError); ok { + result.ExitCode = exitErr.ExitStatus() + return result, nil + } + result.ExitCode = -1 + return result, err +} + +func prependEnvExports(env map[string]string, command string) string { + if len(env) == 0 { + return command + } + var builder strings.Builder + for name, value := range env { + builder.WriteString("export ") + builder.WriteString(shellQuote(name)) + builder.WriteString("=") + builder.WriteString(shellQuote(value)) + builder.WriteString("; ") + } + builder.WriteString(command) + return builder.String() +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/service/internal/runner/executor.go b/service/internal/runner/executor.go new file mode 100644 index 0000000..bf9271a --- /dev/null +++ b/service/internal/runner/executor.go @@ -0,0 +1,355 @@ +package runner + +import ( + "fmt" + "strings" + "sync" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +const defaultRunTimeout = 5 * time.Minute + +// Executor runs action groups and items against nodes over SSH. +type Executor struct { + ConfigDir string + Key []byte + + mu sync.Mutex + running map[string]struct{} // groupID -> running + stopCh chan struct{} + stopped chan struct{} + tickerOn bool +} + +// NewExecutor creates an Executor for the given config directory and encryption key. +func NewExecutor(configDir string, key []byte) *Executor { + return &Executor{ + ConfigDir: configDir, + Key: key, + running: map[string]struct{}{}, + stopCh: make(chan struct{}), + stopped: make(chan struct{}), + } +} + +// StartScheduler begins the background schedule ticker (30s). +func (executor *Executor) StartScheduler() { + executor.mu.Lock() + if executor.tickerOn { + executor.mu.Unlock() + return + } + executor.tickerOn = true + executor.mu.Unlock() + + go func() { + defer close(executor.stopped) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-executor.stopCh: + return + case <-ticker.C: + executor.tickSchedules() + } + } + }() +} + +// StopScheduler stops the background ticker and waits for it to exit. +func (executor *Executor) StopScheduler() { + executor.mu.Lock() + if !executor.tickerOn { + executor.mu.Unlock() + return + } + executor.tickerOn = false + executor.mu.Unlock() + close(executor.stopCh) + <-executor.stopped +} + +func (executor *Executor) tryLockGroup(groupID string) bool { + executor.mu.Lock() + defer executor.mu.Unlock() + if _, ok := executor.running[groupID]; ok { + return false + } + executor.running[groupID] = struct{}{} + return true +} + +func (executor *Executor) unlockGroup(groupID string) { + executor.mu.Lock() + defer executor.mu.Unlock() + delete(executor.running, groupID) +} + +func (executor *Executor) tickSchedules() { + store, err := settings.LoadNodeActionGroupsOrEmpty(executor.ConfigDir) + if err != nil { + return + } + now := time.Now() + for _, group := range store.Groups { + if !settings.IsScheduleDue(group, now) { + continue + } + groupCopy := group + go func() { + _, _ = executor.RunGroup(groupCopy, settings.ActionRunTriggerSchedule, "scheduler") + }() + } +} + +// ResolvedAction is the effective action definition after library resolution. +type ResolvedAction struct { + Name string + Description string + Kind settings.ActionKind + Body string + Env []settings.ActionEnvVar + Source settings.ActionItemSource +} + +// ResolveItem resolves a library or local item to an executable action. +func ResolveItem(item settings.NodeActionItem, library []settings.Action) (ResolvedAction, error) { + switch item.Source { + case settings.ActionItemSourceLibrary: + if strings.TrimSpace(item.LibraryActionID) == "" { + return ResolvedAction{}, fmt.Errorf("library action id is required") + } + for _, action := range library { + if action.ID == item.LibraryActionID { + env := action.Env + if env == nil { + env = []settings.ActionEnvVar{} + } + return ResolvedAction{ + Name: action.Name, + Description: action.Description, + Kind: action.Kind, + Body: action.Body, + Env: env, + Source: settings.ActionItemSourceLibrary, + }, nil + } + } + return ResolvedAction{}, fmt.Errorf("library action %q not found", item.LibraryActionID) + case settings.ActionItemSourceLocal: + if strings.TrimSpace(item.Name) == "" { + return ResolvedAction{}, fmt.Errorf("local action name is required") + } + if strings.TrimSpace(item.Body) == "" { + return ResolvedAction{}, fmt.Errorf("local action body is required") + } + if item.Kind != settings.ActionKindShell && item.Kind != settings.ActionKindScript { + return ResolvedAction{}, fmt.Errorf("local action kind must be shell or script") + } + env := item.Env + if env == nil { + env = []settings.ActionEnvVar{} + } + return ResolvedAction{ + Name: item.Name, + Description: item.Description, + Kind: item.Kind, + Body: item.Body, + Env: env, + Source: settings.ActionItemSourceLocal, + }, nil + default: + return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source) + } +} + +// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt. +func (executor *Executor) RunGroup( + group settings.NodeActionGroup, + trigger settings.ActionRunTrigger, + actor string, +) (settings.ActionGroupRunRecord, error) { + if !executor.tryLockGroup(group.ID) { + return settings.ActionGroupRunRecord{}, fmt.Errorf("action group is already running") + } + defer executor.unlockGroup(group.ID) + + return executor.runItems(group, group.Items, trigger, actor) +} + +// RunItem executes a single item (still logged under the parent group file). +func (executor *Executor) RunItem( + group settings.NodeActionGroup, + item settings.NodeActionItem, + trigger settings.ActionRunTrigger, + actor string, +) (settings.ActionGroupRunRecord, error) { + lockKey := group.ID + ":" + item.ID + if !executor.tryLockGroup(lockKey) { + return settings.ActionGroupRunRecord{}, fmt.Errorf("action is already running") + } + defer executor.unlockGroup(lockKey) + + return executor.runItems(group, []settings.NodeActionItem{item}, trigger, actor) +} + +func (executor *Executor) runItems( + group settings.NodeActionGroup, + items []settings.NodeActionItem, + trigger settings.ActionRunTrigger, + actor string, +) (settings.ActionGroupRunRecord, error) { + runID, err := auth.NewUUID() + if err != nil { + return settings.ActionGroupRunRecord{}, err + } + + node, privateKey, passphrase, err := executor.loadNodeCredentials(group.NodeID) + if err != nil { + return settings.ActionGroupRunRecord{}, err + } + + libraryStore, err := settings.LoadActionsOrSeed(executor.ConfigDir) + if err != nil { + return settings.ActionGroupRunRecord{}, err + } + + startedAt := time.Now().UTC() + results := make([]settings.ActionItemRunResult, 0, len(items)) + + for _, item := range items { + itemStarted := time.Now().UTC() + resolved, resolveErr := ResolveItem(item, libraryStore.Actions) + if resolveErr != nil { + results = append(results, settings.ActionItemRunResult{ + ItemID: item.ID, + ActionName: item.Name, + Source: string(item.Source), + ExitCode: -1, + Stdout: "", + Stderr: "", + Error: resolveErr.Error(), + StartedAt: itemStarted, + FinishedAt: time.Now().UTC(), + }) + continue + } + + envMap := make(map[string]string, len(resolved.Env)) + for _, envVar := range resolved.Env { + envMap[envVar.Name] = envVar.Value + } + + expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{ + Host: node.Name, + IP: node.HostIP, + Username: node.Username, + Env: envMap, + Secrets: map[string]string{}, + }) + + var sshResult auth.SSHRunResult + var runErr error + switch resolved.Kind { + case settings.ActionKindShell: + sshResult, runErr = auth.RunSSHShell( + node.HostIP, + node.Username, + privateKey, + passphrase, + expandedBody, + envMap, + defaultRunTimeout, + ) + case settings.ActionKindScript: + sshResult, runErr = auth.RunSSHScript( + node.HostIP, + node.Username, + privateKey, + passphrase, + expandedBody, + envMap, + defaultRunTimeout, + ) + default: + runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind) + } + + itemResult := settings.ActionItemRunResult{ + ItemID: item.ID, + ActionName: resolved.Name, + Source: string(resolved.Source), + ExitCode: sshResult.ExitCode, + Stdout: sshResult.Stdout, + Stderr: sshResult.Stderr, + StartedAt: itemStarted, + FinishedAt: time.Now().UTC(), + } + if runErr != nil { + itemResult.Error = runErr.Error() + if itemResult.ExitCode == 0 { + itemResult.ExitCode = -1 + } + } + results = append(results, itemResult) + } + + finishedAt := time.Now().UTC() + record := settings.ActionGroupRunRecord{ + ID: runID, + NodeID: group.NodeID, + GroupID: group.ID, + GroupName: group.Name, + Trigger: trigger, + Actor: actor, + StartedAt: startedAt, + FinishedAt: finishedAt, + Actions: results, + } + + if err := settings.AppendActionLogRun(executor.ConfigDir, record); err != nil { + return record, fmt.Errorf("append action log: %w", err) + } + if err := settings.UpdateGroupLastRunAt(executor.ConfigDir, group.ID, finishedAt); err != nil { + return record, fmt.Errorf("update last run: %w", err) + } + return record, nil +} + +func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, string, string, error) { + if len(executor.Key) == 0 { + return settings.Node{}, "", "", fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar) + } + + nodeStore, err := settings.LoadNodesOrEmpty(executor.ConfigDir) + if err != nil { + return settings.Node{}, "", "", err + } + var node settings.Node + found := false + for _, candidate := range nodeStore.Nodes { + if candidate.ID == nodeID { + node = candidate + found = true + break + } + } + if !found { + return settings.Node{}, "", "", fmt.Errorf("node not found") + } + + keyStore, err := settings.LoadNodeKeysOrEmpty(executor.ConfigDir, executor.Key) + if err != nil { + return settings.Node{}, "", "", err + } + for _, entry := range keyStore.Keys { + if entry.NodeID == nodeID { + return node, entry.PrivateKey, entry.Passphrase, nil + } + } + return settings.Node{}, "", "", fmt.Errorf("node private key not found") +} diff --git a/service/internal/runner/executor_test.go b/service/internal/runner/executor_test.go new file mode 100644 index 0000000..84308e8 --- /dev/null +++ b/service/internal/runner/executor_test.go @@ -0,0 +1,53 @@ +package runner + +import ( + "testing" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +func TestResolveItemLibraryAndLocal(t *testing.T) { + library := []settings.Action{ + { + ID: settings.BuiltinActionUptimeID, + Name: "Uptime", + Kind: settings.ActionKindShell, + Body: "uptime", + Env: []settings.ActionEnvVar{}, + }, + } + + resolved, err := ResolveItem(settings.NodeActionItem{ + ID: "i1", + Source: settings.ActionItemSourceLibrary, + LibraryActionID: settings.BuiltinActionUptimeID, + }, library) + if err != nil { + t.Fatalf("ResolveItem library: %v", err) + } + if resolved.Name != "Uptime" || resolved.Body != "uptime" { + t.Fatalf("resolved = %#v", resolved) + } + + local, err := ResolveItem(settings.NodeActionItem{ + ID: "i2", + Source: settings.ActionItemSourceLocal, + Name: "Echo", + Kind: settings.ActionKindShell, + Body: "echo hi", + Env: []settings.ActionEnvVar{}, + }, library) + if err != nil { + t.Fatalf("ResolveItem local: %v", err) + } + if local.Name != "Echo" { + t.Fatalf("local = %#v", local) + } + + if _, err := ResolveItem(settings.NodeActionItem{ + Source: settings.ActionItemSourceLibrary, + LibraryActionID: "missing", + }, library); err == nil { + t.Fatal("expected missing library action error") + } +} diff --git a/service/internal/settings/action_logs.go b/service/internal/settings/action_logs.go new file mode 100644 index 0000000..433a411 --- /dev/null +++ b/service/internal/settings/action_logs.go @@ -0,0 +1,174 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// MaxActionLogRuns is the retention cap per action-group log file. +const MaxActionLogRuns = 200 + +// ActionLogDir returns action-logs/ under the config dir. +func ActionLogDir(configDir string, nodeID string) string { + return filepath.Join(configDir, ActionLogsDirName, nodeID) +} + +// ActionLogFilePath returns the JSON log path for a node + group name. +func ActionLogFilePath(configDir string, nodeID string, groupName string) string { + filename := DiskSafeFilename(groupName) + ".json" + return filepath.Join(ActionLogDir(configDir, nodeID), filename) +} + +// AppendActionLogRun appends a run record to the group's log file and trims retention. +func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error { + if strings.TrimSpace(record.NodeID) == "" { + return fmt.Errorf("node id is required") + } + if strings.TrimSpace(record.GroupName) == "" { + return fmt.Errorf("group name is required") + } + + dir := ActionLogDir(configDir, record.NodeID) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create action log dir: %w", err) + } + + path := ActionLogFilePath(configDir, record.NodeID, record.GroupName) + file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName) + if err != nil { + return err + } + + file.NodeID = record.NodeID + file.GroupID = record.GroupID + file.GroupName = record.GroupName + file.Runs = append(file.Runs, record) + if len(file.Runs) > MaxActionLogRuns { + file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:] + } + + return SaveActionLogFile(path, file) +} + +// LoadActionLogFile reads a log JSON file from path. +func LoadActionLogFile(path string) (ActionLogFile, error) { + payload, err := os.ReadFile(path) + if err != nil { + return ActionLogFile{}, err + } + var file ActionLogFile + if err := json.Unmarshal(payload, &file); err != nil { + return ActionLogFile{}, fmt.Errorf("parse action log: %w", err) + } + if file.Runs == nil { + file.Runs = []ActionGroupRunRecord{} + } + return file, nil +} + +// LoadActionLogFileOrEmpty returns an empty file when missing. +func LoadActionLogFileOrEmpty( + path string, + nodeID string, + groupID string, + groupName string, +) (ActionLogFile, error) { + file, err := LoadActionLogFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ActionLogFile{ + NodeID: nodeID, + GroupID: groupID, + GroupName: groupName, + Runs: []ActionGroupRunRecord{}, + }, nil + } + return ActionLogFile{}, err + } + return file, nil +} + +// SaveActionLogFile writes a log JSON file with mode 0640. +func SaveActionLogFile(path string, file ActionLogFile) error { + if file.Runs == nil { + file.Runs = []ActionGroupRunRecord{} + } + payload, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("encode action log: %w", err) + } + payload = append(payload, '\n') + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write action log: %w", err) + } + return nil +} + +// ListActionLogFiles lists JSON log files for a node. +func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) { + dir := ActionLogDir(configDir, nodeID) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []ActionLogFileInfo{}, nil + } + return nil, fmt.Errorf("list action logs: %w", err) + } + + result := make([]ActionLogFileInfo, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + groupName := strings.TrimSuffix(name, ".json") + groupID := "" + file, loadErr := LoadActionLogFile(filepath.Join(dir, name)) + if loadErr == nil { + if file.GroupName != "" { + groupName = file.GroupName + } + groupID = file.GroupID + } + result = append(result, ActionLogFileInfo{ + Filename: name, + GroupName: groupName, + GroupID: groupID, + ModTime: info.ModTime().UTC(), + SizeBytes: info.Size(), + }) + } + return result, nil +} + +// ReadActionLogFileByName reads one log file for a node after validating the name. +func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) { + safeName := filepath.Base(strings.TrimSpace(filename)) + if safeName == "." || safeName == ".." || safeName == "" { + return ActionLogFile{}, fmt.Errorf("invalid log filename") + } + if !strings.HasSuffix(safeName, ".json") { + return ActionLogFile{}, fmt.Errorf("invalid log filename") + } + if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) { + return ActionLogFile{}, fmt.Errorf("invalid log filename") + } + + path := filepath.Join(ActionLogDir(configDir, nodeID), safeName) + cleaned := filepath.Clean(path) + if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) { + return ActionLogFile{}, fmt.Errorf("invalid log filename") + } + return LoadActionLogFile(cleaned) +} diff --git a/service/internal/settings/node_action_groups.go b/service/internal/settings/node_action_groups.go new file mode 100644 index 0000000..96948d9 --- /dev/null +++ b/service/internal/settings/node_action_groups.go @@ -0,0 +1,227 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// LoadNodeActionGroups reads and parses node-action-groups.json from dir. +func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) { + path := filepath.Join(dir, NodeActionGroupsFileName) + payload, err := os.ReadFile(path) + if err != nil { + return NodeActionGroupStore{}, err + } + + var store NodeActionGroupStore + if err := json.Unmarshal(payload, &store); err != nil { + return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err) + } + normalizeNodeActionGroupStore(&store) + return store, nil +} + +// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing. +func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) { + store, err := LoadNodeActionGroups(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil + } + return NodeActionGroupStore{}, err + } + return store, nil +} + +// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640. +func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error { + if err := ensureConfigDir(dir); err != nil { + return err + } + normalizeNodeActionGroupStore(&store) + + payload, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("encode node action groups: %w", err) + } + payload = append(payload, '\n') + + path := filepath.Join(dir, NodeActionGroupsFileName) + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write node action groups: %w", err) + } + return nil +} + +func normalizeNodeActionGroupStore(store *NodeActionGroupStore) { + if store.Groups == nil { + store.Groups = []NodeActionGroup{} + } + for index := range store.Groups { + if store.Groups[index].Items == nil { + store.Groups[index].Items = []NodeActionItem{} + } + if store.Groups[index].Schedule.Times == nil { + store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{} + } + for itemIndex := range store.Groups[index].Items { + if store.Groups[index].Items[itemIndex].Env == nil { + store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{} + } + } + } +} + +// DiskSafeFilename converts a group name into a safe filename stem. +func DiskSafeFilename(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "unnamed" + } + safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_") + safe = strings.Trim(safe, "._-") + if safe == "" { + return "unnamed" + } + if len(safe) > 120 { + safe = safe[:120] + } + return safe +} + +// FindNodeActionGroupIndex returns the index of a group by id, or -1. +func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int { + for index := range groups { + if groups[index].ID == groupID { + return index + } + } + return -1 +} + +// FindNodeActionItemIndex returns the index of an item by id, or -1. +func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int { + for index := range items { + if items[index].ID == itemID { + return index + } + } + return -1 +} + +// GroupsForNode returns groups belonging to nodeID. +func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup { + result := make([]NodeActionGroup, 0) + for _, group := range store.Groups { + if group.NodeID == nodeID { + result = append(result, group) + } + } + return result +} + +// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves. +func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error { + store, err := LoadNodeActionGroupsOrEmpty(dir) + if err != nil { + return err + } + index := FindNodeActionGroupIndex(store.Groups, groupID) + if index < 0 { + return fmt.Errorf("action group not found") + } + runAt := at.UTC() + store.Groups[index].LastRunAt = &runAt + store.Groups[index].UpdatedAt = runAt + return SaveNodeActionGroups(dir, store) +} + +// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt. +func IsScheduleDue(group NodeActionGroup, now time.Time) bool { + schedule := group.Schedule + if !schedule.Enabled { + return false + } + localNow := now.In(time.Local) + + switch schedule.Kind { + case ActionGroupScheduleInterval: + if schedule.EverySeconds < 1 { + return false + } + if group.LastRunAt == nil { + return true + } + elapsed := localNow.Sub(group.LastRunAt.In(time.Local)) + return elapsed >= time.Duration(schedule.EverySeconds)*time.Second + case ActionGroupScheduleTimes: + if len(schedule.Times) == 0 { + return false + } + for _, spec := range schedule.Times { + if !timeSpecMatches(spec, localNow) { + continue + } + if group.LastRunAt == nil { + return true + } + lastLocal := group.LastRunAt.In(time.Local) + // Fire once per matching minute window. + sameMinute := lastLocal.Year() == localNow.Year() && + lastLocal.YearDay() == localNow.YearDay() && + lastLocal.Hour() == localNow.Hour() && + lastLocal.Minute() == localNow.Minute() + if !sameMinute { + return true + } + } + return false + default: + return false + } +} + +func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool { + hour, minute, ok := parseHHMM(spec.Time) + if !ok { + return false + } + if now.Hour() != hour || now.Minute() != minute { + return false + } + if len(spec.DaysOfWeek) == 0 { + return true + } + weekday := int(now.Weekday()) + for _, day := range spec.DaysOfWeek { + if day == weekday { + return true + } + } + return false +} + +func parseHHMM(value string) (hour int, minute int, ok bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, false + } + if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil { + return 0, 0, false + } + if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil { + return 0, 0, false + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, 0, false + } + return hour, minute, true +} diff --git a/service/internal/settings/node_action_groups_test.go b/service/internal/settings/node_action_groups_test.go new file mode 100644 index 0000000..5d23f43 --- /dev/null +++ b/service/internal/settings/node_action_groups_test.go @@ -0,0 +1,160 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDiskSafeFilename(t *testing.T) { + cases := map[string]string{ + "Daily Backup": "Daily_Backup", + " hello!! ": "hello", + "../etc/passwd": "etc_passwd", + "": "unnamed", + "a/b\\c:d*e?f": "a_b_c_d_e_f", + } + for input, want := range cases { + got := DiskSafeFilename(input) + if got != want { + t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want) + } + } +} + +func TestNodeActionGroupsRoundTrip(t *testing.T) { + dir := t.TempDir() + now := time.Now().UTC() + store := NodeActionGroupStore{ + Groups: []NodeActionGroup{ + { + ID: "g1", + NodeID: "n1", + Name: "Nightly", + Schedule: ActionGroupSchedule{ + Enabled: true, + Kind: ActionGroupScheduleInterval, + EverySeconds: 60, + Times: []ActionGroupTimeSpec{}, + }, + Items: []NodeActionItem{ + { + ID: "i1", + Source: ActionItemSourceLibrary, + LibraryActionID: BuiltinActionUptimeID, + Env: []ActionEnvVar{}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + }, + }, + } + if err := SaveNodeActionGroups(dir, store); err != nil { + t.Fatalf("SaveNodeActionGroups: %v", err) + } + loaded, err := LoadNodeActionGroups(dir) + if err != nil { + t.Fatalf("LoadNodeActionGroups: %v", err) + } + if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" { + t.Fatalf("loaded = %#v", loaded) + } + if len(loaded.Groups[0].Items) != 1 { + t.Fatalf("items = %#v", loaded.Groups[0].Items) + } +} + +func TestIsScheduleDueInterval(t *testing.T) { + now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local) + group := NodeActionGroup{ + Schedule: ActionGroupSchedule{ + Enabled: true, + Kind: ActionGroupScheduleInterval, + EverySeconds: 300, + }, + } + if !IsScheduleDue(group, now) { + t.Fatal("expected due when never run") + } + last := now.Add(-301 * time.Second) + group.LastRunAt = &last + if !IsScheduleDue(group, now) { + t.Fatal("expected due after interval elapsed") + } + recent := now.Add(-10 * time.Second) + group.LastRunAt = &recent + if IsScheduleDue(group, now) { + t.Fatal("expected not due within interval") + } +} + +func TestIsScheduleDueTimes(t *testing.T) { + now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday + group := NodeActionGroup{ + Schedule: ActionGroupSchedule{ + Enabled: true, + Kind: ActionGroupScheduleTimes, + Times: []ActionGroupTimeSpec{ + {Time: "14:30", DaysOfWeek: []int{0}}, + }, + }, + } + if !IsScheduleDue(group, now) { + t.Fatal("expected due at matching time") + } + sameMinute := now + group.LastRunAt = &sameMinute + if IsScheduleDue(group, now) { + t.Fatal("expected not due again in same minute") + } + group.LastRunAt = nil + group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only + if IsScheduleDue(group, now) { + t.Fatal("expected not due on wrong weekday") + } +} + +func TestActionLogAppendAndList(t *testing.T) { + dir := t.TempDir() + record := ActionGroupRunRecord{ + ID: "run-1", + NodeID: "node-abc", + GroupID: "group-1", + GroupName: "Nightly Jobs", + Trigger: ActionRunTriggerManual, + StartedAt: time.Now().UTC(), + FinishedAt: time.Now().UTC(), + Actions: []ActionItemRunResult{}, + } + if err := AppendActionLogRun(dir, record); err != nil { + t.Fatalf("AppendActionLogRun: %v", err) + } + path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected log file at %s: %v", path, err) + } + files, err := ListActionLogFiles(dir, "node-abc") + if err != nil { + t.Fatalf("ListActionLogFiles: %v", err) + } + if len(files) != 1 { + t.Fatalf("files = %#v", files) + } + wantName := DiskSafeFilename("Nightly Jobs") + ".json" + if files[0].Filename != wantName { + t.Fatalf("filename = %q want %q", files[0].Filename, wantName) + } + loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName) + if err != nil { + t.Fatalf("ReadActionLogFileByName: %v", err) + } + if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" { + t.Fatalf("loaded = %#v", loaded) + } + if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil { + t.Fatal("expected path traversal rejected") + } + _ = filepath.Base(wantName) +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index e391caa..0922b01 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -20,7 +20,9 @@ const ( NodeKeysFileName = "node-keys.enc" NodeAuditFileName = "node-audit.json" AuthAuditFileName = "auth-audit.json" - ActionsFileName = "actions.json" + ActionsFileName = "actions.json" + NodeActionGroupsFileName = "node-action-groups.json" + ActionLogsDirName = "action-logs" ) // ResolveDir returns the config directory using precedence: diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 2f069a7..ac03680 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -144,6 +144,116 @@ type ActionStore struct { Actions []Action `json:"actions"` } +// ActionItemSource identifies whether a node action item is a library ref or local copy. +type ActionItemSource string + +const ( + ActionItemSourceLibrary ActionItemSource = "library" + ActionItemSourceLocal ActionItemSource = "local" +) + +// ActionGroupScheduleKind identifies how an action group is scheduled. +type ActionGroupScheduleKind string + +const ( + ActionGroupScheduleInterval ActionGroupScheduleKind = "interval" + ActionGroupScheduleTimes ActionGroupScheduleKind = "times" +) + +// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule. +type ActionGroupTimeSpec struct { + Time string `json:"time"` // "HH:MM" in server local time + DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day +} + +// ActionGroupSchedule configures when an action group runs automatically. +type ActionGroupSchedule struct { + Enabled bool `json:"enabled"` + Kind ActionGroupScheduleKind `json:"kind"` + EverySeconds int `json:"every_seconds,omitempty"` + Times []ActionGroupTimeSpec `json:"times,omitempty"` +} + +// NodeActionItem is one ordered action inside a node action group. +type NodeActionItem struct { + ID string `json:"id"` + Source ActionItemSource `json:"source"` + LibraryActionID string `json:"library_action_id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Kind ActionKind `json:"kind,omitempty"` + Body string `json:"body,omitempty"` + Env []ActionEnvVar `json:"env,omitempty"` +} + +// NodeActionGroup is a named ordered set of actions with one schedule on a node. +type NodeActionGroup struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Schedule ActionGroupSchedule `json:"schedule"` + Items []NodeActionItem `json:"items"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// NodeActionGroupStore is the plain JSON payload in node-action-groups.json. +type NodeActionGroupStore struct { + Groups []NodeActionGroup `json:"groups"` +} + +// ActionRunTrigger identifies what started an action run. +type ActionRunTrigger string + +const ( + ActionRunTriggerManual ActionRunTrigger = "manual" + ActionRunTriggerSchedule ActionRunTrigger = "schedule" +) + +// ActionItemRunResult is the captured output of one action within a run. +type ActionItemRunResult struct { + ItemID string `json:"item_id"` + ActionName string `json:"action_name"` + Source string `json:"source"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` +} + +// ActionGroupRunRecord is one execution of a group (or a single-item manual run). +type ActionGroupRunRecord struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + GroupID string `json:"group_id"` + GroupName string `json:"group_name"` + Trigger ActionRunTrigger `json:"trigger"` + Actor string `json:"actor,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Actions []ActionItemRunResult `json:"actions"` +} + +// ActionLogFile is the JSON payload for one node/group log file on disk. +type ActionLogFile struct { + NodeID string `json:"node_id"` + GroupID string `json:"group_id"` + GroupName string `json:"group_name"` + Runs []ActionGroupRunRecord `json:"runs"` +} + +// ActionLogFileInfo is metadata for listing log files without reading full content. +type ActionLogFileInfo struct { + Filename string `json:"filename"` + GroupName string `json:"group_name"` + GroupID string `json:"group_id,omitempty"` + ModTime time.Time `json:"mod_time"` + SizeBytes int64 `json:"size_bytes"` +} + // NodeKeyEntry holds private key material for one node, keyed by node UUID. type NodeKeyEntry struct { NodeID string `json:"node_id"` diff --git a/webui/src/App.css b/webui/src/App.css index 02ec07b..82dfa82 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -422,24 +422,60 @@ margin: 0; padding: 0; list-style: none; - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(2, minmax(min(100%, 18rem), 1fr)); gap: 0.75rem; } +@media (max-width: 640px) { + .node-list { + grid-template-columns: 1fr; + } +} + .node-list-item { - padding: 0.75rem 0; - border-bottom: 1px solid var(--ctp-surface0); + padding: 0.85rem 1rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ctp-surface0) 55%, transparent); + min-width: 0; + max-width: 100%; } .node-list-heading { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: flex-start; + justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.25rem; } +.node-list-heading-main { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1rem; + min-width: 0; +} + +.node-edit { + flex-shrink: 0; + margin-left: auto; + padding: 0.3rem 0.7rem; + border: 1px solid var(--ctp-surface1); + border-radius: 6px; + background: var(--ctp-surface0); + color: var(--ctp-text); + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.node-edit:hover { + border-color: var(--ctp-overlay0); +} + .node-id { font-size: 0.8rem; color: var(--ctp-subtext0); @@ -1359,3 +1395,183 @@ opacity: 0.45; cursor: not-allowed; } + +.node-detail-back { + margin-bottom: 0.75rem; +} + +.node-detail-header { + margin-bottom: 1rem; +} + +.node-detail-header h2 { + margin: 0 0 0.35rem; +} + +.node-detail-overview { + display: grid; + gap: 0.75rem; + margin: 0; +} + +.node-detail-overview dt { + font-size: 0.8rem; + color: var(--ctp-subtext0); +} + +.node-detail-overview dd { + margin: 0.15rem 0 0; +} + +.node-action-group-create, +.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; + gap: 0.5rem; + align-items: center; +} + +.node-action-group-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.node-action-group-card { + border: 1px solid var(--ctp-surface1); + border-radius: 0.5rem; + padding: 0.85rem 1rem; + background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent); +} + +.node-action-schedule { + border: 1px solid var(--ctp-surface1); + border-radius: 0.4rem; + padding: 0.75rem; + margin: 0.75rem 0; + display: grid; + gap: 0.5rem; +} + +.node-action-item-list { + margin: 0.5rem 0 1rem; + padding-left: 1.25rem; + display: grid; + gap: 0.65rem; +} + +.node-action-item-edit, +.node-action-add-custom, +.node-action-adders { + display: grid; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.schedule-times-editor, +.schedule-time-row, +.schedule-weekdays { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.checkbox-row { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; +} + +.node-action-run-result { + margin-top: 1.25rem; + border-top: 1px solid var(--ctp-surface1); + padding-top: 1rem; +} + +.action-log-pre { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.8rem; + background: var(--ctp-mantle); + border: 1px solid var(--ctp-surface1); + border-radius: 0.35rem; + padding: 0.65rem; + max-height: 16rem; + overflow: auto; +} + +.action-log-stderr { + color: var(--ctp-red); +} + +.node-action-logs-layout { + display: grid; + grid-template-columns: minmax(12rem, 16rem) 1fr; + gap: 1rem; + min-height: 18rem; +} + +@media (max-width: 720px) { + .node-action-logs-layout { + grid-template-columns: 1fr; + } +} + +.node-action-logs-list { + border: 1px solid var(--ctp-surface1); + border-radius: 0.45rem; + padding: 0.5rem; + overflow: auto; + max-height: 28rem; +} + +.node-action-logs-list ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.35rem; +} + +.log-file-button { + width: 100%; + text-align: left; + display: grid; + gap: 0.15rem; + background: transparent; + border: 1px solid transparent; + border-radius: 0.35rem; + padding: 0.45rem 0.55rem; + color: inherit; + cursor: pointer; +} + +.log-file-button-active, +.log-file-button:hover { + border-color: var(--ctp-surface2); + background: color-mix(in srgb, var(--ctp-surface1) 60%, transparent); +} + +.node-action-logs-content { + border: 1px solid var(--ctp-surface1); + border-radius: 0.45rem; + padding: 0.75rem 1rem; + overflow: auto; + max-height: 28rem; +} + +.action-log-run { + margin-bottom: 0.65rem; +} + +button.danger { + color: var(--ctp-red); +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6b9cbdc..da85816 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -73,6 +73,7 @@ import { type LogsPageSize, } from './logsPreferences' import { LoginPage } from './LoginPage' +import { NodeDetailPanel } from './NodeDetailPanel' import { countAuthFailuresToday, countNodesBySection, @@ -86,6 +87,7 @@ import { pathFor, subscribeToLocation, type ConfigTabId, + type NodeDetailTabId, type ResourceSectionId, type ResourceTabId, type SectionId, @@ -2546,8 +2548,21 @@ function ResourceOverviewTab({ return (
  • - {node.name} - {node.id} +
    + {node.name} + {node.id} +
    +

    {node.host_ip} · {node.username} · group {node.group_name} ·{' '} @@ -2919,10 +2934,14 @@ function ResourceSectionPanel({ section, activeResourceTab, onResourceTabChange, + nodeId, + nodeDetailTab, }: { section: ResourceSectionId activeResourceTab: ResourceTabId onResourceTabChange: (tab: ResourceTabId) => void + nodeId: string | null + nodeDetailTab: NodeDetailTabId }) { const [me, setMe] = useState(null) const [overviewKey, setOverviewKey] = useState(0) @@ -2955,6 +2974,16 @@ function ResourceSectionPanel({ const canDelete = Boolean(me?.permissions?.includes('nodes.delete')) const tabs = resourceTabsFor(section) + if (nodeId) { + return ( + + ) + } + return (

    ( initialLocation.resourceTab, ) + const [activeNodeId, setActiveNodeId] = useState( + initialLocation.nodeId, + ) + const [activeNodeDetailTab, setActiveNodeDetailTab] = + useState(initialLocation.nodeDetailTab) const [profileDisplayName, setProfileDisplayName] = useState( null, ) @@ -3764,6 +3798,8 @@ function App() { setActiveSection(location.section) setActiveConfigTab(location.configTab) setActiveResourceTab(location.resourceTab) + setActiveNodeId(location.nodeId) + setActiveNodeDetailTab(location.nodeDetailTab) }) }, []) @@ -3916,6 +3952,8 @@ function App() { return } setActiveSection(section) + setActiveNodeId(null) + setActiveNodeDetailTab('overview') if (section === 'configuration') { navigateTo(pathFor(section, activeConfigTab)) return @@ -3932,6 +3970,7 @@ function App() { function goToConfigTab(tab: ConfigTabId) { setActiveSection('configuration') setActiveConfigTab(tab) + setActiveNodeId(null) navigateTo(pathFor('configuration', tab)) } @@ -3940,6 +3979,8 @@ function App() { return } setActiveResourceTab(tab) + setActiveNodeId(null) + setActiveNodeDetailTab('overview') navigateTo(pathFor(activeSection, 'overview', tab)) } @@ -4150,6 +4191,8 @@ function App() { section={activeSection} activeResourceTab={activeResourceTab} onResourceTabChange={goToResourceTab} + nodeId={activeNodeId} + nodeDetailTab={activeNodeDetailTab} /> ) : (

    Coming soon

    diff --git a/webui/src/NodeDetailPanel.tsx b/webui/src/NodeDetailPanel.tsx new file mode 100644 index 0000000..f5d454d --- /dev/null +++ b/webui/src/NodeDetailPanel.tsx @@ -0,0 +1,1182 @@ +import { useEffect, useState } from 'react' +import { + type Action, + type ActionGroupSchedule, + type ActionLogFile, + type ActionLogFileInfo, + type ActionGroupRunRecord, + type MeResponse, + type Node, + type NodeActionGroup, + type NodeActionItem, + cloneActionGroupItem, + createActionGroup, + createActionGroupItem, + deleteActionGroup, + deleteActionGroupItem, + fetchActionGroups, + fetchActionLogFile, + fetchActionLogs, + fetchActions, + fetchMe, + fetchNode, + patchActionGroup, + patchActionGroupItem, + reorderActionGroupItems, + runActionGroup, + runActionGroupItem, +} from './api/client' +import { SECTION_TITLES } from './documentTitle' +import { + type NodeDetailTabId, + type ResourceSectionId, + navigateTo, + pathFor, +} from './navigation' + +const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const + +const NODE_DETAIL_TABS: ReadonlyArray<{ id: NodeDetailTabId; label: string }> = [ + { id: 'overview', label: 'Overview' }, + { id: 'actions', label: 'Actions' }, + { id: 'logs', label: 'Logs' }, +] + +function defaultSchedule(): ActionGroupSchedule { + return { + enabled: false, + kind: 'interval', + every_seconds: 3600, + times: [], + } +} + +function itemDisplayName( + item: NodeActionItem, + libraryById: Map, +): string { + if (item.source === 'local') { + return item.name || 'Untitled action' + } + const libraryAction = libraryById.get(item.library_action_id || '') + return libraryAction?.name || `Library ${item.library_action_id || 'action'}` +} + +export function NodeDetailPanel({ + section, + nodeId, + activeTab, +}: { + section: ResourceSectionId + nodeId: string + activeTab: NodeDetailTabId +}) { + const [me, setMe] = useState(null) + const [node, setNode] = useState(null) + const [loadError, setLoadError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let isCancelled = false + + async function load() { + setIsLoading(true) + setLoadError(null) + try { + const [meResponse, nodeResponse] = await Promise.all([ + fetchMe(), + fetchNode(nodeId), + ]) + if (!isCancelled) { + setMe(meResponse) + setNode(nodeResponse.node) + } + } catch (err) { + if (!isCancelled) { + setLoadError(err instanceof Error ? err.message : 'Failed to load node') + setNode(null) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void load() + return () => { + isCancelled = true + } + }, [nodeId]) + + function goToTab(tab: NodeDetailTabId) { + navigateTo(pathFor(section, 'overview', 'overview', nodeId, tab)) + } + + function goBack() { + navigateTo(pathFor(section)) + } + + if (isLoading) { + return

    Loading…

    + } + + if (loadError || !node) { + return ( +
    + +

    {loadError || 'Node not found'}

    +
    + ) + } + + 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 canReadLogs = Boolean(me?.permissions?.includes('logs.read')) + + return ( +
    + +
    +

    {node.name}

    +

    + {node.host_ip} · {node.username} · {node.group_name} +

    + {node.id} +
    + +
    + {NODE_DETAIL_TABS.map((tab) => { + const isSelected = activeTab === tab.id + return ( + + ) + })} +
    + +
    + {activeTab === 'overview' ? ( + + ) : null} + {activeTab === 'actions' ? ( + + ) : null} + {activeTab === 'logs' ? ( + + ) : null} +
    +
    + ) +} + +function NodeDetailOverviewTab({ node }: { node: Node }) { + return ( +
    +
    +
    Name
    +
    {node.name}
    +
    +
    +
    Host IP
    +
    {node.host_ip}
    +
    +
    +
    Username
    +
    {node.username}
    +
    +
    +
    Group
    +
    {node.group_name}
    +
    +
    +
    Key algorithm
    +
    {node.key_algo}
    +
    +
    +
    Created
    +
    {new Date(node.created_at).toLocaleString()}
    +
    +
    + ) +} + +function NodeActionsTab({ + nodeId, + canCreate, + canUpdate, + canDelete, + canRun, +}: { + nodeId: string + canCreate: boolean + canUpdate: boolean + canDelete: boolean + canRun: boolean +}) { + const [groups, setGroups] = useState>([]) + const [library, setLibrary] = useState>([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [newGroupName, setNewGroupName] = useState('') + const [busyKey, setBusyKey] = useState(null) + const [lastRun, setLastRun] = useState(null) + + const libraryById = new Map(library.map((action) => [action.id, action])) + + async function reload() { + const [groupsResponse, actionsResponse] = await Promise.all([ + fetchActionGroups(nodeId), + fetchActions(), + ]) + setGroups(groupsResponse.groups) + setLibrary(actionsResponse.actions) + } + + useEffect(() => { + let isCancelled = false + + async function load() { + setIsLoading(true) + setError(null) + try { + await reload() + } catch (err) { + if (!isCancelled) { + setError(err instanceof Error ? err.message : 'Failed to load actions') + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void load() + return () => { + isCancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodeId]) + + async function handleCreateGroup() { + const name = newGroupName.trim() + if (!name) { + return + } + setBusyKey('create-group') + setError(null) + try { + await createActionGroup(nodeId, { + name, + schedule: defaultSchedule(), + }) + setNewGroupName('') + await reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create group') + } finally { + setBusyKey(null) + } + } + + function replaceGroup(updated: NodeActionGroup) { + setGroups((previous) => + previous.map((group) => (group.id === updated.id ? updated : group)), + ) + } + + if (isLoading) { + return

    Loading action groups…

    + } + + return ( +
    + {error ?

    {error}

    : null} + {canCreate ? ( +
    + setNewGroupName(event.target.value)} + aria-label="New action group name" + /> + +
    + ) : ( +

    + You need actions.create to add action groups. +

    + )} + + {groups.length === 0 ? ( +

    No action groups on this host yet.

    + ) : ( +
      + {groups.map((group) => ( +
    • + +
    • + ))} +
    + )} + + {lastRun ? ( +
    +

    Last run result

    +

    + {lastRun.group_name} · {lastRun.trigger} ·{' '} + {new Date(lastRun.finished_at).toLocaleString()} +

    + {lastRun.actions.map((actionResult) => ( +
    + + {actionResult.action_name} (exit {actionResult.exit_code}) + {actionResult.error ? ` — ${actionResult.error}` : ''} + + {actionResult.stdout ? ( +
    {actionResult.stdout}
    + ) : null} + {actionResult.stderr ? ( +
    +                  {actionResult.stderr}
    +                
    + ) : null} +
    + ))} +
    + ) : null} +
    + ) +} + +function ActionGroupEditor({ + nodeId, + group, + library, + libraryById, + canCreate, + canUpdate, + canDelete, + canRun, + busyKey, + setBusyKey, + setError, + onGroupChange, + onGroupsChange, + onRunComplete, +}: { + nodeId: string + group: NodeActionGroup + library: ReadonlyArray + libraryById: Map + canCreate: boolean + canUpdate: boolean + canDelete: boolean + canRun: boolean + busyKey: string | null + setBusyKey: (key: string | null) => void + setError: (message: string | null) => void + onGroupChange: (group: NodeActionGroup) => void + onGroupsChange: (groups: ReadonlyArray) => void + onRunComplete: (run: ActionGroupRunRecord) => void +}) { + const [nameDraft, setNameDraft] = useState(group.name) + const [scheduleDraft, setScheduleDraft] = useState( + group.schedule || defaultSchedule(), + ) + const [libraryPick, setLibraryPick] = useState('') + const [customName, setCustomName] = useState('') + const [customBody, setCustomBody] = useState('') + const [customKind, setCustomKind] = useState<'shell' | 'script'>('shell') + const [editingItemId, setEditingItemId] = useState(null) + const [editName, setEditName] = useState('') + const [editBody, setEditBody] = useState('') + + useEffect(() => { + setNameDraft(group.name) + setScheduleDraft(group.schedule || defaultSchedule()) + }, [group]) + + async function saveSchedule() { + setBusyKey(`save-${group.id}`) + setError(null) + try { + const response = await patchActionGroup(nodeId, group.id, { + name: nameDraft.trim(), + schedule: scheduleDraft, + }) + onGroupChange(response.group) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save group') + } finally { + setBusyKey(null) + } + } + + async function handleDeleteGroup() { + if (!window.confirm(`Delete action group “${group.name}”?`)) { + return + } + setBusyKey(`delete-group-${group.id}`) + setError(null) + try { + const response = await deleteActionGroup(nodeId, group.id) + onGroupsChange(response.groups) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete group') + } finally { + setBusyKey(null) + } + } + + async function handleAddLibrary() { + if (!libraryPick) { + return + } + setBusyKey(`add-lib-${group.id}`) + setError(null) + try { + const response = await createActionGroupItem(nodeId, group.id, { + 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) + } + } + + async function handleAddCustom() { + setBusyKey(`add-custom-${group.id}`) + setError(null) + try { + const response = await createActionGroupItem(nodeId, group.id, { + 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) + } + } + + 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) { + 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) + } + } + + async function handleRunGroup() { + setBusyKey(`run-group-${group.id}`) + setError(null) + try { + const response = await runActionGroup(nodeId, group.id) + onRunComplete(response.run) + const refreshed = await fetchActionGroups(nodeId) + onGroupsChange(refreshed.groups) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to run group') + } finally { + setBusyKey(null) + } + } + + return ( +
    +
    + {canUpdate ? ( + setNameDraft(event.target.value)} + aria-label="Action group name" + /> + ) : ( + {group.name} + )} +
    + {canRun ? ( + + ) : null} + {canUpdate ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
    +
    + + {group.last_run_at ? ( +

    + Last run: {new Date(group.last_run_at).toLocaleString()} +

    + ) : ( +

    Never run

    + )} + +
    + Schedule + + + {scheduleDraft.kind === 'interval' ? ( + + ) : ( + + setScheduleDraft((previous) => ({ ...previous, times })) + } + /> + )} +
    + +

    Actions

    + {group.items.length === 0 ? ( +

    No actions in this group yet.

    + ) : ( +
      + {group.items.map((item, index) => ( +
    1. +
      + + {itemDisplayName(item, libraryById)}{' '} + ({item.source}) + +
      + {canUpdate ? ( + <> + + + + ) : null} + {canRun ? ( + + ) : null} + {item.source === 'library' && canCreate ? ( + + ) : null} + {item.source === 'local' && canUpdate ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
      +
      + {editingItemId === item.id ? ( +
      + +