v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths
Closes R-47. No new agent coupling — MinAgent stays 0.90.0. The replay needs a running DB container, so both restore paths started the WHOLE stack first, giving the application a window to rebuild the very schema objects the dump was about to create. Measured live on 2026-07-19 (H4, DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds before the dump's CREATE INDEX, the replay aborted "already exists" under ON_ERROR_STOP=1, and immich reported schema drift. The data survived only because pg_dump emits COPY before CREATE INDEX. Both paths now open a DB-ONLY window: only the stack's database service(s) come up, the dump is replayed with the app still down, and the full start runs only after the replay exits 0. Fail-closed: a dump with no identifiable DB service refuses BEFORE the first mutation. Every exit from the window still does a best-effort full start, so a failed restore never leaves a box with a database and no application. New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty list — argument-less `up -d` is a full start); RedeployFromEnv split into PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden `up -d` inside the old name is what carried the defect on the local path. 19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed effects, replay-failure bring-up, parser decoys, empty-list refusal); three companion red-proofs run and reverted. 23/23 packages green. Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
This commit is contained in:
@@ -12,13 +12,23 @@ import (
|
||||
)
|
||||
|
||||
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
|
||||
//
|
||||
// It records the ORDER of every mutating call (R-47): the local restore path's correctness is an
|
||||
// ordering property — definition persisted, then the DB service alone, then the replay, then the
|
||||
// full start — and an ordering guarantee nothing observes is one refactor from silently reverting to
|
||||
// the shape that produced H4.
|
||||
type fakeRecoveryProvider struct {
|
||||
info RecoveryInfo
|
||||
hdd string
|
||||
secrets map[string]string // returned by RecoverStackSecrets
|
||||
gotEnv map[string]string // captured by RecreateStackFromUnit
|
||||
gotEnv map[string]string // captured by RecreateStackDefinitionFromUnit
|
||||
running bool // returned by RefreshAndIsRunning
|
||||
stopped bool
|
||||
|
||||
calls []string // ordered log: stop / recreate / startsvc:<a,b> / start
|
||||
gotServices []string // services passed to StartStackServices
|
||||
startSvcErr error // injected StartStackServices failure
|
||||
fullStarted bool // a FULL StartStack happened
|
||||
}
|
||||
|
||||
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
||||
@@ -28,9 +38,17 @@ func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil
|
||||
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
||||
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true; return nil }
|
||||
func (f *fakeRecoveryProvider) StartStack(string) error { return nil }
|
||||
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
||||
func (f *fakeRecoveryProvider) StopStack(string) error {
|
||||
f.stopped = true
|
||||
f.calls = append(f.calls, "stop")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) StartStack(string) error {
|
||||
f.fullStarted = true
|
||||
f.calls = append(f.calls, "start")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
||||
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return f.info, true
|
||||
}
|
||||
@@ -40,10 +58,16 @@ func (f *fakeRecoveryProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind
|
||||
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
|
||||
return f.secrets
|
||||
}
|
||||
func (f *fakeRecoveryProvider) RecreateStackFromUnit(_, _ string, fullEnv map[string]string) error {
|
||||
func (f *fakeRecoveryProvider) RecreateStackDefinitionFromUnit(_, _ string, fullEnv map[string]string) error {
|
||||
f.gotEnv = fullEnv
|
||||
f.calls = append(f.calls, "recreate")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) error {
|
||||
f.gotServices = append([]string(nil), services...)
|
||||
f.calls = append(f.calls, "startsvc:"+strings.Join(services, ","))
|
||||
return f.startSvcErr
|
||||
}
|
||||
|
||||
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
|
||||
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
|
||||
|
||||
Reference in New Issue
Block a user