fix(backup): O4 — generate a replacement for unrecoverable resettable secrets on restore

The proceed-path for a missing RESETTABLE secret redeployed the app with the
secret blank (compose "Defaulting to a blank string" → exit 1, live-hit in the
2026-07-04 drill Phase 5). Now the restore generates a fresh credential instead:

- stacks.Manager.GenerateSecretForField: replacement value from the field's
  catalog generate spec via the deploy flow's generateValue (no logic copied);
  refuses data-keys (defense-in-depth), spec-less and non-secret fields.
- backup.Manager.SetSecretGenerator seam (wired in main.go), consulted in
  RestoreFromRecoveryUnit AFTER the untouched fail-closed gate, for missing
  names NOT in DataKeyEnvVars. The generated value rides fullEnv into
  RecreateStackFromUnit → RedeployFromEnv → SaveAppConfig, so it persists
  encrypted in the guest app.yaml and round-trips on the next backup/restore
  (no second write path). reconcileRestoreSecrets stays pure and untouched.
- WARNs now discriminate: "generated replacement for X (credential was reset)"
  vs "X unrecoverable and has no generator — app may fail to start". Values are
  never logged (asserted in test).
- Residual case (documented, not pretended away): if a restored volume tar
  carries the OLD internal credential hash, the app may still fail auth until a
  manual in-DB reset — generation fully fixes only the fresh-init case.

Companion red-proof: pre-fix behaviour (generation skipped) fails
TestRestoreGeneratesMissingResettableSecret on the non-empty DB_PASSWORD
assertion (verified, reverted). Data-key gate proven unreachable by generation
in TestRestoreGenerationNeverReachesDataKeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 11:52:04 +02:00
parent 73378a812c
commit a52851e79e
6 changed files with 303 additions and 2 deletions
+12
View File
@@ -44,6 +44,12 @@ type Manager struct {
// disconnected) can be unit-tested without Docker. Nil → the real DumpAppVolumesSafe.
dumpVolumesSafe func(stackName string) error
// generateSecret (O4), if set, produces a replacement value for a RESETTABLE secret that could
// not be recovered during restore-from-unit (wired to stacks.Manager.GenerateSecretForField in
// main.go). Nil / ok=false → the secret stays absent and the restore proceeds with a loud WARN.
// NEVER consulted for data-keys — the fail-closed gate refuses those before generation runs.
generateSecret func(stackName, envVar string) (string, bool)
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
// nightly dump/Tier-2 can't race a migration copy/cleanup on the same drive.
@@ -506,6 +512,12 @@ func (m *Manager) releaseRunning() {
m.mu.Unlock()
}
// SetSecretGenerator wires the O4 resettable-secret generator used by RestoreFromRecoveryUnit
// (init-only, same contract as SetStackProvider: call once during single-threaded startup).
func (m *Manager) SetSecretGenerator(fn func(stackName, envVar string) (string, bool)) {
m.generateSecret = fn
}
// SetStackProvider sets the stack data provider for app data discovery.
//
// M2: this MUST be called exactly once during single-threaded startup (main.go),