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
+6 -1
View File
@@ -36,9 +36,10 @@ func main() {
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
handler, app := api.NewRouterWithApp(configDir)
server := &http.Server{
Addr: listenAddr,
Handler: api.NewRouter(configDir),
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
@@ -53,6 +54,10 @@ func main() {
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
if app.Executor != nil {
app.Executor.StopScheduler()
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -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 {
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"regexp"
"strings"
)
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
// PlaceholderContext holds values used to expand action body placeholders.
type PlaceholderContext struct {
Host string
IP string
Username string
Env map[string]string
Secrets map[string]string
}
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
// Unknown or unresolved secrets expand to empty strings.
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
submatches := placeholderPattern.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
key := strings.TrimSpace(submatches[1])
switch key {
case "cc.host", "node.host":
return ctx.Host
case "cc.ip", "node.ip":
return ctx.IP
case "node.username":
return ctx.Username
default:
if strings.HasPrefix(key, "env.") {
name := strings.TrimPrefix(key, "env.")
if ctx.Env != nil {
return ctx.Env[name]
}
return ""
}
if strings.HasPrefix(key, "secret.") {
name := strings.TrimPrefix(key, "secret.")
if ctx.Secrets != nil {
return ctx.Secrets[name]
}
return ""
}
return ""
}
})
}
@@ -0,0 +1,18 @@
package auth
import "testing"
func TestExpandPlaceholders(t *testing.T) {
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
got := ExpandPlaceholders(body, PlaceholderContext{
Host: "web-1",
IP: "10.0.0.5",
Username: "deploy",
Env: map[string]string{"FOO": "bar"},
Secrets: map[string]string{},
})
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+228
View File
@@ -0,0 +1,228 @@
package auth
import (
"bytes"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHRunTimeout = 5 * time.Minute
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
type SSHRunResult struct {
Stdout string
Stderr string
ExitCode int
}
// RunSSHShell runs a single shell command on the remote host over SSH.
func RunSSHShell(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(command) == "" {
return SSHRunResult{}, fmt.Errorf("command is required")
}
fullCommand := prependEnvExports(env, command)
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
}
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
func RunSSHScript(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
scriptBody string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(scriptBody) == "" {
return SSHRunResult{}, fmt.Errorf("script body is required")
}
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
session.Stdin = strings.NewReader(scriptBody)
remoteCommand := prependEnvExports(env, "bash -s")
done := make(chan error, 1)
go func() {
done <- session.Run(remoteCommand)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func runSSHSession(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
timeout time.Duration,
) (SSHRunResult, error) {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
result := SSHRunResult{
Stdout: stdout,
Stderr: stderr,
ExitCode: 0,
}
if err == nil {
return result, nil
}
if exitErr, ok := err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
return result, nil
}
result.ExitCode = -1
return result, err
}
func prependEnvExports(env map[string]string, command string) string {
if len(env) == 0 {
return command
}
var builder strings.Builder
for name, value := range env {
builder.WriteString("export ")
builder.WriteString(shellQuote(name))
builder.WriteString("=")
builder.WriteString(shellQuote(value))
builder.WriteString("; ")
}
builder.WriteString(command)
return builder.String()
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
+355
View File
@@ -0,0 +1,355 @@
package runner
import (
"fmt"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const defaultRunTimeout = 5 * time.Minute
// Executor runs action groups and items against nodes over SSH.
type Executor struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{} // groupID -> running
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
}
// NewExecutor creates an Executor for the given config directory and encryption key.
func NewExecutor(configDir string, key []byte) *Executor {
return &Executor{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background schedule ticker (30s).
func (executor *Executor) StartScheduler() {
executor.mu.Lock()
if executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = true
executor.mu.Unlock()
go func() {
defer close(executor.stopped)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-executor.stopCh:
return
case <-ticker.C:
executor.tickSchedules()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (executor *Executor) StopScheduler() {
executor.mu.Lock()
if !executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = false
executor.mu.Unlock()
close(executor.stopCh)
<-executor.stopped
}
func (executor *Executor) tryLockGroup(groupID string) bool {
executor.mu.Lock()
defer executor.mu.Unlock()
if _, ok := executor.running[groupID]; ok {
return false
}
executor.running[groupID] = struct{}{}
return true
}
func (executor *Executor) unlockGroup(groupID string) {
executor.mu.Lock()
defer executor.mu.Unlock()
delete(executor.running, groupID)
}
func (executor *Executor) tickSchedules() {
store, err := settings.LoadNodeActionGroupsOrEmpty(executor.ConfigDir)
if err != nil {
return
}
now := time.Now()
for _, group := range store.Groups {
if !settings.IsScheduleDue(group, now) {
continue
}
groupCopy := group
go func() {
_, _ = executor.RunGroup(groupCopy, settings.ActionRunTriggerSchedule, "scheduler")
}()
}
}
// ResolvedAction is the effective action definition after library resolution.
type ResolvedAction struct {
Name string
Description string
Kind settings.ActionKind
Body string
Env []settings.ActionEnvVar
Source settings.ActionItemSource
}
// ResolveItem resolves a library or local item to an executable action.
func ResolveItem(item settings.NodeActionItem, library []settings.Action) (ResolvedAction, error) {
switch item.Source {
case settings.ActionItemSourceLibrary:
if strings.TrimSpace(item.LibraryActionID) == "" {
return ResolvedAction{}, fmt.Errorf("library action id is required")
}
for _, action := range library {
if action.ID == item.LibraryActionID {
env := action.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: action.Name,
Description: action.Description,
Kind: action.Kind,
Body: action.Body,
Env: env,
Source: settings.ActionItemSourceLibrary,
}, nil
}
}
return ResolvedAction{}, fmt.Errorf("library action %q not found", item.LibraryActionID)
case settings.ActionItemSourceLocal:
if strings.TrimSpace(item.Name) == "" {
return ResolvedAction{}, fmt.Errorf("local action name is required")
}
if strings.TrimSpace(item.Body) == "" {
return ResolvedAction{}, fmt.Errorf("local action body is required")
}
if item.Kind != settings.ActionKindShell && item.Kind != settings.ActionKindScript {
return ResolvedAction{}, fmt.Errorf("local action kind must be shell or script")
}
env := item.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: item.Name,
Description: item.Description,
Kind: item.Kind,
Body: item.Body,
Env: env,
Source: settings.ActionItemSourceLocal,
}, nil
default:
return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source)
}
}
// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt.
func (executor *Executor) RunGroup(
group settings.NodeActionGroup,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
if !executor.tryLockGroup(group.ID) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action group is already running")
}
defer executor.unlockGroup(group.ID)
return executor.runItems(group, group.Items, trigger, actor)
}
// RunItem executes a single item (still logged under the parent group file).
func (executor *Executor) RunItem(
group settings.NodeActionGroup,
item settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
lockKey := group.ID + ":" + item.ID
if !executor.tryLockGroup(lockKey) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action is already running")
}
defer executor.unlockGroup(lockKey)
return executor.runItems(group, []settings.NodeActionItem{item}, trigger, actor)
}
func (executor *Executor) runItems(
group settings.NodeActionGroup,
items []settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
runID, err := auth.NewUUID()
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
node, privateKey, passphrase, err := executor.loadNodeCredentials(group.NodeID)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
libraryStore, err := settings.LoadActionsOrSeed(executor.ConfigDir)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
startedAt := time.Now().UTC()
results := make([]settings.ActionItemRunResult, 0, len(items))
for _, item := range items {
itemStarted := time.Now().UTC()
resolved, resolveErr := ResolveItem(item, libraryStore.Actions)
if resolveErr != nil {
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Source: string(item.Source),
ExitCode: -1,
Stdout: "",
Stderr: "",
Error: resolveErr.Error(),
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
envMap := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
envMap[envVar.Name] = envVar.Value
}
expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: envMap,
Secrets: map[string]string{},
})
var sshResult auth.SSHRunResult
var runErr error
switch resolved.Kind {
case settings.ActionKindShell:
sshResult, runErr = auth.RunSSHShell(
node.HostIP,
node.Username,
privateKey,
passphrase,
expandedBody,
envMap,
defaultRunTimeout,
)
case settings.ActionKindScript:
sshResult, runErr = auth.RunSSHScript(
node.HostIP,
node.Username,
privateKey,
passphrase,
expandedBody,
envMap,
defaultRunTimeout,
)
default:
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
}
itemResult := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
ExitCode: sshResult.ExitCode,
Stdout: sshResult.Stdout,
Stderr: sshResult.Stderr,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
}
if runErr != nil {
itemResult.Error = runErr.Error()
if itemResult.ExitCode == 0 {
itemResult.ExitCode = -1
}
}
results = append(results, itemResult)
}
finishedAt := time.Now().UTC()
record := settings.ActionGroupRunRecord{
ID: runID,
NodeID: group.NodeID,
GroupID: group.ID,
GroupName: group.Name,
Trigger: trigger,
Actor: actor,
StartedAt: startedAt,
FinishedAt: finishedAt,
Actions: results,
}
if err := settings.AppendActionLogRun(executor.ConfigDir, record); err != nil {
return record, fmt.Errorf("append action log: %w", err)
}
if err := settings.UpdateGroupLastRunAt(executor.ConfigDir, group.ID, finishedAt); err != nil {
return record, fmt.Errorf("update last run: %w", err)
}
return record, nil
}
func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, string, string, error) {
if len(executor.Key) == 0 {
return settings.Node{}, "", "", fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
nodeStore, err := settings.LoadNodesOrEmpty(executor.ConfigDir)
if err != nil {
return settings.Node{}, "", "", err
}
var node settings.Node
found := false
for _, candidate := range nodeStore.Nodes {
if candidate.ID == nodeID {
node = candidate
found = true
break
}
}
if !found {
return settings.Node{}, "", "", fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(executor.ConfigDir, executor.Key)
if err != nil {
return settings.Node{}, "", "", err
}
for _, entry := range keyStore.Keys {
if entry.NodeID == nodeID {
return node, entry.PrivateKey, entry.Passphrase, nil
}
}
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
}
+53
View File
@@ -0,0 +1,53 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestResolveItemLibraryAndLocal(t *testing.T) {
library := []settings.Action{
{
ID: settings.BuiltinActionUptimeID,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: settings.BuiltinActionUptimeID,
}, library)
if err != nil {
t.Fatalf("ResolveItem library: %v", err)
}
if resolved.Name != "Uptime" || resolved.Body != "uptime" {
t.Fatalf("resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Echo",
Kind: settings.ActionKindShell,
Body: "echo hi",
Env: []settings.ActionEnvVar{},
}, library)
if err != nil {
t.Fatalf("ResolveItem local: %v", err)
}
if local.Name != "Echo" {
t.Fatalf("local = %#v", local)
}
if _, err := ResolveItem(settings.NodeActionItem{
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "missing",
}, library); err == nil {
t.Fatal("expected missing library action error")
}
}
+174
View File
@@ -0,0 +1,174 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// MaxActionLogRuns is the retention cap per action-group log file.
const MaxActionLogRuns = 200
// ActionLogDir returns action-logs/<nodeID> under the config dir.
func ActionLogDir(configDir string, nodeID string) string {
return filepath.Join(configDir, ActionLogsDirName, nodeID)
}
// ActionLogFilePath returns the JSON log path for a node + group name.
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
filename := DiskSafeFilename(groupName) + ".json"
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
}
// AppendActionLogRun appends a run record to the group's log file and trims retention.
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
if strings.TrimSpace(record.NodeID) == "" {
return fmt.Errorf("node id is required")
}
if strings.TrimSpace(record.GroupName) == "" {
return fmt.Errorf("group name is required")
}
dir := ActionLogDir(configDir, record.NodeID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create action log dir: %w", err)
}
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
if err != nil {
return err
}
file.NodeID = record.NodeID
file.GroupID = record.GroupID
file.GroupName = record.GroupName
file.Runs = append(file.Runs, record)
if len(file.Runs) > MaxActionLogRuns {
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
}
return SaveActionLogFile(path, file)
}
// LoadActionLogFile reads a log JSON file from path.
func LoadActionLogFile(path string) (ActionLogFile, error) {
payload, err := os.ReadFile(path)
if err != nil {
return ActionLogFile{}, err
}
var file ActionLogFile
if err := json.Unmarshal(payload, &file); err != nil {
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
}
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
return file, nil
}
// LoadActionLogFileOrEmpty returns an empty file when missing.
func LoadActionLogFileOrEmpty(
path string,
nodeID string,
groupID string,
groupName string,
) (ActionLogFile, error) {
file, err := LoadActionLogFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionLogFile{
NodeID: nodeID,
GroupID: groupID,
GroupName: groupName,
Runs: []ActionGroupRunRecord{},
}, nil
}
return ActionLogFile{}, err
}
return file, nil
}
// SaveActionLogFile writes a log JSON file with mode 0640.
func SaveActionLogFile(path string, file ActionLogFile) error {
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode action log: %w", err)
}
payload = append(payload, '\n')
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write action log: %w", err)
}
return nil
}
// ListActionLogFiles lists JSON log files for a node.
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
dir := ActionLogDir(configDir, nodeID)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []ActionLogFileInfo{}, nil
}
return nil, fmt.Errorf("list action logs: %w", err)
}
result := make([]ActionLogFileInfo, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
groupName := strings.TrimSuffix(name, ".json")
groupID := ""
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
if loadErr == nil {
if file.GroupName != "" {
groupName = file.GroupName
}
groupID = file.GroupID
}
result = append(result, ActionLogFileInfo{
Filename: name,
GroupName: groupName,
GroupID: groupID,
ModTime: info.ModTime().UTC(),
SizeBytes: info.Size(),
})
}
return result, nil
}
// ReadActionLogFileByName reads one log file for a node after validating the name.
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
safeName := filepath.Base(strings.TrimSpace(filename))
if safeName == "." || safeName == ".." || safeName == "" {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if !strings.HasSuffix(safeName, ".json") {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
cleaned := filepath.Clean(path)
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
return LoadActionLogFile(cleaned)
}
@@ -0,0 +1,227 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// LoadNodeActionGroups reads and parses node-action-groups.json from dir.
func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) {
path := filepath.Join(dir, NodeActionGroupsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeActionGroupStore{}, err
}
var store NodeActionGroupStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err)
}
normalizeNodeActionGroupStore(&store)
return store, nil
}
// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing.
func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) {
store, err := LoadNodeActionGroups(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil
}
return NodeActionGroupStore{}, err
}
return store, nil
}
// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640.
func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
normalizeNodeActionGroupStore(&store)
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode node action groups: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodeActionGroupsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write node action groups: %w", err)
}
return nil
}
func normalizeNodeActionGroupStore(store *NodeActionGroupStore) {
if store.Groups == nil {
store.Groups = []NodeActionGroup{}
}
for index := range store.Groups {
if store.Groups[index].Items == nil {
store.Groups[index].Items = []NodeActionItem{}
}
if store.Groups[index].Schedule.Times == nil {
store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{}
}
for itemIndex := range store.Groups[index].Items {
if store.Groups[index].Items[itemIndex].Env == nil {
store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{}
}
}
}
}
// DiskSafeFilename converts a group name into a safe filename stem.
func DiskSafeFilename(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "unnamed"
}
safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_")
safe = strings.Trim(safe, "._-")
if safe == "" {
return "unnamed"
}
if len(safe) > 120 {
safe = safe[:120]
}
return safe
}
// FindNodeActionGroupIndex returns the index of a group by id, or -1.
func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int {
for index := range groups {
if groups[index].ID == groupID {
return index
}
}
return -1
}
// FindNodeActionItemIndex returns the index of an item by id, or -1.
func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int {
for index := range items {
if items[index].ID == itemID {
return index
}
}
return -1
}
// GroupsForNode returns groups belonging to nodeID.
func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup {
result := make([]NodeActionGroup, 0)
for _, group := range store.Groups {
if group.NodeID == nodeID {
result = append(result, group)
}
}
return result
}
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
store, err := LoadNodeActionGroupsOrEmpty(dir)
if err != nil {
return err
}
index := FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 {
return fmt.Errorf("action group not found")
}
runAt := at.UTC()
store.Groups[index].LastRunAt = &runAt
store.Groups[index].UpdatedAt = runAt
return SaveNodeActionGroups(dir, store)
}
// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt.
func IsScheduleDue(group NodeActionGroup, now time.Time) bool {
schedule := group.Schedule
if !schedule.Enabled {
return false
}
localNow := now.In(time.Local)
switch schedule.Kind {
case ActionGroupScheduleInterval:
if schedule.EverySeconds < 1 {
return false
}
if group.LastRunAt == nil {
return true
}
elapsed := localNow.Sub(group.LastRunAt.In(time.Local))
return elapsed >= time.Duration(schedule.EverySeconds)*time.Second
case ActionGroupScheduleTimes:
if len(schedule.Times) == 0 {
return false
}
for _, spec := range schedule.Times {
if !timeSpecMatches(spec, localNow) {
continue
}
if group.LastRunAt == nil {
return true
}
lastLocal := group.LastRunAt.In(time.Local)
// Fire once per matching minute window.
sameMinute := lastLocal.Year() == localNow.Year() &&
lastLocal.YearDay() == localNow.YearDay() &&
lastLocal.Hour() == localNow.Hour() &&
lastLocal.Minute() == localNow.Minute()
if !sameMinute {
return true
}
}
return false
default:
return false
}
}
func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool {
hour, minute, ok := parseHHMM(spec.Time)
if !ok {
return false
}
if now.Hour() != hour || now.Minute() != minute {
return false
}
if len(spec.DaysOfWeek) == 0 {
return true
}
weekday := int(now.Weekday())
for _, day := range spec.DaysOfWeek {
if day == weekday {
return true
}
}
return false
}
func parseHHMM(value string) (hour int, minute int, ok bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
return 0, 0, false
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
@@ -0,0 +1,160 @@
package settings
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDiskSafeFilename(t *testing.T) {
cases := map[string]string{
"Daily Backup": "Daily_Backup",
" hello!! ": "hello",
"../etc/passwd": "etc_passwd",
"": "unnamed",
"a/b\\c:d*e?f": "a_b_c_d_e_f",
}
for input, want := range cases {
got := DiskSafeFilename(input)
if got != want {
t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want)
}
}
}
func TestNodeActionGroupsRoundTrip(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC()
store := NodeActionGroupStore{
Groups: []NodeActionGroup{
{
ID: "g1",
NodeID: "n1",
Name: "Nightly",
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 60,
Times: []ActionGroupTimeSpec{},
},
Items: []NodeActionItem{
{
ID: "i1",
Source: ActionItemSourceLibrary,
LibraryActionID: BuiltinActionUptimeID,
Env: []ActionEnvVar{},
},
},
CreatedAt: now,
UpdatedAt: now,
},
},
}
if err := SaveNodeActionGroups(dir, store); err != nil {
t.Fatalf("SaveNodeActionGroups: %v", err)
}
loaded, err := LoadNodeActionGroups(dir)
if err != nil {
t.Fatalf("LoadNodeActionGroups: %v", err)
}
if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" {
t.Fatalf("loaded = %#v", loaded)
}
if len(loaded.Groups[0].Items) != 1 {
t.Fatalf("items = %#v", loaded.Groups[0].Items)
}
}
func TestIsScheduleDueInterval(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 300,
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due when never run")
}
last := now.Add(-301 * time.Second)
group.LastRunAt = &last
if !IsScheduleDue(group, now) {
t.Fatal("expected due after interval elapsed")
}
recent := now.Add(-10 * time.Second)
group.LastRunAt = &recent
if IsScheduleDue(group, now) {
t.Fatal("expected not due within interval")
}
}
func TestIsScheduleDueTimes(t *testing.T) {
now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleTimes,
Times: []ActionGroupTimeSpec{
{Time: "14:30", DaysOfWeek: []int{0}},
},
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due at matching time")
}
sameMinute := now
group.LastRunAt = &sameMinute
if IsScheduleDue(group, now) {
t.Fatal("expected not due again in same minute")
}
group.LastRunAt = nil
group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only
if IsScheduleDue(group, now) {
t.Fatal("expected not due on wrong weekday")
}
}
func TestActionLogAppendAndList(t *testing.T) {
dir := t.TempDir()
record := ActionGroupRunRecord{
ID: "run-1",
NodeID: "node-abc",
GroupID: "group-1",
GroupName: "Nightly Jobs",
Trigger: ActionRunTriggerManual,
StartedAt: time.Now().UTC(),
FinishedAt: time.Now().UTC(),
Actions: []ActionItemRunResult{},
}
if err := AppendActionLogRun(dir, record); err != nil {
t.Fatalf("AppendActionLogRun: %v", err)
}
path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected log file at %s: %v", path, err)
}
files, err := ListActionLogFiles(dir, "node-abc")
if err != nil {
t.Fatalf("ListActionLogFiles: %v", err)
}
if len(files) != 1 {
t.Fatalf("files = %#v", files)
}
wantName := DiskSafeFilename("Nightly Jobs") + ".json"
if files[0].Filename != wantName {
t.Fatalf("filename = %q want %q", files[0].Filename, wantName)
}
loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName)
if err != nil {
t.Fatalf("ReadActionLogFileByName: %v", err)
}
if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" {
t.Fatalf("loaded = %#v", loaded)
}
if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil {
t.Fatal("expected path traversal rejected")
}
_ = filepath.Base(wantName)
}
+3 -1
View File
@@ -20,7 +20,9 @@ const (
NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
AuthAuditFileName = "auth-audit.json"
ActionsFileName = "actions.json"
ActionsFileName = "actions.json"
NodeActionGroupsFileName = "node-action-groups.json"
ActionLogsDirName = "action-logs"
)
// ResolveDir returns the config directory using precedence:
+110
View File
@@ -144,6 +144,116 @@ type ActionStore struct {
Actions []Action `json:"actions"`
}
// ActionItemSource identifies whether a node action item is a library ref or local copy.
type ActionItemSource string
const (
ActionItemSourceLibrary ActionItemSource = "library"
ActionItemSourceLocal ActionItemSource = "local"
)
// ActionGroupScheduleKind identifies how an action group is scheduled.
type ActionGroupScheduleKind string
const (
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
)
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
type ActionGroupTimeSpec struct {
Time string `json:"time"` // "HH:MM" in server local time
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
}
// ActionGroupSchedule configures when an action group runs automatically.
type ActionGroupSchedule struct {
Enabled bool `json:"enabled"`
Kind ActionGroupScheduleKind `json:"kind"`
EverySeconds int `json:"every_seconds,omitempty"`
Times []ActionGroupTimeSpec `json:"times,omitempty"`
}
// NodeActionItem is one ordered action inside a node action group.
type NodeActionItem struct {
ID string `json:"id"`
Source ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,omitempty"`
Env []ActionEnvVar `json:"env,omitempty"`
}
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
type NodeActionGroup struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Schedule ActionGroupSchedule `json:"schedule"`
Items []NodeActionItem `json:"items"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
type NodeActionGroupStore struct {
Groups []NodeActionGroup `json:"groups"`
}
// ActionRunTrigger identifies what started an action run.
type ActionRunTrigger string
const (
ActionRunTriggerManual ActionRunTrigger = "manual"
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
)
// ActionItemRunResult is the captured output of one action within a run.
type ActionItemRunResult struct {
ItemID string `json:"item_id"`
ActionName string `json:"action_name"`
Source string `json:"source"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
type ActionGroupRunRecord struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Trigger ActionRunTrigger `json:"trigger"`
Actor string `json:"actor,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
Actions []ActionItemRunResult `json:"actions"`
}
// ActionLogFile is the JSON payload for one node/group log file on disk.
type ActionLogFile struct {
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Runs []ActionGroupRunRecord `json:"runs"`
}
// ActionLogFileInfo is metadata for listing log files without reading full content.
type ActionLogFileInfo struct {
Filename string `json:"filename"`
GroupName string `json:"group_name"`
GroupID string `json:"group_id,omitempty"`
ModTime time.Time `json:"mod_time"`
SizeBytes int64 `json:"size_bytes"`
}
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
type NodeKeyEntry struct {
NodeID string `json:"node_id"`