D5: an app restore works from the drive alone (v0.188.0)

The recovery unit on the customer's drive now carries the PORTABLE secret
class, so Tier-1/Tier-2 restore no longer depends on the whole-guest tier.
A customer needs the drive and nothing else.

Part 0's rulings overturned the brief's recommendation, on evidence:
- the data_key flag is untrustworthy (4+ encryption keys the catalog itself
  labels as such are unflagged) -> R-127
- a DB password is not resettable in practice: POSTGRES_PASSWORD is ignored
  once PGDATA is non-empty, so a regenerated value leaves the app unable to
  authenticate against its own restored rows while the dump replay still
  reports success (proven on a throwaway postgres:16-alpine)

Ruling (operator): type:secret travels, type:password never does, minus the
nonPortableSecrets code register. Plaintext -- withholding the internet-
reachable class is what licenses that, and the two are coupled.

Precedence: the UNIT WINS over the guest -- the unit's secrets were captured
in the same run as the dumps beside them, so they match the data being
restored. The fail-closed data-key gate is unchanged.

Secret values are never logged; the manifest records NAMES only.
This commit is contained in:
2026-07-30 16:33:06 +02:00
parent 2f27a363d5
commit 4ed938cce4
12 changed files with 818 additions and 151 deletions
+32 -12
View File
@@ -1440,12 +1440,14 @@ func (a *stackAdapter) GetStackHDDPath(name string) string {
return ""
}
// GetStackRecoveryInfo gathers the SECRET-FREE inputs for an app's recovery unit (Phase 2): the
// stack dir, pinned image tags, the non-secret env, and the NAMES of secret/data-key env vars.
// It deliberately does NOT decrypt or return any secret value — secret/password fields are stored
// encrypted in app.yaml, so excluding them (plus a defensive crypto.IsEncrypted guard) yields a
// plaintext, secret-free env. The actual secret values are recovered at restore time from the
// guest's own app.yaml (live, or via the PBS whole-guest snapshot), never from the unit.
// GetStackRecoveryInfo gathers the inputs for an app's recovery unit: the stack dir, pinned image
// tags, the non-secret env, the NAMES of every secret/data-key env var, and — since D5 — the decrypted
// VALUES of the PORTABLE secret class.
//
// The non-secret env still excludes every named secret (plus a defensive crypto.IsEncrypted guard), so
// the two sets are disjoint by construction and a secret can only reach the unit by being in the
// portable class. The EXCLUDED class (`type: password`, and the nonPortableSecrets register) is
// name-only here and is recovered from the guest — or regenerated (O4) — exactly as before.
func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, bool) {
s, ok := a.mgr.GetStack(name)
if !ok {
@@ -1482,13 +1484,31 @@ func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, b
}
}
// D5: decrypt the portable class so it can travel in the unit. Reuses the SAME decrypt path as the
// restore side (LoadAppConfigDecrypted), so there is one way to turn app.yaml into plaintext, not
// two. A name whose value is absent/empty is simply omitted — the restore's fail-closed data-key
// gate is what decides whether that is survivable.
portableNames := stacks.PortableSecretEnvVars(&meta)
portable := make(map[string]string, len(portableNames))
if len(portableNames) > 0 {
if dec := stacks.LoadAppConfigDecrypted(stackDir, a.encKey); dec != nil {
for _, n := range portableNames {
if v, ok := dec.Env[n]; ok && v != "" {
portable[n] = v
}
}
}
}
return backup.RecoveryInfo{
StackDir: stackDir,
DisplayName: s.Meta.DisplayName,
ImagePins: backup.ParseComposeImages(s.ComposePath),
NonSecretEnv: nonSecret,
SecretEnvVars: secretNames,
DataKeyEnvVars: dataKeys,
StackDir: stackDir,
DisplayName: s.Meta.DisplayName,
ImagePins: backup.ParseComposeImages(s.ComposePath),
NonSecretEnv: nonSecret,
SecretEnvVars: secretNames,
DataKeyEnvVars: dataKeys,
PortableSecretEnvVars: portableNames,
PortableSecrets: portable,
}, true
}