diff --git a/CHANGELOG.md b/CHANGELOG.md index e6df385..5343659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## v0.283.1 — a Stop holds during the nightly volume dump IN PRODUCTION, and at the crash recovery (R-721) (2026-09-30) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings. + +- **Found live, on v0.283.0 itself (9202, 2026-09-30 08:42):** the household's Stop was recorded during the volume + dump, and the dump restarted the app 8 s later. v0.283.0 asked `WantsStopped` through an optional interface; the + unit test's fake answered it, but production hands the backup manager `cmd/controller`'s `stackAdapter`, which did + not — the assertion failed and the check silently skipped (the "seam built but never wired" class). The quiesce + path was fine (it is handed the stacks manager itself). +- `stackAdapter.WantsStopped` and `gatedAppStopStarter.WantsStopped` forward to the stacks manager; the startup + crash recovery (`AppStopGuard.Recover`) skips an app the household stopped, and still clears its marker. +- Tests: `TestR721_TheBackupsStackProviderAnswersWantsStopped` (the PRODUCTION types — adapter, gated starter, stacks + manager — each answer the question), `TestR721_CrashRecoveryKeepsTheHouseholdsStop`. Red-proofs RP43, RP44. +- A second release in one session, deliberately: the rule is one release per repo per session, and shipping a Stop + that is known to be undone every night serves no purpose of that rule. + ## v0.283.0 — apps go off-site by themselves (decision 50), with a size warning; a Stop holds during a backup (R-721); page slips (R-724, R-725) (2026-09-30) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `backups_offsite.offer_text`, diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index b8411bd..8ec6556 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -2297,6 +2297,14 @@ type gatedAppStopStarter struct { logger *log.Logger } +// WantsStopped (R-721, v0.283.1) forwards the household's intent to the crash recovery (see Recover). +func (s gatedAppStopStarter) WantsStopped(name string) bool { + if w, ok := s.inner.(interface{ WantsStopped(string) bool }); ok { + return w.WantsStopped(name) + } + return false +} + func (s gatedAppStopStarter) StartStack(name string) error { if ok, why := s.gate.MayStart(name); !ok { s.logger.Printf("[WARN] [appstop] refusing to restart %q after an interrupted operation: %s", name, why) @@ -2735,6 +2743,12 @@ func (a *stackAdapter) IsDeploying(name string) bool { return ok && s.Deploying } +// WantsStopped (R-721, v0.283.1): the backup package asks this before restarting an app it stopped for a +// volume dump. Measured live on 9202 2026-09-30 with v0.283.0: the method existed on the stacks manager but +// NOT on this adapter, so the dump's type assertion failed and a household Stop was undone 8 s later. +// Pinned by TestR721_TheBackupsStackProviderAnswersWantsStopped. +func (a *stackAdapter) WantsStopped(name string) bool { return a.mgr.WantsStopped(name) } + func (a *stackAdapter) StartStack(name string) error { return a.mgr.StartStack(name) } diff --git a/controller/cmd/controller/r721_wiring_test.go b/controller/cmd/controller/r721_wiring_test.go new file mode 100644 index 0000000..7aa32f6 --- /dev/null +++ b/controller/cmd/controller/r721_wiring_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/quiesce" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with +// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop +// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202 +// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app. +// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails. +func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) { + var prov interface{} = &stackAdapter{} + if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok { + t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)") + } + // The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}). + // The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter). + var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)} + if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok { + t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)") + } + var mgr *stacks.Manager + var _ quiesce.Stacks = mgr + if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok { + t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)") + } +} diff --git a/controller/internal/backup/appstop_marker.go b/controller/internal/backup/appstop_marker.go index dbb9c42..3614993 100644 --- a/controller/internal/backup/appstop_marker.go +++ b/controller/internal/backup/appstop_marker.go @@ -279,7 +279,14 @@ func (g *AppStopGuard) Recover() *AppStopRecovery { m.Reason.humanReason(), m.OpID, len(m.Stacks), m.Stacks) res := &AppStopRecovery{Reason: m.Reason, OpID: m.OpID, StartedAt: m.StartedAt} + hs, _ := g.starter.(interface{ WantsStopped(string) bool }) for _, name := range m.Stacks { + if hs != nil && hs.WantsStopped(name) { + // R-721 (v0.283.1): the household pressed Stop while the interrupted operation had the app down. + // It is owed nothing; the marker still clears below. + g.logger.Printf("[INFO] [appstop] crash recovery: NOT restarting %s — the household stopped it", name) + continue + } if err := g.starter.StartStack(name); err != nil { // R-174: a REFUSAL is not a failure. The starter's gate has said this app must not be // started (an absent data drive), so the app is left down deliberately and the holder diff --git a/controller/internal/backup/r721_recover_test.go b/controller/internal/backup/r721_recover_test.go new file mode 100644 index 0000000..e35ccdc --- /dev/null +++ b/controller/internal/backup/r721_recover_test.go @@ -0,0 +1,36 @@ +package backup + +import ( + "io" + "log" + "path/filepath" + "testing" +) + +type stoppedStarter struct { + fakeStarter + stopped map[string]bool +} + +func (s *stoppedStarter) WantsStopped(n string) bool { return s.stopped[n] } + +// R-721 (v0.283.1): a controller crash during a volume dump leaves a marker owing the app a restart; if the +// household pressed Stop meanwhile, the startup recovery does not start it — and the marker still clears. +// COMPANION RED-PROOF: remove the WantsStopped skip in Recover → started=[immich]. +func TestR721_CrashRecoveryKeepsTheHouseholdsStop(t *testing.T) { + dir := t.TempDir() + g1, _ := newGuard(t, dir) + if err := g1.Begin("volume-dump:immich", ReasonVolumeDump, []string{"immich"}); err != nil { + t.Fatal(err) + } + s := &stoppedStarter{stopped: map[string]bool{"immich": true}} + g2 := NewAppStopGuard(filepath.Join(dir, "appstop-state.json"), log.New(io.Discard, "", 0)) + g2.SetStarter(s) + g2.Recover() + if len(s.starts) != 0 { + t.Fatalf("the crash recovery started %v — the household stopped it (R-721)", s.starts) + } + if markerExists(t, dir) { + t.Fatal("the marker survived — the next boot would try again") + } +}