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.
872 lines
29 KiB
Go
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
|
|
}
|