a52851e79e
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
182 lines
7.5 KiB
Go
182 lines
7.5 KiB
Go
package backup
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// reconcileRestoreSecrets merges the recovery unit's non-secret env with the secrets recovered from
|
|
// the guest's own app.yaml, and applies the FAIL-CLOSED data-key gate. It is the safety-critical heart
|
|
// of Phase 2b and is deliberately a pure function (no I/O) so it can be exhaustively unit-tested.
|
|
//
|
|
// Policy (per the Phase 2 design — see REPORT/CHANGELOG):
|
|
// - Regenerate NOTHING. Every secret comes from the guest (live rootfs, or PBS whole-guest restore).
|
|
// - A missing DATA-ENCRYPTING key (`dataKeyNames`) is FATAL: regenerating it would render the
|
|
// restored data unreadable, so we refuse and tell the operator to do a PBS whole-guest restore.
|
|
// - A missing resettable secret (DB password, admin password) is NON-fatal: it's returned in
|
|
// `missing` so the caller can warn; the app may simply need a credential reset, no data is lost.
|
|
func reconcileRestoreSecrets(nonSecretEnv, recoveredSecrets map[string]string, secretNames, dataKeyNames []string) (fullEnv map[string]string, missing []string, err error) {
|
|
fullEnv = make(map[string]string, len(nonSecretEnv)+len(secretNames))
|
|
for k, v := range nonSecretEnv {
|
|
fullEnv[k] = v
|
|
}
|
|
have := func(n string) bool {
|
|
v, ok := recoveredSecrets[n]
|
|
return ok && v != ""
|
|
}
|
|
for _, n := range secretNames {
|
|
if have(n) {
|
|
fullEnv[n] = recoveredSecrets[n]
|
|
} else {
|
|
missing = append(missing, n)
|
|
}
|
|
}
|
|
// Fail-closed: any unrecoverable data-encrypting key aborts the restore.
|
|
var missingDataKeys []string
|
|
for _, dk := range dataKeyNames {
|
|
if !have(dk) {
|
|
missingDataKeys = append(missingDataKeys, dk)
|
|
}
|
|
}
|
|
if len(missingDataKeys) > 0 {
|
|
return nil, missing, fmt.Errorf(
|
|
"refusing to restore: data-encrypting key(s) %v could not be recovered from the guest's app.yaml — "+
|
|
"a PBS whole-guest restore is required first (regenerating the key would render stored data unreadable)",
|
|
missingDataKeys)
|
|
}
|
|
return fullEnv, missing, nil
|
|
}
|
|
|
|
// readStrippedEnv parses the non-secret env from a recovery unit's secret-stripped app.yaml.
|
|
func readStrippedEnv(path string) map[string]string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return map[string]string{}
|
|
}
|
|
var s strippedAppYaml
|
|
if yaml.Unmarshal(data, &s) != nil || s.Env == nil {
|
|
return map[string]string{}
|
|
}
|
|
return s.Env
|
|
}
|
|
|
|
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit + the guest's own secrets.
|
|
//
|
|
// It reads the unit manifest, recovers the secret values from the guest's live app.yaml, applies the
|
|
// fail-closed data-key gate, restores the named-volume data from the unit's tars, then restores the
|
|
// app's definition from the unit and redeploys it with the reconstructed env (re-pulling the pinned
|
|
// image). No secret is ever regenerated, and no secret is read from the unit. If no unit exists it
|
|
// falls back to the legacy volume-only RestoreApp.
|
|
func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
|
|
if m.stackProvider == nil {
|
|
return fmt.Errorf("stack provider not configured")
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.running {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("backup or restore already in progress")
|
|
}
|
|
m.running = true
|
|
m.mu.Unlock()
|
|
defer func() {
|
|
m.mu.Lock()
|
|
m.running = false
|
|
m.mu.Unlock()
|
|
}()
|
|
|
|
drivePath := m.GetAppDrivePath(stackName)
|
|
if drivePath == "" || !filepath.IsAbs(drivePath) {
|
|
return fmt.Errorf("cannot determine drive path for %s", stackName)
|
|
}
|
|
nsRoot := m.namespaceRoot(drivePath)
|
|
|
|
manifest := readManifest(RecoveryUnitManifestPath(nsRoot, stackName))
|
|
if manifest == nil {
|
|
m.logger.Printf("[WARN] [backup] No recovery unit for %s — falling back to volume-only restore", stackName)
|
|
m.mu.Lock()
|
|
m.running = false // RestoreApp re-acquires the running flag
|
|
m.mu.Unlock()
|
|
return m.RestoreApp(stackName, "")
|
|
}
|
|
|
|
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
|
|
nonSecretEnv := readStrippedEnv(filepath.Join(composeDir, "app.yaml"))
|
|
|
|
// Recover secrets from the GUEST (never the unit), then apply the fail-closed gate.
|
|
recovered := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
|
|
fullEnv, missing, err := reconcileRestoreSecrets(nonSecretEnv, recovered, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
|
|
if err != nil {
|
|
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
|
|
return err
|
|
}
|
|
// O4: a missing RESETTABLE secret used to redeploy blank (compose "Defaulting to a blank
|
|
// string" → exit 1). Generate a replacement via the deploy flow's generator instead —
|
|
// RecreateStackFromUnit persists fullEnv through SaveAppConfig, so the new value lands
|
|
// encrypted in the guest app.yaml and round-trips on the next backup/restore. Data-keys are
|
|
// never generated: the fail-closed gate above already refused if one was missing, and the
|
|
// generator itself refuses data-key fields (defense-in-depth). Values are never logged.
|
|
if len(missing) > 0 {
|
|
dataKeySet := make(map[string]bool, len(manifest.DataKeyEnvVars))
|
|
for _, dk := range manifest.DataKeyEnvVars {
|
|
dataKeySet[dk] = true
|
|
}
|
|
var generated, unresolved []string
|
|
for _, name := range missing {
|
|
if !dataKeySet[name] && m.generateSecret != nil {
|
|
if v, ok := m.generateSecret(stackName, name); ok && v != "" {
|
|
fullEnv[name] = v
|
|
generated = append(generated, name)
|
|
continue
|
|
}
|
|
}
|
|
unresolved = append(unresolved, name)
|
|
}
|
|
if len(generated) > 0 {
|
|
m.logger.Printf("[WARN] [backup] Restore %s: generated replacement for %v — the credential was reset (old value unrecoverable); stored data is unaffected (no data-key involved)",
|
|
stackName, generated)
|
|
}
|
|
if len(unresolved) > 0 {
|
|
m.logger.Printf("[WARN] [backup] Restore %s: %d resettable secret(s) unrecoverable and have no generator %v — proceeding, but the app may fail to start until the credential is set manually",
|
|
stackName, len(unresolved), unresolved)
|
|
}
|
|
}
|
|
m.logger.Printf("[INFO] [backup] Restoring %s from recovery unit: images=%d, secrets recovered=%d/%d, data_keys=%d",
|
|
stackName, len(manifest.ImagePins), len(manifest.SecretEnvVars)-len(missing), len(manifest.SecretEnvVars), len(manifest.DataKeyEnvVars))
|
|
|
|
// Stop, restore named-volume data, then recreate the definition + redeploy with the recovered env.
|
|
// F17: surface a data-restore failure instead of swallowing it (we still bring the app back up).
|
|
var dataErr error
|
|
if err := m.stackProvider.StopStack(stackName); err != nil {
|
|
m.logger.Printf("[WARN] [backup] could not stop %s before restore: %v (continuing)", stackName, err)
|
|
}
|
|
if err := m.restoreDockerVolumes(stackName, drivePath); err != nil {
|
|
m.logger.Printf("[ERROR] [backup] volume restore for %s: %v", stackName, err)
|
|
dataErr = err
|
|
}
|
|
if err := m.stackProvider.RecreateStackFromUnit(stackName, composeDir, fullEnv); err != nil {
|
|
return fmt.Errorf("recreating %s from unit: %w", stackName, err)
|
|
}
|
|
// F17: the captured .sql dump is the authoritative logical DB state — replay it into the now-running
|
|
// DB container AFTER the volume restore, so the dump WINS over any volume-tar copy of the database.
|
|
if _, err := m.reimportDBDumpsCtx(stackName, nsRoot); err != nil {
|
|
m.logger.Printf("[ERROR] [backup] DB re-import for %s: %v", stackName, err)
|
|
if dataErr == nil {
|
|
dataErr = err
|
|
}
|
|
}
|
|
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil {
|
|
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
|
|
}
|
|
|
|
if dataErr != nil {
|
|
return fmt.Errorf("restore of %s from unit completed with data errors: %w", stackName, dataErr)
|
|
}
|
|
m.logger.Printf("[INFO] [backup] Restore-from-unit completed: %s", stackName)
|
|
return nil
|
|
}
|