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.
268 lines
7.9 KiB
Go
268 lines
7.9 KiB
Go
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"`
|
|
RequiresSudo bool `json:"requires_sudo"`
|
|
}
|
|
|
|
type patchActionRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Body *string `json:"body"`
|
|
Env *[]settings.ActionEnvVar `json:"env"`
|
|
RequiresSudo *bool `json:"requires_sudo"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if payload.RequiresSudo != nil {
|
|
updated.RequiresSudo = *payload.RequiresSudo
|
|
}
|
|
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,
|
|
RequiresSudo: payload.RequiresSudo,
|
|
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
|
|
}
|