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:
2026-06-13 10:20:37 +02:00
parent 5eb25c3861
commit 70eb521cd0
9 changed files with 586 additions and 3 deletions
+50
View File
@@ -1,6 +1,7 @@
package appbackup
import (
"bufio"
"context"
"fmt"
"log"
@@ -23,6 +24,55 @@ type StackDataProvider interface {
StopStack(name string) error
StartStack(name string) error
RefreshAndIsRunning(name string) bool
// GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit
// (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the
// secret/data-key env vars (values are NEVER returned — they are recovered at restore time
// from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the
// stack is unknown.
GetStackRecoveryInfo(name string) (RecoveryInfo, bool)
}
// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
// It deliberately holds NO secret values — only the names of secret/data-key env vars, so the
// manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the
// unit ever storing a secret or a data-encrypting key.
type RecoveryInfo struct {
StackDir string // dir holding docker-compose.yml + .felhom.yml + app.yaml
DisplayName string // app display name
ImagePins []string // pinned image tags from compose `image:` lines (re-pulled on restore)
NonSecretEnv map[string]string // env with all secret/password/data-key values removed (plaintext only)
SecretEnvVars []string // NAMES of stripped secret/password fields (recovered from guest/PBS)
DataKeyEnvVars []string // NAMES of data-encrypting-key fields (fail-closed gate on restore)
}
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
// docker-compose.yml, in file order, de-duplicated. The image bytes are never stored in the
// recovery unit — only these pins, so restore re-pulls from the registry.
func ParseComposeImages(composePath string) []string {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var images []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "image:") {
continue
}
img := strings.TrimSpace(strings.TrimPrefix(line, "image:"))
img = strings.Trim(img, "\"'")
// Skip variable-only images we can't pin (e.g. image: ${SOME_IMAGE})
if img == "" || strings.HasPrefix(img, "${") {
continue
}
if !seen[img] {
seen[img] = true
images = append(images, img)
}
}
return images
}
// StackSummary holds minimal stack info needed for app data discovery.