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:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
@@ -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
}
@@ -0,0 +1,224 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedNodeForActions(t *testing.T, configDir string) settings.Node {
t.Helper()
node := settings.Node{
ID: "11111111-1111-4111-8111-111111111111",
Kind: settings.NodeKindContainer,
Name: "box-1",
HostIP: "127.0.0.1",
Username: "root",
GroupName: settings.AdministratorsGroupName,
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}
if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
return node
}
func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
router := NewRouter(configDir)
createBody, _ := json.Marshal(map[string]any{
"name": "Nightly",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"every_seconds": 3600,
"times": []any{},
},
})
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(createBody),
),
cookie,
)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
}
var created actionGroupResponse
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
groupID := created.Group.ID
addLibBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": settings.BuiltinActionUptimeID,
})
addReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLibBody),
),
cookie,
)
addRec := httptest.NewRecorder()
router.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusOK {
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
}
var withItem actionGroupResponse
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
t.Fatalf("decode add: %v", err)
}
if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary {
t.Fatalf("items = %#v", withItem.Group.Items)
}
itemID := withItem.Group.Items[0].ID
addCustomBody, _ := json.Marshal(map[string]any{
"source": "local",
"name": "Echo",
"kind": "shell",
"body": "echo hi",
"env": []any{},
})
addCustomReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addCustomBody),
),
cookie,
)
addCustomRec := httptest.NewRecorder()
router.ServeHTTP(addCustomRec, addCustomReq)
if addCustomRec.Code != http.StatusOK {
t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String())
}
var withTwo actionGroupResponse
_ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo)
if len(withTwo.Group.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items))
}
customID := withTwo.Group.Items[1].ID
orderBody, _ := json.Marshal(map[string]any{
"item_ids": []string{customID, itemID},
})
orderReq := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order",
bytes.NewReader(orderBody),
),
cookie,
)
orderRec := httptest.NewRecorder()
router.ServeHTTP(orderRec, orderReq)
if orderRec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String())
}
var reordered actionGroupResponse
_ = json.Unmarshal(orderRec.Body.Bytes(), &reordered)
if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID {
t.Fatalf("order = %#v", reordered.Group.Items)
}
cloneReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
nil,
),
cookie,
)
cloneRec := httptest.NewRecorder()
router.ServeHTTP(cloneRec, cloneReq)
if cloneRec.Code != http.StatusOK {
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
}
var cloned actionGroupResponse
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
var clonedItem settings.NodeActionItem
for _, item := range cloned.Group.Items {
if item.ID == itemID {
clonedItem = item
break
}
}
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
t.Fatalf("cloned item = %#v", clonedItem)
}
// Forbidden without actions.create for a limited user session is covered below via groups.
listReq := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil),
cookie,
)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d", listRec.Code)
}
logsReq := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil),
cookie,
)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String())
}
}
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
// Downgrade Administrators permissions to nodes.read only.
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups[0].Permissions = []string{"nodes.read"}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
body, _ := json.Marshal(map[string]any{"name": "Nope"})
req := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(body),
),
cookie,
)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
}
}
+17
View File
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
var errActionsCreateRequired = errors.New("permission actions.create required")
var errActionsUpdateRequired = errors.New("permission actions.update required")
var errActionsDeleteRequired = errors.New("permission actions.delete required")
var errJobsRunRequired = errors.New("permission jobs.run required")
var errLogsReadRequired = errors.New("permission logs.read required")
// permissionsForUser returns the union of permissions from the user's groups.
@@ -160,3 +161,19 @@ func authorizeActionsUpdate(request *http.Request, configDir string) error {
func authorizeActionsDelete(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
}
func (app *App) authorizeJobsRun(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "jobs.run") {
return errJobsRunRequired
}
return nil
}
+23 -1
View File
@@ -8,11 +8,20 @@ const defaultAllowedOrigin = "http://localhost:5173"
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
func NewRouter(configDir string) http.Handler {
handler, _ := NewRouterWithApp(configDir)
return handler
}
// NewRouterWithApp builds the router and returns the App for lifecycle control (scheduler stop).
func NewRouterWithApp(configDir string) (http.Handler, *App) {
app, err := newApp(configDir)
if err != nil {
// newApp currently always succeeds; keep signature for future.
panic(err)
}
if app.Executor != nil {
app.Executor.StartScheduler()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
@@ -55,8 +64,21 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items", app.actionGroupItemsCreateHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsPatchHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/clone", app.actionGroupItemsCloneHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsDeleteHandler)
mux.HandleFunc("PUT /api/v1/nodes/{id}/action-groups/{gid}/items/order", app.actionGroupItemsOrderHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/run", app.actionGroupsRunHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
return app.withCORS(app.withMiddleware(mux))
return app.withCORS(app.withMiddleware(mux)), app
}
+10 -4
View File
@@ -11,6 +11,7 @@ import (
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -79,6 +80,7 @@ type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
Executor *runner.Executor
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
@@ -88,17 +90,21 @@ func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
return &App{
app := &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
}, nil
Executor: runner.NewExecutor(configDir, nil),
}
return app, nil
}
return &App{
app := &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
}, nil
Executor: runner.NewExecutor(configDir, key),
}
return app, nil
}
func (app *App) requireKey() error {