R-893: hold the app after ANY failure once the definition or a volume moved; hold persisted before the stop; run_job done says it ran, not what it found (security review)
gates / gates (push) Successful in 56s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 15:13:04 +02:00
parent d3e17e9b2e
commit 3f84f82c3d
9 changed files with 134 additions and 28 deletions
@@ -31,7 +31,15 @@ import (
// r893Provider records EVERY definition write (vtReconProvider keeps only the last).
type r893Provider struct {
*vtReconProvider
defs [][]string
defs [][]string
onStop func()
}
func (p *r893Provider) StopStack(name string) error {
if p.onStop != nil {
p.onStop()
}
return p.vtReconProvider.StopStack(name)
}
func (p *r893Provider) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
@@ -133,3 +141,45 @@ func TestR893_AReplacedVolumeThenAFailedReplayHoldsTheApp(t *testing.T) {
t.Fatalf("no version changed, yet a definition was written: %v", vp.defs)
}
}
// Security review 2026-10-08 (G1): the hold covers EVERY failure after the snapshot's definition is written, not only a
// failed replay. Here the DB-only start fails after a version change: before the fix the app was started at the
// snapshot's (older) definition on the live database. Now the live definition is written back and the app is held.
// RED-PROOF: restore `restartStack()` as the only action in the StartStackServices failure branch → started → FAILS.
func TestR893_AVersionChangeThenAFailedDBStartHoldsTheApp(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, true, 0)
vp.startSvcErr = context.DeadlineExceeded
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed DB-only start must be surfaced")
}
if vp.fullStarted {
t.Fatalf("the app was STARTED at the snapshot's definition on the live data — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("no hold was left after a failed DB-only start that followed a version change")
}
if len(vp.defs) != 2 || !strings.Contains(strings.Join(vp.defs[1], " "), "postgres:18-alpine") {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
if *rolled != 0 || *notified != 1 {
t.Errorf("rollback ran %d (want 0: nothing was replayed), notified %d (want 1)", *rolled, *notified)
}
}
// Security review 2026-10-08 (G3): the hold is persisted BEFORE the app is stopped, so a controller that dies in
// between cannot restart the app from its app-stop marker with nothing refusing it. The provider's StopStack asserts
// the hold is already on disk at the moment it is called.
// RED-PROOF: move SetRestoreHold after StopStack in holdAppAfterMixedRestore → FAILS.
func TestR893_HoldIsPersistedBeforeTheStop(t *testing.T) {
m, vp, _, _ := r893Fixture(t, true, 0)
seenAtStop := []bool{}
vp.onStop = func() {
held, _ := m.RestoreHoldFor("immich")
seenAtStop = append(seenAtStop, held)
}
_, _ = m.ReconstituteFromOffsite(context.Background(), "immich", false)
if len(seenAtStop) == 0 || !seenAtStop[len(seenAtStop)-1] {
t.Fatalf("the last stop ran before the hold was persisted (held at each stop: %v)", seenAtStop)
}
}