Files
ClusterCanvas/service/internal/api/action_groups_handlers.go
T
Squid 7631591f30 Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
2026-07-19 22:00:14 +02:00

872 lines
29 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"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"`
RequiresSudo bool `json:"requires_sudo"`
SetVariable string `json:"set_variable"`
RunIf string `json:"run_if"`
}
type patchActionGroupItemRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Kind *settings.ActionKind `json:"kind"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
RequiresSudo *bool `json:"requires_sudo"`
SetVariable *string `json:"set_variable"`
RunIf *string `json:"run_if"`
}
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]
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
return
}
if item.Source == settings.ActionItemSourceLocal {
if payload.Name != nil {
item.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
item.Description = strings.TrimSpace(*payload.Description)
}
if payload.Kind != nil {
item.Kind = *payload.Kind
}
if payload.Body != nil {
item.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
item.Env = *payload.Env
}
if payload.RequiresSudo != nil {
item.RequiresSudo = *payload.RequiresSudo
}
if item.Env == nil {
item.Env = []settings.ActionEnvVar{}
}
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
}
if payload.SetVariable != nil {
setVariable, err := normalizeSetVariable(*payload.SetVariable)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.SetVariable = setVariable
}
if payload.RunIf != nil {
runIf, err := normalizeRunIf(*payload.RunIf)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.RunIf = runIf
}
store.Groups[groupIndex].Items[itemIndex] = item
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
item.RequiresSudo = libraryAction.RequiresSudo
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 := authorizeActionsExecute(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"))
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 := authorizeActionsExecute(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"))
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
}
setVariable, err := normalizeSetVariable(payload.SetVariable)
if err != nil {
return settings.NodeActionItem{}, err
}
runIf, err := normalizeRunIf(payload.RunIf)
if err != nil {
return settings.NodeActionItem{}, err
}
switch payload.Source {
case settings.ActionItemSourceLibrary:
libraryID := strings.TrimSpace(payload.LibraryActionID)
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{},
SetVariable: setVariable,
RunIf: runIf,
}, 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,
RequiresSudo: payload.RequiresSudo,
SetVariable: setVariable,
RunIf: runIf,
}, nil
default:
return settings.NodeActionItem{}, errors.New("source must be library or local")
}
}
func normalizeSetVariable(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if strings.HasPrefix(trimmed, "$") {
trimmed = strings.TrimPrefix(trimmed, "$")
trimmed = strings.TrimSpace(trimmed)
}
if err := runner.ValidateVariableName(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func normalizeRunIf(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if err := runner.ValidateCondition(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func validateSchedule(schedule settings.ActionGroupSchedule) error {
switch schedule.Kind {
case settings.ActionGroupScheduleInterval:
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
}