From 935e624f10b7d298f66deaebc2333610b7e0728c Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 25 Oct 2026 17:17:00 +0100 Subject: [PATCH] D47 follow-up: a set-aside tells the operator once (backup_run_failures, leg unit-set-aside, both identities, own run id). Red-proved. Commit only. --- CHANGELOG.md | 3 +++ controller/cmd/controller/main.go | 8 ++++++++ controller/cmd/controller/r1279_wiring_test.go | 3 +++ controller/internal/backup/backup.go | 3 +++ controller/internal/backup/unit_owner.go | 8 ++++++++ controller/internal/backup/unit_owner_test.go | 18 ++++++++++++++++++ 6 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd19846..427a233e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ says „not started yet", alarms the operator; a late start is a success; never started" (a healthy Vikunja restore was reported not started). It reads the inspect JSON now (`parseContainerSamples`, `TestFixA_ParseContainerSamples`); the sampler's error keeps Docker's stderr, and a DEBUG line says why a sample was not ok. +- **D47 follow-up (brief Part 5.2):** a set-aside now tells the operator once (`backup_run_failures`, leg + `unit-set-aside`, both identities, its own run id so the hub's cooldown never swallows a second one) — a household's + set-aside after the floor is visible (`TestD47_ASetAsideTellsTheOperatorOnce`, seen failing without the call). - **Fix B (R-1279, `appexport/restore.go`):** the import asks the restores' own D48 helper (`PrepareRestoreImages`, passed in by main.go — the package may not import `backup`) about the bundle's definition BEFORE step 1 removes the running app's volumes; a version that cannot be downloaded refuses with the two D48 sentences and nothing changed. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index babc1644..31d0b6a7 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1543,6 +1543,14 @@ func main() { }) // R-893 option A (D20 = A): a failed off-site restore that was PUT BACK — the live volumes and definition poured // back and the app started. The same operator-tier event as the hold above (no new event type), its own leg. + // D47 follow-up: a unit of an earlier install was set aside whole — the operator sees it (its own leg). + backupMgr.SetUnitSetAsideNotify(func(stack, unitID, liveID, dest string) { + d := notify.BackupRunFailuresDetails{RunID: "unit-set-aside-" + stack + "-" + filepath.Base(dest), RunKind: "backup", Failed: 1, Attempted: 1} + d.Apps = append(d.Apps, notify.RunFailureDetail{App: stack, Leg: "unit-set-aside", Reason: "the backup of an earlier install was set aside whole (D47)"}) + msg := fmt.Sprintf("App %q: its backup belonged to an earlier install (%s; this install %s), so it was SET ASIDE whole at %s (D47) "+ + "and listed on the restore page. Expected after a reinstall; not expected on an upgrade — look if no one reinstalled the app.", stack, unitID, liveID, dest) + notifier.NotifyBackupRunFailures(msg, d) + }) // D48: a restore refused BEFORE anything was touched because an image of the backup's version could not be // downloaded — the operator line that tells a deleted version (R-698, R-933) from a busy registry. backupMgr.SetRestorePullRefusedNotify(func(stack, ref string, cause error) { diff --git a/controller/cmd/controller/r1279_wiring_test.go b/controller/cmd/controller/r1279_wiring_test.go index a2544b9e..fa17bc37 100644 --- a/controller/cmd/controller/r1279_wiring_test.go +++ b/controller/cmd/controller/r1279_wiring_test.go @@ -16,6 +16,9 @@ func TestR1279_MainWiresTheImportsDownloadFirst(t *testing.T) { if !strings.Contains(string(b), "appExporter.SetImagePreparer(backupMgr.PrepareRestoreImages)") { t.Fatal("main.go must wire the import's download-first helper (SetImagePreparer)") } + if !strings.Contains(string(b), "backupMgr.SetUnitSetAsideNotify(") { + t.Fatal("main.go must wire the D47 set-aside operator line") + } if !strings.Contains(string(b), "func (a *stackAdapter) ContainerSamples(") { t.Fatal("main.go's stack adapter must read container samples for the stable-start rule (fix A)") } diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 547f9ca3..916f0fef 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -248,6 +248,9 @@ type Manager struct { imagePresentFn func(ref string) bool imagePullFn func(ctx context.Context, ref string) (string, error) restorePullRefusedNotify func(stack, ref string, cause error) + // unitSetAsideNotify (D47 follow-up, brief 2026-10-26 Part 5.2): one operator line when a box sets an earlier + // install's unit aside — so a household's set-aside after the floor is visible to the operator. + unitSetAsideNotify func(stack, unitID, liveID, dest string) // R-403 unit-REHYDRATE seam — the copy that refills the primary unit from the Tier-2 mirror after // a restore, overridable so the "only when hollow" condition and the byte-identical non-effect are diff --git a/controller/internal/backup/unit_owner.go b/controller/internal/backup/unit_owner.go index de971416..ae47baa4 100644 --- a/controller/internal/backup/unit_owner.go +++ b/controller/internal/backup/unit_owner.go @@ -181,9 +181,17 @@ func (m *Manager) guardUnitDir(stack, unitDir, setAsideNs, what string) error { } m.logger.Printf("[INFO] [backup] %s: the recovery unit of an earlier install (%s; %s) is SET ASIDE whole → %s; this install (%s) gets its own unit from its first backup (D47)", stack, orUnknown(unitID), why, dest, orUnknown(liveID)) + if m.unitSetAsideNotify != nil { + m.unitSetAsideNotify(stack, orUnknown(unitID), orUnknown(liveID), dest) + } return nil } +// SetUnitSetAsideNotify wires the operator line for a set-aside unit (main.go). +func (m *Manager) SetUnitSetAsideNotify(fn func(stack, unitID, liveID, dest string)) { + m.unitSetAsideNotify = fn +} + func orUnknown(id string) string { if id == "" { return "unrecorded" diff --git a/controller/internal/backup/unit_owner_test.go b/controller/internal/backup/unit_owner_test.go index 5021cbe8..992a22a5 100644 --- a/controller/internal/backup/unit_owner_test.go +++ b/controller/internal/backup/unit_owner_test.go @@ -302,3 +302,21 @@ func TestD47_OwnerVerdict(t *testing.T) { } } } + +// D47 follow-up (brief 2026-10-26 Part 5.2): a set-aside tells the operator once — the box's own households must not +// set units aside silently after the floor. COMPANION RED-PROOF: drop the notify call → this fails. +func TestD47_ASetAsideTellsTheOperatorOnce(t *testing.T) { + r := d47Setup(t, "aaaa000000000001") + var got []string + r.m.SetUnitSetAsideNotify(func(stack, unitID, liveID, dest string) { got = append(got, stack+" "+unitID+" "+liveID) }) + r.reinstall("bbbb000000000002") + if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { + t.Fatal(err) + } + if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { // the next refresh: nothing more to set aside + t.Fatal(err) + } + if len(got) != 1 || got[0] != "tandoor aaaa000000000001 bbbb000000000002" { + t.Fatalf("the operator hears once, with both identities: %v", got) + } +}