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,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