Wire action-group variables, sudo, and command path resolution through the API and UI.

Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
2026-07-19 22:00:14 +02:00
parent 0e06063c1d
commit 7631591f30
22 changed files with 2685 additions and 274 deletions
+104 -34
View File
@@ -9,6 +9,7 @@ import (
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -50,14 +51,20 @@ type createActionGroupItemRequest struct {
Kind settings.ActionKind `json:"kind"`
Body string `json:"body"`
Env []settings.ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
SetVariable string `json:"set_variable"`
RunIf string `json:"run_if"`
}
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"`
Name *string `json:"name"`
Description *string `json:"description"`
Kind *settings.ActionKind `json:"kind"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
RequiresSudo *bool `json:"requires_sudo"`
SetVariable *string `json:"set_variable"`
RunIf *string `json:"run_if"`
}
type reorderActionGroupItemsRequest struct {
@@ -325,32 +332,55 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
return
}
item := store.Groups[groupIndex].Items[itemIndex]
if item.Source != settings.ActionItemSourceLocal {
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
return
}
if payload.Name != nil {
item.Name = strings.TrimSpace(*payload.Name)
if item.Source == settings.ActionItemSourceLocal {
if payload.Name != nil {
item.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
item.Description = strings.TrimSpace(*payload.Description)
}
if payload.Kind != nil {
item.Kind = *payload.Kind
}
if payload.Body != nil {
item.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
item.Env = *payload.Env
}
if payload.RequiresSudo != nil {
item.RequiresSudo = *payload.RequiresSudo
}
if item.Env == nil {
item.Env = []settings.ActionEnvVar{}
}
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
}
if payload.Description != nil {
item.Description = strings.TrimSpace(*payload.Description)
if payload.SetVariable != nil {
setVariable, err := normalizeSetVariable(*payload.SetVariable)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.SetVariable = setVariable
}
if payload.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
if payload.RunIf != nil {
runIf, err := normalizeRunIf(*payload.RunIf)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.RunIf = runIf
}
store.Groups[groupIndex].Items[itemIndex] = item
@@ -425,6 +455,7 @@ func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request
item.Kind = libraryAction.Kind
item.Body = libraryAction.Body
item.Env = env
item.RequiresSudo = libraryAction.RequiresSudo
store.Groups[groupIndex].Items[itemIndex] = item
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
@@ -534,7 +565,7 @@ func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request
}
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeJobsRun(request); err != nil {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
@@ -565,7 +596,7 @@ func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http
}
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeJobsRun(request); err != nil {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
@@ -700,6 +731,14 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
if err != nil {
return settings.NodeActionItem{}, err
}
setVariable, err := normalizeSetVariable(payload.SetVariable)
if err != nil {
return settings.NodeActionItem{}, err
}
runIf, err := normalizeRunIf(payload.RunIf)
if err != nil {
return settings.NodeActionItem{}, err
}
switch payload.Source {
case settings.ActionItemSourceLibrary:
libraryID := strings.TrimSpace(payload.LibraryActionID)
@@ -711,6 +750,8 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
Source: settings.ActionItemSourceLibrary,
LibraryActionID: libraryID,
Env: []settings.ActionEnvVar{},
SetVariable: setVariable,
RunIf: runIf,
}, nil
case settings.ActionItemSourceLocal:
env := payload.Env
@@ -723,19 +764,48 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
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,
ID: itemID,
Source: settings.ActionItemSourceLocal,
Name: name,
Description: strings.TrimSpace(payload.Description),
Kind: payload.Kind,
Body: body,
Env: env,
RequiresSudo: payload.RequiresSudo,
SetVariable: setVariable,
RunIf: runIf,
}, nil
default:
return settings.NodeActionItem{}, errors.New("source must be library or local")
}
}
func normalizeSetVariable(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if strings.HasPrefix(trimmed, "$") {
trimmed = strings.TrimPrefix(trimmed, "$")
trimmed = strings.TrimSpace(trimmed)
}
if err := runner.ValidateVariableName(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func normalizeRunIf(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if err := runner.ValidateCondition(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func validateSchedule(schedule settings.ActionGroupSchedule) error {
switch schedule.Kind {
case settings.ActionGroupScheduleInterval: