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:
@@ -827,6 +827,42 @@ func generateValue(spec string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecretForField generates a replacement value for a stack's RESETTABLE secret deploy-field
|
||||
// (O4: the restore-from-unit path uses this — via backup.SetSecretGenerator — when a resettable
|
||||
// secret cannot be recovered from the guest's app.yaml, so the app redeploys with a fresh credential
|
||||
// instead of a blank one that fails compose-up).
|
||||
//
|
||||
// Returns ok=false when the field is unknown, has no generator spec, or — deliberately — is a
|
||||
// DATA-ENCRYPTING key: data-keys are NEVER generated (regenerating one would render stored data
|
||||
// unreadable; the restore's fail-closed gate refuses before this point, this is defense-in-depth).
|
||||
// The generated VALUE is never logged — names only.
|
||||
func (m *Manager) GenerateSecretForField(stackName, envVar string) (string, bool) {
|
||||
s, ok := m.GetStack(stackName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
meta := LoadMetadata(filepath.Dir(s.ComposePath))
|
||||
for _, f := range meta.DeployFields {
|
||||
if f.EnvVar != envVar {
|
||||
continue
|
||||
}
|
||||
if f.DataKey {
|
||||
m.logger.Printf("[WARN] [stacks] GenerateSecretForField(%s/%s): refusing — field is a data-encrypting key", stackName, envVar)
|
||||
return "", false
|
||||
}
|
||||
if (f.Type != "secret" && f.Type != "password") || f.Generate == "" {
|
||||
return "", false
|
||||
}
|
||||
value, err := generateValue(f.Generate)
|
||||
if err != nil || value == "" {
|
||||
m.logger.Printf("[ERROR] [stacks] GenerateSecretForField(%s/%s): generator %q failed: %v", stackName, envVar, f.Generate, err)
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// InjectMissingFields checks deployed stacks for new deploy_fields that are not
|
||||
// yet in app.yaml and auto-generates values for secret/domain fields.
|
||||
// Called after sync (for updated stacks) and on startup (for all deployed stacks).
|
||||
|
||||
Reference in New Issue
Block a user