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 }