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
+127
View File
@@ -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
}
+68
View File
@@ -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)
}
}
+1
View File
@@ -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:
+32
View File
@@ -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"`