903 lines
30 KiB
Go
903 lines
30 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"`
|
|
WidgetLabel string `json:"widget_label"`
|
|
}
|
|
|
|
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"`
|
|
WidgetLabel *string `json:"widget_label"`
|
|
}
|
|
|
|
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.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
|
|
}
|
|
} else if payload.Env != nil {
|
|
env := *payload.Env
|
|
if env == nil {
|
|
env = []settings.ActionEnvVar{}
|
|
}
|
|
for index := range env {
|
|
env[index].Name = strings.TrimSpace(env[index].Name)
|
|
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "environment variable name must match [A-Za-z_][A-Za-z0-9_]*"})
|
|
return
|
|
}
|
|
}
|
|
item.Env = env
|
|
}
|
|
|
|
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
|
|
}
|
|
if payload.WidgetLabel != nil {
|
|
item.WidgetLabel = strings.TrimSpace(*payload.WidgetLabel)
|
|
}
|
|
|
|
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
|
|
}
|
|
_ = settings.DeleteActionWidgetsForItem(app.ConfigDir, node.ID, itemID)
|
|
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")
|
|
}
|
|
env := payload.Env
|
|
if env == nil {
|
|
env = []settings.ActionEnvVar{}
|
|
}
|
|
for index := range env {
|
|
env[index].Name = strings.TrimSpace(env[index].Name)
|
|
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
|
return settings.NodeActionItem{}, errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
|
|
}
|
|
}
|
|
return settings.NodeActionItem{
|
|
ID: itemID,
|
|
Source: settings.ActionItemSourceLibrary,
|
|
LibraryActionID: libraryID,
|
|
Env: env,
|
|
SetVariable: setVariable,
|
|
RunIf: runIf,
|
|
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
|
}, 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,
|
|
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
|
}, 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
|
|
}
|