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
@@ -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)
}