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:
@@ -480,6 +480,32 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
|
||||
// flow (Phase 2b): unlike UpdateStackConfig it sets the full env INCLUDING locked secrets — which were
|
||||
// recovered from the guest's own app.yaml, never regenerated. Caller is responsible for the gate.
|
||||
func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
|
||||
if err := m.PersistUnitRedeployConfig(name, env); err != nil {
|
||||
return err
|
||||
}
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
deployEnv := m.stackEnv(stackDir) // decrypts secrets back for compose
|
||||
if _, err := m.composeExecCustomEnv(stackDir, deployEnv, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("compose up: %w", err)
|
||||
}
|
||||
m.logPostStartStatus(name, stackDir, deployEnv)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// PersistUnitRedeployConfig is the PERSIST half of RedeployFromEnv: it writes app.yaml from the full
|
||||
// env (encrypting secret fields, recording locked fields) and marks the stack deployed in memory —
|
||||
// and starts NOTHING.
|
||||
//
|
||||
// Split out for R-47. The restore paths must place the app's definition and then bring up only the
|
||||
// database service for the dump replay; calling RedeployFromEnv there would end in a full
|
||||
// `compose up -d` BEFORE the replay, which is exactly the race (H4) this work removes.
|
||||
// RedeployFromEnv itself is this function plus the unchanged up-and-report tail, so its public
|
||||
// behaviour is identical to before the split.
|
||||
func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
@@ -509,12 +535,7 @@ func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Redeploying %s from recovery unit with %d env vars", name, len(env))
|
||||
deployEnv := m.stackEnv(stackDir) // decrypts secrets back for compose
|
||||
if _, err := m.composeExecCustomEnv(stackDir, deployEnv, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("compose up: %w", err)
|
||||
}
|
||||
m.logPostStartStatus(name, stackDir, deployEnv)
|
||||
return m.RefreshStatus()
|
||||
return nil
|
||||
}
|
||||
|
||||
// composeExecWithEnv runs a compose command with custom env vars injected. Used by the initial deploy
|
||||
|
||||
Reference in New Issue
Block a user