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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type actionsResponseTest struct {
|
||||
Actions []settings.Action `json:"actions"`
|
||||
}
|
||||
|
||||
func TestActionsGetSeedsBuiltins(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var payload actionsResponseTest
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Actions) != 3 {
|
||||
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
|
||||
}
|
||||
for _, action := range payload.Actions {
|
||||
if !action.Builtin {
|
||||
t.Fatalf("expected builtin, got %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
"name": "Echo host",
|
||||
"description": "Print the target IP",
|
||||
"kind": "shell",
|
||||
"body": "echo {{node.ip}}",
|
||||
"env": [{"name": "APP_HOME", "value": "/opt/app"}]
|
||||
}`)
|
||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
var created actionsResponseTest
|
||||
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
if len(created.Actions) != 4 {
|
||||
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
|
||||
}
|
||||
custom := created.Actions[3]
|
||||
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
|
||||
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`)
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
||||
cookie,
|
||||
)
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
|
||||
}
|
||||
|
||||
var patched actionsResponseTest
|
||||
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
|
||||
t.Fatalf("decode patch: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, action := range patched.Actions {
|
||||
if action.ID == custom.ID {
|
||||
found = true
|
||||
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
||||
t.Fatalf("patched = %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("patched action missing")
|
||||
}
|
||||
|
||||
deleteReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
|
||||
cookie,
|
||||
)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("delete status %d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
|
||||
var deleted actionsResponseTest
|
||||
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
|
||||
t.Fatalf("decode delete: %v", err)
|
||||
}
|
||||
if len(deleted.Actions) != 3 {
|
||||
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
var listed actionsResponseTest
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
builtinID := listed.Actions[0].ID
|
||||
|
||||
patchBody := []byte(`{"name":"Nope"}`)
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
|
||||
cookie,
|
||||
)
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
|
||||
}
|
||||
|
||||
deleteReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
|
||||
cookie,
|
||||
)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
|
||||
}
|
||||
|
||||
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
|
||||
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
|
||||
badRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badRec, badReq)
|
||||
if badRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
|
||||
}
|
||||
|
||||
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
|
||||
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
|
||||
kindRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(kindRec, kindReq)
|
||||
if kindRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsMutationsRequirePermissions(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
// Strip action permissions from Administrators.
|
||||
settingsPayload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range settingsPayload.Groups {
|
||||
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
|
||||
for _, permission := range settingsPayload.Groups[index].Permissions {
|
||||
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
settingsPayload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
|
||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
|
||||
cookie,
|
||||
)
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected patch 403, got %d", patchRec.Code)
|
||||
}
|
||||
|
||||
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ var errUsersManageRequired = errors.New("permission users.manage required")
|
||||
var errNodesReadRequired = errors.New("permission nodes.read required")
|
||||
var errNodesExecRequired = errors.New("permission nodes.exec required")
|
||||
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")
|
||||
|
||||
// permissionsForUser returns the union of permissions from the user's groups.
|
||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
||||
@@ -49,6 +52,22 @@ func permissionList(user settings.UserCredential, groups []settings.Group) []str
|
||||
return result
|
||||
}
|
||||
|
||||
func authorizeNamedPermission(request *http.Request, configDir string, permission string, missing error) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, permission) {
|
||||
return missing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) authorizeUsersWrite(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
@@ -112,3 +131,15 @@ func (app *App) authorizeNodesDelete(request *http.Request) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizeActionsCreate(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.create", errActionsCreateRequired)
|
||||
}
|
||||
|
||||
func authorizeActionsUpdate(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.update", errActionsUpdateRequired)
|
||||
}
|
||||
|
||||
func authorizeActionsDelete(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ var allowedPermissions = map[string]struct{}{
|
||||
"nodes.delete": {},
|
||||
"jobs.read": {},
|
||||
"jobs.run": {},
|
||||
"actions.create": {},
|
||||
"actions.update": {},
|
||||
"actions.delete": {},
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
|
||||
@@ -37,6 +37,10 @@ func NewRouter(configDir string) http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
||||
mux.HandleFunc("GET /api/v1/actions", actionsGetHandler(configDir))
|
||||
mux.HandleFunc("POST /api/v1/actions", actionsCreateHandler(configDir))
|
||||
mux.HandleFunc("PATCH /api/v1/actions", actionsPatchHandler(configDir))
|
||||
mux.HandleFunc("DELETE /api/v1/actions", actionsDeleteHandler(configDir))
|
||||
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
|
||||
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
||||
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
|
||||
|
||||
@@ -453,6 +453,9 @@ func allPermissionList() []string {
|
||||
"nodes.delete",
|
||||
"jobs.read",
|
||||
"jobs.run",
|
||||
"actions.create",
|
||||
"actions.update",
|
||||
"actions.delete",
|
||||
"users.manage",
|
||||
"secrets.manage",
|
||||
"roles.manage",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stable IDs for seeded built-in actions.
|
||||
const (
|
||||
BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001"
|
||||
BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002"
|
||||
BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003"
|
||||
)
|
||||
|
||||
// DefaultBuiltinActions returns the read-only seed actions.
|
||||
func DefaultBuiltinActions() []Action {
|
||||
now := time.Now().UTC()
|
||||
return []Action{
|
||||
{
|
||||
ID: BuiltinActionUptimeID,
|
||||
Name: "Uptime",
|
||||
Description: "Show how long the target system has been running.",
|
||||
Kind: ActionKindShell,
|
||||
Body: "uptime",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Description: "Show filesystem disk space usage on the target.",
|
||||
Kind: ActionKindShell,
|
||||
Body: "df -h",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionSysInfoID,
|
||||
Name: "System info",
|
||||
Description: "Print kernel and OS release details from the target.",
|
||||
Kind: ActionKindScript,
|
||||
Body: `uname -a
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "OS: ${NAME:-unknown} ${VERSION:-}"
|
||||
fi`,
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadActions reads and parses actions.json from dir.
|
||||
func LoadActions(dir string) (ActionStore, error) {
|
||||
path := filepath.Join(dir, ActionsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionStore{}, err
|
||||
}
|
||||
|
||||
var store ActionStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return ActionStore{}, fmt.Errorf("parse actions: %w", err)
|
||||
}
|
||||
if store.Actions == nil {
|
||||
store.Actions = []Action{}
|
||||
}
|
||||
for index := range store.Actions {
|
||||
if store.Actions[index].Env == nil {
|
||||
store.Actions[index].Env = []ActionEnvVar{}
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing.
|
||||
func LoadActionsOrSeed(dir string) (ActionStore, error) {
|
||||
store, err := LoadActions(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
seeded := ActionStore{Actions: DefaultBuiltinActions()}
|
||||
if saveErr := SaveActions(dir, seeded); saveErr != nil {
|
||||
return ActionStore{}, saveErr
|
||||
}
|
||||
return seeded, nil
|
||||
}
|
||||
return ActionStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveActions writes actions.json to dir with mode 0640.
|
||||
func SaveActions(dir string, store ActionStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Actions == nil {
|
||||
store.Actions = []Action{}
|
||||
}
|
||||
for index := range store.Actions {
|
||||
if store.Actions[index].Env == nil {
|
||||
store.Actions[index].Env = []ActionEnvVar{}
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode actions: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, ActionsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write actions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
store, err := LoadActionsOrSeed(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
if len(store.Actions) != 3 {
|
||||
t.Fatalf("expected 3 builtins, got %d", len(store.Actions))
|
||||
}
|
||||
for _, action := range store.Actions {
|
||||
if !action.Builtin {
|
||||
t.Fatalf("expected builtin action, got %#v", action)
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, ActionsFileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected actions.json to exist: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := LoadActions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActions: %v", err)
|
||||
}
|
||||
if len(reloaded.Actions) != 3 {
|
||||
t.Fatalf("reloaded len = %d", len(reloaded.Actions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ActionStore{
|
||||
Actions: append(DefaultBuiltinActions(), Action{
|
||||
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
Name: "Custom",
|
||||
Description: "A custom shell action",
|
||||
Kind: ActionKindShell,
|
||||
Body: "echo hello",
|
||||
Env: []ActionEnvVar{
|
||||
{Name: "APP_HOME", Value: "/opt/app"},
|
||||
},
|
||||
Builtin: false,
|
||||
}),
|
||||
}
|
||||
if err := SaveActions(dir, store); err != nil {
|
||||
t.Fatalf("SaveActions: %v", err)
|
||||
}
|
||||
loaded, err := LoadActions(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActions: %v", err)
|
||||
}
|
||||
if len(loaded.Actions) != 4 {
|
||||
t.Fatalf("loaded len = %d", len(loaded.Actions))
|
||||
}
|
||||
custom := loaded.Actions[3]
|
||||
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
NodesFileName = "nodes.json"
|
||||
NodeKeysFileName = "node-keys.enc"
|
||||
NodeAuditFileName = "node-audit.json"
|
||||
ActionsFileName = "actions.json"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -112,6 +112,38 @@ type NodeStore struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
|
||||
// ActionKind identifies how an action body is interpreted when run on a node.
|
||||
type ActionKind string
|
||||
|
||||
const (
|
||||
ActionKindShell ActionKind = "shell"
|
||||
ActionKindScript ActionKind = "script"
|
||||
)
|
||||
|
||||
// ActionEnvVar is one environment variable applied before an action runs.
|
||||
type ActionEnvVar struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Action is a named shell command or script that can run on managed nodes.
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Kind ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []ActionEnvVar `json:"env"`
|
||||
Builtin bool `json:"builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ActionStore is the plain JSON payload in actions.json.
|
||||
type ActionStore struct {
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||
type NodeKeyEntry struct {
|
||||
NodeID string `json:"node_id"`
|
||||
|
||||
Reference in New Issue
Block a user