v0.53.0: Phase 2 capture side — per-app secret-free recovery unit
Each app's on-drive backup becomes a self-contained, recreatable recovery unit: compose/ (docker-compose.yml + .felhom.yml + secret-stripped app.yaml) alongside the existing db-dumps/ + volume-dumps/, plus a secret-free manifest.json (image pins, secret env-var NAMES, data_key names, checksums). The unit stores no secret value, no data-key, and not the image — secrets are recovered at restore from the guest's own app.yaml (live/PBS), never regenerated. - appbackup: RecoveryUnit* path helpers, RecoveryInfo + GetStackRecoveryInfo, ParseComposeImages; AppDBDump/Volume refactored onto RecoveryUnitPath. - backup: recovery_unit.go (manifest + CaptureRecoveryUnit), wired into RunDBDumps; capture test proves secret-free. - stacks: DeployField.DataKey + Metadata.DataKeyEnvVars(); main.go stackAdapter implements GetStackRecoveryInfo (excludes secret-named + encrypted values). - Restore-from-unit recreate + fail-closed gate + live AdventureLog validation: next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,7 @@ func main() {
|
||||
if cfg.Backup.Enabled {
|
||||
backupMgr = backup.NewManager(cfg, sett, logger)
|
||||
backupMgr.SetStackProvider(stackProv)
|
||||
backupMgr.SetVersion(Version)
|
||||
}
|
||||
|
||||
// --- Initialize alert manager ---
|
||||
@@ -851,6 +852,58 @@ 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.
|
||||
func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, bool) {
|
||||
s, ok := a.mgr.GetStack(name)
|
||||
if !ok {
|
||||
return backup.RecoveryInfo{}, false
|
||||
}
|
||||
stackDir := filepath.Dir(s.ComposePath)
|
||||
meta := stacks.LoadMetadata(stackDir)
|
||||
|
||||
// Secret set = all secret/password fields ∪ any data_key fields (in deterministic metadata order).
|
||||
secretSet := make(map[string]bool)
|
||||
var secretNames []string
|
||||
add := func(v string) {
|
||||
if !secretSet[v] {
|
||||
secretSet[v] = true
|
||||
secretNames = append(secretNames, v)
|
||||
}
|
||||
}
|
||||
for _, v := range stacks.SensitiveEnvVars(&meta) {
|
||||
add(v)
|
||||
}
|
||||
dataKeys := meta.DataKeyEnvVars()
|
||||
for _, v := range dataKeys {
|
||||
add(v)
|
||||
}
|
||||
|
||||
// Non-secret env: raw app.yaml values that are neither named-secret nor (defensively) encrypted.
|
||||
nonSecret := make(map[string]string)
|
||||
if appCfg := stacks.LoadAppConfig(stackDir); appCfg != nil {
|
||||
for k, v := range appCfg.Env {
|
||||
if secretSet[k] || crypto.IsEncrypted(v) {
|
||||
continue
|
||||
}
|
||||
nonSecret[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return backup.RecoveryInfo{
|
||||
StackDir: stackDir,
|
||||
DisplayName: s.Meta.DisplayName,
|
||||
ImagePins: backup.ParseComposeImages(s.ComposePath),
|
||||
NonSecretEnv: nonSecret,
|
||||
SecretEnvVars: secretNames,
|
||||
DataKeyEnvVars: dataKeys,
|
||||
}, true
|
||||
}
|
||||
|
||||
// RefreshAndIsRunning forces a docker ps scan before checking state.
|
||||
// Called during post-restore health check (~every 5s for up to 90s).
|
||||
// Full refresh is acceptable here since restores are rare operations.
|
||||
|
||||
Reference in New Issue
Block a user