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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user