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.
gates / gates (push) Successful in 1m33s

This commit is contained in:
2026-10-25 17:17:00 +01:00
parent 3a1f09d29f
commit 935e624f10
6 changed files with 43 additions and 0 deletions
+3
View File
@@ -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.
+8
View File
@@ -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) {
@@ -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)")
}
+3
View File
@@ -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
+8
View File
@@ -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"
@@ -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)
}
}