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
@@ -190,24 +190,33 @@ func TestR354_ScenarioD_LiveRecoveryUnitIsNeverWritten(t *testing.T) {
}
// TestR354_ScenarioE_PartialReplayIsAFailure — a volume replay that fails must never read as a
// completion, and must name what failed.
// completion, and must name what failed (to the operator).
//
// UPDATED 2026-10-08 (R-893 option C, `09` §3 decision 192): one volume HAS been replaced, so a start
// would run the app on a mix of the snapshot's volume and the live rest. The app is now HELD stopped
// for support instead of brought up; the volume that did not come back is named in the operator's
// hold notice (the household reads the plain hold sentence).
func TestR354_ScenarioE_PartialReplayIsReportedAsFailure(t *testing.T) {
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
seedScratchVolumes(t, m, "immich", "immich_a.tar", "immich_b.tar")
m.volumeReplayFrom = func(_, _ string) (int, error) {
return 1, fmt.Errorf("failed to restore 1 volume(s): [immich_b]")
}
var noticed error
m.SetRestoreHoldNotify(func(_ string, replayErr, _ error) { noticed = replayErr })
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a partial volume replay must be reported as a failure, not a completion")
}
if !strings.Contains(err.Error(), "immich_b") {
t.Errorf("the failure must name the volume that did not come back; got %q", err.Error())
if noticed == nil || !strings.Contains(noticed.Error(), "immich_b") {
t.Errorf("the operator's hold notice must name the volume that did not come back; got %v", noticed)
}
// Best-effort bring-up: a failed restore must not also be an outage.
if !prov.fullStarted {
t.Error("the app was left stopped after a failed volume replay")
if prov.fullStarted {
t.Error("the app was STARTED on a partly replaced set of volumes — it must be held (R-893)")
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Error("no restore hold was left after a partial volume replay")
}
// The count of what DID come back is still carried, so the report can say "1 of 2".
if res.VolumesReplayed != 1 {