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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user