Add per-node action groups with schedules, runs, and logs.

Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
This commit is contained in:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
+174
View File
@@ -0,0 +1,174 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// MaxActionLogRuns is the retention cap per action-group log file.
const MaxActionLogRuns = 200
// ActionLogDir returns action-logs/<nodeID> under the config dir.
func ActionLogDir(configDir string, nodeID string) string {
return filepath.Join(configDir, ActionLogsDirName, nodeID)
}
// ActionLogFilePath returns the JSON log path for a node + group name.
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
filename := DiskSafeFilename(groupName) + ".json"
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
}
// AppendActionLogRun appends a run record to the group's log file and trims retention.
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
if strings.TrimSpace(record.NodeID) == "" {
return fmt.Errorf("node id is required")
}
if strings.TrimSpace(record.GroupName) == "" {
return fmt.Errorf("group name is required")
}
dir := ActionLogDir(configDir, record.NodeID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create action log dir: %w", err)
}
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
if err != nil {
return err
}
file.NodeID = record.NodeID
file.GroupID = record.GroupID
file.GroupName = record.GroupName
file.Runs = append(file.Runs, record)
if len(file.Runs) > MaxActionLogRuns {
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
}
return SaveActionLogFile(path, file)
}
// LoadActionLogFile reads a log JSON file from path.
func LoadActionLogFile(path string) (ActionLogFile, error) {
payload, err := os.ReadFile(path)
if err != nil {
return ActionLogFile{}, err
}
var file ActionLogFile
if err := json.Unmarshal(payload, &file); err != nil {
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
}
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
return file, nil
}
// LoadActionLogFileOrEmpty returns an empty file when missing.
func LoadActionLogFileOrEmpty(
path string,
nodeID string,
groupID string,
groupName string,
) (ActionLogFile, error) {
file, err := LoadActionLogFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionLogFile{
NodeID: nodeID,
GroupID: groupID,
GroupName: groupName,
Runs: []ActionGroupRunRecord{},
}, nil
}
return ActionLogFile{}, err
}
return file, nil
}
// SaveActionLogFile writes a log JSON file with mode 0640.
func SaveActionLogFile(path string, file ActionLogFile) error {
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode action log: %w", err)
}
payload = append(payload, '\n')
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write action log: %w", err)
}
return nil
}
// ListActionLogFiles lists JSON log files for a node.
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
dir := ActionLogDir(configDir, nodeID)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []ActionLogFileInfo{}, nil
}
return nil, fmt.Errorf("list action logs: %w", err)
}
result := make([]ActionLogFileInfo, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
groupName := strings.TrimSuffix(name, ".json")
groupID := ""
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
if loadErr == nil {
if file.GroupName != "" {
groupName = file.GroupName
}
groupID = file.GroupID
}
result = append(result, ActionLogFileInfo{
Filename: name,
GroupName: groupName,
GroupID: groupID,
ModTime: info.ModTime().UTC(),
SizeBytes: info.Size(),
})
}
return result, nil
}
// ReadActionLogFileByName reads one log file for a node after validating the name.
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
safeName := filepath.Base(strings.TrimSpace(filename))
if safeName == "." || safeName == ".." || safeName == "" {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if !strings.HasSuffix(safeName, ".json") {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
cleaned := filepath.Clean(path)
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
return LoadActionLogFile(cleaned)
}
@@ -0,0 +1,227 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// LoadNodeActionGroups reads and parses node-action-groups.json from dir.
func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) {
path := filepath.Join(dir, NodeActionGroupsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeActionGroupStore{}, err
}
var store NodeActionGroupStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err)
}
normalizeNodeActionGroupStore(&store)
return store, nil
}
// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing.
func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) {
store, err := LoadNodeActionGroups(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil
}
return NodeActionGroupStore{}, err
}
return store, nil
}
// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640.
func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
normalizeNodeActionGroupStore(&store)
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode node action groups: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodeActionGroupsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write node action groups: %w", err)
}
return nil
}
func normalizeNodeActionGroupStore(store *NodeActionGroupStore) {
if store.Groups == nil {
store.Groups = []NodeActionGroup{}
}
for index := range store.Groups {
if store.Groups[index].Items == nil {
store.Groups[index].Items = []NodeActionItem{}
}
if store.Groups[index].Schedule.Times == nil {
store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{}
}
for itemIndex := range store.Groups[index].Items {
if store.Groups[index].Items[itemIndex].Env == nil {
store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{}
}
}
}
}
// DiskSafeFilename converts a group name into a safe filename stem.
func DiskSafeFilename(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "unnamed"
}
safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_")
safe = strings.Trim(safe, "._-")
if safe == "" {
return "unnamed"
}
if len(safe) > 120 {
safe = safe[:120]
}
return safe
}
// FindNodeActionGroupIndex returns the index of a group by id, or -1.
func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int {
for index := range groups {
if groups[index].ID == groupID {
return index
}
}
return -1
}
// FindNodeActionItemIndex returns the index of an item by id, or -1.
func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int {
for index := range items {
if items[index].ID == itemID {
return index
}
}
return -1
}
// GroupsForNode returns groups belonging to nodeID.
func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup {
result := make([]NodeActionGroup, 0)
for _, group := range store.Groups {
if group.NodeID == nodeID {
result = append(result, group)
}
}
return result
}
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
store, err := LoadNodeActionGroupsOrEmpty(dir)
if err != nil {
return err
}
index := FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 {
return fmt.Errorf("action group not found")
}
runAt := at.UTC()
store.Groups[index].LastRunAt = &runAt
store.Groups[index].UpdatedAt = runAt
return SaveNodeActionGroups(dir, store)
}
// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt.
func IsScheduleDue(group NodeActionGroup, now time.Time) bool {
schedule := group.Schedule
if !schedule.Enabled {
return false
}
localNow := now.In(time.Local)
switch schedule.Kind {
case ActionGroupScheduleInterval:
if schedule.EverySeconds < 1 {
return false
}
if group.LastRunAt == nil {
return true
}
elapsed := localNow.Sub(group.LastRunAt.In(time.Local))
return elapsed >= time.Duration(schedule.EverySeconds)*time.Second
case ActionGroupScheduleTimes:
if len(schedule.Times) == 0 {
return false
}
for _, spec := range schedule.Times {
if !timeSpecMatches(spec, localNow) {
continue
}
if group.LastRunAt == nil {
return true
}
lastLocal := group.LastRunAt.In(time.Local)
// Fire once per matching minute window.
sameMinute := lastLocal.Year() == localNow.Year() &&
lastLocal.YearDay() == localNow.YearDay() &&
lastLocal.Hour() == localNow.Hour() &&
lastLocal.Minute() == localNow.Minute()
if !sameMinute {
return true
}
}
return false
default:
return false
}
}
func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool {
hour, minute, ok := parseHHMM(spec.Time)
if !ok {
return false
}
if now.Hour() != hour || now.Minute() != minute {
return false
}
if len(spec.DaysOfWeek) == 0 {
return true
}
weekday := int(now.Weekday())
for _, day := range spec.DaysOfWeek {
if day == weekday {
return true
}
}
return false
}
func parseHHMM(value string) (hour int, minute int, ok bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
return 0, 0, false
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
@@ -0,0 +1,160 @@
package settings
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDiskSafeFilename(t *testing.T) {
cases := map[string]string{
"Daily Backup": "Daily_Backup",
" hello!! ": "hello",
"../etc/passwd": "etc_passwd",
"": "unnamed",
"a/b\\c:d*e?f": "a_b_c_d_e_f",
}
for input, want := range cases {
got := DiskSafeFilename(input)
if got != want {
t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want)
}
}
}
func TestNodeActionGroupsRoundTrip(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC()
store := NodeActionGroupStore{
Groups: []NodeActionGroup{
{
ID: "g1",
NodeID: "n1",
Name: "Nightly",
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 60,
Times: []ActionGroupTimeSpec{},
},
Items: []NodeActionItem{
{
ID: "i1",
Source: ActionItemSourceLibrary,
LibraryActionID: BuiltinActionUptimeID,
Env: []ActionEnvVar{},
},
},
CreatedAt: now,
UpdatedAt: now,
},
},
}
if err := SaveNodeActionGroups(dir, store); err != nil {
t.Fatalf("SaveNodeActionGroups: %v", err)
}
loaded, err := LoadNodeActionGroups(dir)
if err != nil {
t.Fatalf("LoadNodeActionGroups: %v", err)
}
if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" {
t.Fatalf("loaded = %#v", loaded)
}
if len(loaded.Groups[0].Items) != 1 {
t.Fatalf("items = %#v", loaded.Groups[0].Items)
}
}
func TestIsScheduleDueInterval(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 300,
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due when never run")
}
last := now.Add(-301 * time.Second)
group.LastRunAt = &last
if !IsScheduleDue(group, now) {
t.Fatal("expected due after interval elapsed")
}
recent := now.Add(-10 * time.Second)
group.LastRunAt = &recent
if IsScheduleDue(group, now) {
t.Fatal("expected not due within interval")
}
}
func TestIsScheduleDueTimes(t *testing.T) {
now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleTimes,
Times: []ActionGroupTimeSpec{
{Time: "14:30", DaysOfWeek: []int{0}},
},
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due at matching time")
}
sameMinute := now
group.LastRunAt = &sameMinute
if IsScheduleDue(group, now) {
t.Fatal("expected not due again in same minute")
}
group.LastRunAt = nil
group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only
if IsScheduleDue(group, now) {
t.Fatal("expected not due on wrong weekday")
}
}
func TestActionLogAppendAndList(t *testing.T) {
dir := t.TempDir()
record := ActionGroupRunRecord{
ID: "run-1",
NodeID: "node-abc",
GroupID: "group-1",
GroupName: "Nightly Jobs",
Trigger: ActionRunTriggerManual,
StartedAt: time.Now().UTC(),
FinishedAt: time.Now().UTC(),
Actions: []ActionItemRunResult{},
}
if err := AppendActionLogRun(dir, record); err != nil {
t.Fatalf("AppendActionLogRun: %v", err)
}
path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected log file at %s: %v", path, err)
}
files, err := ListActionLogFiles(dir, "node-abc")
if err != nil {
t.Fatalf("ListActionLogFiles: %v", err)
}
if len(files) != 1 {
t.Fatalf("files = %#v", files)
}
wantName := DiskSafeFilename("Nightly Jobs") + ".json"
if files[0].Filename != wantName {
t.Fatalf("filename = %q want %q", files[0].Filename, wantName)
}
loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName)
if err != nil {
t.Fatalf("ReadActionLogFileByName: %v", err)
}
if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" {
t.Fatalf("loaded = %#v", loaded)
}
if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil {
t.Fatal("expected path traversal rejected")
}
_ = filepath.Base(wantName)
}
+3 -1
View File
@@ -20,7 +20,9 @@ const (
NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
AuthAuditFileName = "auth-audit.json"
ActionsFileName = "actions.json"
ActionsFileName = "actions.json"
NodeActionGroupsFileName = "node-action-groups.json"
ActionLogsDirName = "action-logs"
)
// ResolveDir returns the config directory using precedence:
+110
View File
@@ -144,6 +144,116 @@ type ActionStore struct {
Actions []Action `json:"actions"`
}
// ActionItemSource identifies whether a node action item is a library ref or local copy.
type ActionItemSource string
const (
ActionItemSourceLibrary ActionItemSource = "library"
ActionItemSourceLocal ActionItemSource = "local"
)
// ActionGroupScheduleKind identifies how an action group is scheduled.
type ActionGroupScheduleKind string
const (
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
)
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
type ActionGroupTimeSpec struct {
Time string `json:"time"` // "HH:MM" in server local time
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
}
// ActionGroupSchedule configures when an action group runs automatically.
type ActionGroupSchedule struct {
Enabled bool `json:"enabled"`
Kind ActionGroupScheduleKind `json:"kind"`
EverySeconds int `json:"every_seconds,omitempty"`
Times []ActionGroupTimeSpec `json:"times,omitempty"`
}
// NodeActionItem is one ordered action inside a node action group.
type NodeActionItem struct {
ID string `json:"id"`
Source ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,omitempty"`
Env []ActionEnvVar `json:"env,omitempty"`
}
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
type NodeActionGroup struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Schedule ActionGroupSchedule `json:"schedule"`
Items []NodeActionItem `json:"items"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
type NodeActionGroupStore struct {
Groups []NodeActionGroup `json:"groups"`
}
// ActionRunTrigger identifies what started an action run.
type ActionRunTrigger string
const (
ActionRunTriggerManual ActionRunTrigger = "manual"
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
)
// ActionItemRunResult is the captured output of one action within a run.
type ActionItemRunResult struct {
ItemID string `json:"item_id"`
ActionName string `json:"action_name"`
Source string `json:"source"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
type ActionGroupRunRecord struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Trigger ActionRunTrigger `json:"trigger"`
Actor string `json:"actor,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
Actions []ActionItemRunResult `json:"actions"`
}
// ActionLogFile is the JSON payload for one node/group log file on disk.
type ActionLogFile struct {
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Runs []ActionGroupRunRecord `json:"runs"`
}
// ActionLogFileInfo is metadata for listing log files without reading full content.
type ActionLogFileInfo struct {
Filename string `json:"filename"`
GroupName string `json:"group_name"`
GroupID string `json:"group_id,omitempty"`
ModTime time.Time `json:"mod_time"`
SizeBytes int64 `json:"size_bytes"`
}
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
type NodeKeyEntry struct {
NodeID string `json:"node_id"`