Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type actionGroupsResponse struct {
|
||||
Groups []settings.NodeActionGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type actionGroupResponse struct {
|
||||
Group settings.NodeActionGroup `json:"group"`
|
||||
}
|
||||
|
||||
type actionRunResponse struct {
|
||||
Run settings.ActionGroupRunRecord `json:"run"`
|
||||
}
|
||||
|
||||
type actionLogsListResponse struct {
|
||||
Files []settings.ActionLogFileInfo `json:"files"`
|
||||
}
|
||||
|
||||
type actionLogFileResponse struct {
|
||||
Log settings.ActionLogFile `json:"log"`
|
||||
}
|
||||
|
||||
type createActionGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Schedule *settings.ActionGroupSchedule `json:"schedule"`
|
||||
}
|
||||
|
||||
type patchActionGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Schedule *settings.ActionGroupSchedule `json:"schedule"`
|
||||
}
|
||||
|
||||
type createActionGroupItemRequest struct {
|
||||
Source settings.ActionItemSource `json:"source"`
|
||||
LibraryActionID string `json:"library_action_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
}
|
||||
|
||||
type patchActionGroupItemRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Kind *settings.ActionKind `json:"kind"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
}
|
||||
|
||||
type reorderActionGroupItemsRequest struct {
|
||||
ItemIDs []string `json:"item_ids"`
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var payload createActionGroupRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
if name == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
schedule := settings.ActionGroupSchedule{
|
||||
Enabled: false,
|
||||
Kind: settings.ActionGroupScheduleInterval,
|
||||
Times: []settings.ActionGroupTimeSpec{},
|
||||
}
|
||||
if payload.Schedule != nil {
|
||||
schedule = *payload.Schedule
|
||||
if schedule.Times == nil {
|
||||
schedule.Times = []settings.ActionGroupTimeSpec{}
|
||||
}
|
||||
}
|
||||
if err := validateSchedule(schedule); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
group := settings.NodeActionGroup{
|
||||
ID: groupID,
|
||||
NodeID: node.ID,
|
||||
Name: name,
|
||||
Schedule: schedule,
|
||||
Items: []settings.NodeActionItem{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
store.Groups = append(store.Groups, group)
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload patchActionGroupRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
group := store.Groups[index]
|
||||
if payload.Name != nil {
|
||||
name := strings.TrimSpace(*payload.Name)
|
||||
if name == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
|
||||
return
|
||||
}
|
||||
group.Name = name
|
||||
}
|
||||
if payload.Schedule != nil {
|
||||
schedule := *payload.Schedule
|
||||
if schedule.Times == nil {
|
||||
schedule.Times = []settings.ActionGroupTimeSpec{}
|
||||
}
|
||||
if err := validateSchedule(schedule); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
group.Schedule = schedule
|
||||
}
|
||||
group.UpdatedAt = time.Now().UTC()
|
||||
store.Groups[index] = group
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
store.Groups = append(store.Groups[:index], store.Groups[index+1:]...)
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload createActionGroupItemRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := buildNodeActionItem(payload)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
library, libErr := settings.LoadActionsOrSeed(app.ConfigDir)
|
||||
if libErr != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: libErr.Error()})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, action := range library.Actions {
|
||||
if action.ID == item.LibraryActionID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
store.Groups[index].Items = append(store.Groups[index].Items, item)
|
||||
store.Groups[index].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[index]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
var payload patchActionGroupItemRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
if item.Source != settings.ActionItemSourceLocal {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Name != nil {
|
||||
item.Name = strings.TrimSpace(*payload.Name)
|
||||
}
|
||||
if payload.Description != nil {
|
||||
item.Description = strings.TrimSpace(*payload.Description)
|
||||
}
|
||||
if payload.Kind != nil {
|
||||
item.Kind = *payload.Kind
|
||||
}
|
||||
if payload.Body != nil {
|
||||
item.Body = strings.TrimSpace(*payload.Body)
|
||||
}
|
||||
if payload.Env != nil {
|
||||
item.Env = *payload.Env
|
||||
}
|
||||
if item.Env == nil {
|
||||
item.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
if item.Source != settings.ActionItemSourceLibrary {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "only library actions can be cloned"})
|
||||
return
|
||||
}
|
||||
|
||||
library, err := settings.LoadActionsOrSeed(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
var libraryAction settings.Action
|
||||
found := false
|
||||
for _, action := range library.Actions {
|
||||
if action.ID == item.LibraryActionID {
|
||||
libraryAction = action
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
|
||||
return
|
||||
}
|
||||
|
||||
env := libraryAction.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
item.Source = settings.ActionItemSourceLocal
|
||||
item.LibraryActionID = ""
|
||||
item.Name = libraryAction.Name
|
||||
item.Description = libraryAction.Description
|
||||
item.Kind = libraryAction.Kind
|
||||
item.Body = libraryAction.Body
|
||||
item.Env = env
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
items := store.Groups[groupIndex].Items
|
||||
store.Groups[groupIndex].Items = append(items[:itemIndex], items[itemIndex+1:]...)
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
|
||||
var payload reorderActionGroupItemsRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
current := store.Groups[groupIndex].Items
|
||||
if len(payload.ItemIDs) != len(current) {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "item_ids must include every item exactly once"})
|
||||
return
|
||||
}
|
||||
byID := make(map[string]settings.NodeActionItem, len(current))
|
||||
for _, item := range current {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
reordered := make([]settings.NodeActionItem, 0, len(payload.ItemIDs))
|
||||
seen := make(map[string]struct{}, len(payload.ItemIDs))
|
||||
for _, itemID := range payload.ItemIDs {
|
||||
item, exists := byID[itemID]
|
||||
if !exists {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "unknown item id in order list"})
|
||||
return
|
||||
}
|
||||
if _, dup := seen[itemID]; dup {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "duplicate item id in order list"})
|
||||
return
|
||||
}
|
||||
seen[itemID] = struct{}{}
|
||||
reordered = append(reordered, item)
|
||||
}
|
||||
store.Groups[groupIndex].Items = reordered
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
group, found := app.findGroupForNode(node.ID, groupID)
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
if app.Executor == nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
|
||||
return
|
||||
}
|
||||
actor := ""
|
||||
if user, ok := UserFromContext(request.Context()); ok {
|
||||
actor = user.Username
|
||||
}
|
||||
record, err := app.Executor.RunGroup(group, settings.ActionRunTriggerManual, actor)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
|
||||
}
|
||||
|
||||
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groupID := strings.TrimSpace(request.PathValue("gid"))
|
||||
itemID := strings.TrimSpace(request.PathValue("iid"))
|
||||
group, found := app.findGroupForNode(node.ID, groupID)
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
|
||||
return
|
||||
}
|
||||
itemIndex := settings.FindNodeActionItemIndex(group.Items, itemID)
|
||||
if itemIndex < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
|
||||
return
|
||||
}
|
||||
if app.Executor == nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
|
||||
return
|
||||
}
|
||||
actor := ""
|
||||
if user, ok := UserFromContext(request.Context()); ok {
|
||||
actor = user.Username
|
||||
}
|
||||
record, err := app.Executor.RunItem(group, group.Items[itemIndex], settings.ActionRunTriggerManual, actor)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
|
||||
}
|
||||
|
||||
func (app *App) actionLogsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
files, err := settings.ListActionLogFiles(app.ConfigDir, node.ID)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionLogsListResponse{Files: files})
|
||||
}
|
||||
|
||||
func (app *App) actionLogsGetHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filename := strings.TrimSpace(request.PathValue("filename"))
|
||||
logFile, err := settings.ReadActionLogFileByName(app.ConfigDir, node.ID, filename)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid log filename") {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionLogFileResponse{Log: logFile})
|
||||
}
|
||||
|
||||
func (app *App) loadAccessibleNode(writer http.ResponseWriter, request *http.Request) (settings.Node, bool) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
nodeID := strings.TrimSpace(request.PathValue("id"))
|
||||
if nodeID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
for _, node := range store.Nodes {
|
||||
if node.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
|
||||
return settings.Node{}, false
|
||||
}
|
||||
|
||||
func (app *App) findGroupForNode(nodeID string, groupID string) (settings.NodeActionGroup, bool) {
|
||||
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.NodeActionGroup{}, false
|
||||
}
|
||||
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
|
||||
if index < 0 || store.Groups[index].NodeID != nodeID {
|
||||
return settings.NodeActionGroup{}, false
|
||||
}
|
||||
return store.Groups[index], true
|
||||
}
|
||||
|
||||
func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeActionItem, error) {
|
||||
itemID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
switch payload.Source {
|
||||
case settings.ActionItemSourceLibrary:
|
||||
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
||||
if libraryID == "" {
|
||||
return settings.NodeActionItem{}, errors.New("library_action_id is required")
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
}, nil
|
||||
case settings.ActionItemSourceLocal:
|
||||
env := payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
body := strings.TrimSpace(payload.Body)
|
||||
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
|
||||
return settings.NodeActionItem{}, err
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(payload.Description),
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
}, nil
|
||||
default:
|
||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||
}
|
||||
}
|
||||
|
||||
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
||||
switch schedule.Kind {
|
||||
case settings.ActionGroupScheduleInterval:
|
||||
if schedule.Enabled && schedule.EverySeconds < 1 {
|
||||
return errors.New("every_seconds must be at least 1 when interval schedule is enabled")
|
||||
}
|
||||
return nil
|
||||
case settings.ActionGroupScheduleTimes:
|
||||
if schedule.Enabled && len(schedule.Times) == 0 {
|
||||
return errors.New("at least one time is required when times schedule is enabled")
|
||||
}
|
||||
for _, spec := range schedule.Times {
|
||||
hour, minute, ok := parseScheduleHHMM(spec.Time)
|
||||
if !ok {
|
||||
return errors.New("time must be HH:MM")
|
||||
}
|
||||
_ = hour
|
||||
_ = minute
|
||||
for _, day := range spec.DaysOfWeek {
|
||||
if day < 0 || day > 6 {
|
||||
return errors.New("days_of_week values must be 0-6")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "":
|
||||
return errors.New("schedule kind is required")
|
||||
default:
|
||||
return errors.New("schedule kind must be interval or times")
|
||||
}
|
||||
}
|
||||
|
||||
func parseScheduleHHMM(value string) (hour int, minute int, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[0], &hour); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[1], &minute); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
|
||||
func fmtSscanf(input string, dest *int) (int, error) {
|
||||
n := 0
|
||||
for _, ch := range input {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if input == "" {
|
||||
return 0, errors.New("empty")
|
||||
}
|
||||
*dest = n
|
||||
return 1, nil
|
||||
}
|
||||
Reference in New Issue
Block a user