Add Configuration Actions tab for shell and script action definitions.

Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
This commit is contained in:
2026-07-18 19:18:13 +02:00
parent 77be394407
commit c90a47c3ea
17 changed files with 1770 additions and 9 deletions
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponse struct {
Actions []settings.Action `json:"actions"`
}
type createActionRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Kind settings.ActionKind `json:"kind"`
Body string `json:"body"`
Env []settings.ActionEnvVar `json:"env"`
}
type patchActionRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
}
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var (
errActionNameRequired = errors.New("action name is required")
errActionBodyRequired = errors.New("action body is required")
errActionKindInvalid = errors.New("action kind must be shell or script")
errActionNotFound = errors.New("action not found")
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
errActionIDRequired = errors.New("action id is required")
)
func actionsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsCreateHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload createActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
action, err := buildCustomAction(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Actions = append(store.Actions, action)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsPatchHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
var payload patchActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
updated := store.Actions[index]
if payload.Name != nil {
updated.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
updated.Description = strings.TrimSpace(*payload.Description)
}
if payload.Body != nil {
updated.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
updated.Env = *payload.Env
}
updated.UpdatedAt = time.Now().UTC()
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if updated.Env == nil {
updated.Env = []settings.ActionEnvVar{}
}
store.Actions[index] = updated
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
name := strings.TrimSpace(payload.Name)
description := strings.TrimSpace(payload.Description)
body := strings.TrimSpace(payload.Body)
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.Action{}, err
}
actionID, err := auth.NewUUID()
if err != nil {
return settings.Action{}, err
}
now := time.Now().UTC()
return settings.Action{
ID: actionID,
Name: name,
Description: description,
Kind: payload.Kind,
Body: body,
Env: env,
Builtin: false,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func validateActionFields(
name string,
kind settings.ActionKind,
body string,
env []settings.ActionEnvVar,
) error {
if name == "" {
return errActionNameRequired
}
if body == "" {
return errActionBodyRequired
}
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
return errActionKindInvalid
}
for index := range env {
env[index].Name = strings.TrimSpace(env[index].Name)
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
}
}
return nil
}
func findActionIndex(actions []settings.Action, actionID string) int {
for index := range actions {
if actions[index].ID == actionID {
return index
}
}
return -1
}