diff --git a/modules/restic/cmd/restic-backups/backups.go b/modules/restic/cmd/restic-backups/backups.go index 876c73ac..03a4e598 100644 --- a/modules/restic/cmd/restic-backups/backups.go +++ b/modules/restic/cmd/restic-backups/backups.go @@ -184,6 +184,10 @@ type Backups struct { Run Runner Now func() time.Time Say func(format string, args ...any) + // Emit says an event of this module on the bus; nil where the process has no runtime to say it (the night and the + // measuring run as processes of their own). Node is the machine's name, said in the events. + Emit func(event string, body any) error + Node string } // The locks, files in this module's state. The night runs in a process of its own and `now`, `restore` and a @@ -249,6 +253,28 @@ func (b *Backups) Nights() map[string]Night { return nights } +// EventBackedUp is what this module says when a backup asked for now (`node-backup.now`) has ended for a module, well or +// not: {node, module, ok, snapshot, at, error}. A caller that asked waits for it on the bus instead of asking `backed-up` +// again and again (novox/hq ADR 0539 §16); the nights run in a process with no runtime and say nothing. +const EventBackedUp = "backed-up" + +// saidBackedUp says EventBackedUp for one module's backup taken by hand. A failure to say it is logged, never the backup's. +func (b *Backups) saidBackedUp(module string, n Night) { + if b.Emit == nil { + return + } + body := map[string]any{"node": b.Node, "module": module, "ok": n.OK, "at": n.At} + if n.Snapshot != "" { + body["snapshot"] = n.Snapshot + } + if n.Error != "" { + body["error"] = n.Error + } + if err := b.Emit(EventBackedUp, body); err != nil { + b.Say("%s's backup could not be said on the bus: %v", module, err) + } +} + func (b *Backups) record(module string, n Night) { nights := b.Nights() nights[module] = n @@ -379,6 +405,9 @@ func (b *Backups) backUp(ctx context.Context, only string, hand bool) (map[strin for _, d := range chosen { outcome[d.Module] = b.one(ctx, d, hand) b.record(d.Module, outcome[d.Module]) + if hand { + b.saidBackedUp(d.Module, outcome[d.Module]) + } if n := outcome[d.Module]; !hand && n.OK && n.Snapshot != "" { b.recordGoodNight(d.Module, n.Snapshot) } diff --git a/modules/restic/cmd/restic-backups/hand.go b/modules/restic/cmd/restic-backups/hand.go index 4fc0dba5..1b9253b8 100644 --- a/modules/restic/cmd/restic-backups/hand.go +++ b/modules/restic/cmd/restic-backups/hand.go @@ -33,7 +33,9 @@ const HandTag = "hand" func (b *Backups) BackUpNow(ctx context.Context, only string) (map[string]Night, error) { out, err := b.backUp(ctx, only, true) if err != nil && only != "" { - b.record(only, Night{OK: false, At: b.Now().UTC(), Error: err.Error()}) + n := Night{OK: false, At: b.Now().UTC(), Error: err.Error()} + b.record(only, n) + b.saidBackedUp(only, n) } return out, err } diff --git a/modules/restic/cmd/restic-backups/hand_test.go b/modules/restic/cmd/restic-backups/hand_test.go index afd4637b..14a64422 100644 --- a/modules/restic/cmd/restic-backups/hand_test.go +++ b/modules/restic/cmd/restic-backups/hand_test.go @@ -351,3 +351,57 @@ func TestABackupAskedForNowThatCannotStartIsRecordedAsThatModulesLastNight(t *te t.Errorf("mail's last night is %+v, want a failure saying it declares nothing", n) } } + +// R6 (novox/hq ADR 0539 §16): a backup asked for now says on the bus when it has ended for a module, well or not, with the node, +// the module, the snapshot and the time, so the node agent waits for the event instead of asking `backed-up` every five seconds. +func TestABackupAskedForNowSaysItEndedOnTheBusForEachModule(t *testing.T) { + type said struct { + event string + body map[string]any + } + var events []said + emit := func(event string, body any) error { + raw, _ := json.Marshal(body) + var m map[string]any + _ = json.Unmarshal(raw, &m) + events = append(events, said{event, m}) + return nil + } + at := time.Date(2026, 10, 24, 14, 0, 0, 0, time.UTC) + h := &handRestic{made: "abcdef0123456789"} + b := &Backups{Where: placed(t, "# pg\npath /\n# app\npath /\n"), Run: h.run, Now: func() time.Time { return at }, Say: quiet, + Emit: emit, Node: "ace"} + _, err := b.BackUpNow(context.Background(), "") + must(t, err) + if len(events) != 2 { + t.Fatalf("said %d events for two modules: %v", len(events), events) + } + seen := map[string]bool{} + for _, e := range events { + m, _ := e.body["module"].(string) + seen[m] = true + if e.event != "backed-up" || e.body["node"] != "ace" || e.body["ok"] != true || e.body["snapshot"] == "" || e.body["snapshot"] == nil || + e.body["at"] != at.Format(time.RFC3339) { + t.Errorf("%s: %s %v", m, e.event, e.body) + } + } + if !seen["pg"] || !seen["app"] { + t.Errorf("modules said: %v", seen) + } + // One that cannot start is said failed with why, so a caller waiting on it reads an answer and not silence. + events = nil + _, err = b.BackUpNow(context.Background(), "nobody") + if err == nil { + t.Fatal("a module that declares nothing was backed up") + } + if len(events) != 1 || events[0].body["module"] != "nobody" || events[0].body["ok"] != false || events[0].body["error"] == nil { + t.Errorf("the failure was not said: %v", events) + } + // A night says nothing: it runs in a process with no runtime. + events = nil + _, err = b.BackUp(context.Background(), "") + must(t, err) + if len(events) != 0 { + t.Errorf("a night said %v", events) + } +} diff --git a/modules/restic/cmd/restic-backups/main.go b/modules/restic/cmd/restic-backups/main.go index 37493adf..f4e08e32 100644 --- a/modules/restic/cmd/restic-backups/main.go +++ b/modules/restic/cmd/restic-backups/main.go @@ -39,11 +39,14 @@ func main() { say("%v", err) os.Exit(1) } - b := &Backups{Where: where, Run: execRunner, Now: time.Now, Say: say} + b := &Backups{Where: where, Run: execRunner, Now: time.Now, Say: say, Node: os.Getenv("MESH_NODE")} way := "" if len(os.Args) > 1 { way = os.Args[1] } + if way == "" { + b.Emit = func(event string, body any) error { return stdio.Emit(event, body) } + } switch way { case "night": nights(b) diff --git a/modules/restic/module.json b/modules/restic/module.json index 18ee811d..ea5ec8b9 100644 --- a/modules/restic/module.json +++ b/modules/restic/module.json @@ -13,6 +13,9 @@ ] } ], + "emits": [ + "backed-up" + ], "tools": [ "backup_health", "backup_delete_retired", diff --git a/modules/restic/restic-backups b/modules/restic/restic-backups new file mode 100755 index 00000000..01f0b24a Binary files /dev/null and b/modules/restic/restic-backups differ