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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user