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