Files
felhom-controller/controller/cmd/controller/opactions_wiring_test.go
T
admin e06680b797 Operator actions in the report reply (D1, R-314/R-279/R-177, decision 185)
The hub may ask the running controller for a CLOSED list of actions,
carried in the report ACK (operator_actions) and answered on the next
report (operator_action_results): offsite_backup_now, abandon_stop,
abandon_extend (1-30 days), run_job (fill-watch, offsite-integrity,
offsite-proof, disk-health-check). Unknown action/job/argument -> refused,
nothing called. Once per id (in memory; every action is safe to repeat).

- internal/report/opactions.go: the executor; results re-sent until the
  hub stops listing the id.
- scheduler.RunNow: refuses unknown / already-running jobs; OnDemand(ctx)
  makes an operator's offsite-integrity run even when not due.
- ExtendAbandon never shortens the countdown and refuses in the hub phase;
  StopAbandon reports failure when the hub cancel failed (it said success).
- Report ACK read cap 4 KiB -> 64 KiB (an ACK over the cap dropped every
  field in it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00

170 lines
6.1 KiB
Go

package main
import (
"context"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
// write the running controller can overwrite (the lost update, main.go's R-241 block).
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.json")
sett, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
mgr := backup.NewManager(cfg, sett, logger)
// No test reaches a real ssh or restic (R-488 / R-650).
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
t.Fatal("abandon_stop must issue no remote command")
return nil, nil
})
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("abandon_stop must run no restic")
return nil, nil
})
due := time.Now().Add(10 * 24 * time.Hour).UTC()
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "escrowed",
AbandonRepoPath: "/srv/repo.orphaned-20261001",
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
AbandonAt: due.Format(time.RFC3339),
}); err != nil {
t.Fatal(err)
}
if !mgr.AbandonStatus().Active {
t.Fatal("fixture: a countdown must be running")
}
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
if mgr.AbandonStatus().Active {
t.Fatal("the running manager still counts down after abandon_stop")
}
onDisk, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
}
rs := o.Results()
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
t.Fatalf("result = %+v", rs)
}
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
t.Fatalf("second stop: %+v", r)
}
}
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
mgr := backup.NewManager(cfg, sett, logger)
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("no restic may run")
return nil, nil
})
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
t.Fatalf("result = %+v", r)
}
}
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
// refused.
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
logger := log.New(io.Discard, "", 0)
sched := scheduler.New(logger)
ran := make(chan bool, 1)
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
defer sched.Stop()
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
select {
case od := <-ran:
if !od {
t.Fatal("the job did not see itself as on-demand")
}
case <-time.After(5 * time.Second):
t.Fatal("fill-watch never ran")
}
deadline := time.Now().Add(5 * time.Second)
for len(o.Results()) < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
got := map[int64]string{}
for _, r := range o.Results() {
got[r.ID] = r.Outcome
}
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
}
}
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
func TestD1_MainWiresTheExecutor(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := map[string]int{}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
if x, ok := fn.X.(*ast.Ident); ok {
found[x.Name+"."+fn.Sel.Name]++
}
case *ast.Ident:
found[fn.Name]++
}
return true
})
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
if found[want] != 1 {
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
}
}
}