Files
felhom-controller/controller/internal/backup/recovery_unit_test.go
T
admin 4ed938cce4 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.
2026-07-30 16:33:06 +02:00

273 lines
11 KiB
Go

package backup
import (
"encoding/json"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
//
// It records the ORDER of every mutating call (R-47): the local restore path's correctness is an
// ordering property — definition persisted, then the DB service alone, then the replay, then the
// full start — and an ordering guarantee nothing observes is one refactor from silently reverting to
// the shape that produced H4.
type fakeRecoveryProvider struct {
info RecoveryInfo
hdd string
secrets map[string]string // returned by RecoverStackSecrets
gotEnv map[string]string // captured by RecreateStackDefinitionFromUnit
running bool // returned by RefreshAndIsRunning
stopped bool
calls []string // ordered log: stop / recreate / startsvc:<a,b> / start
gotServices []string // services passed to StartStackServices
startSvcErr error // injected StartStackServices failure
fullStarted bool // a FULL StartStack happened
}
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
return filepath.Join(f.info.StackDir, "docker-compose.yml"), true
}
func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil }
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
func (f *fakeRecoveryProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
func (f *fakeRecoveryProvider) StopStack(string) error {
f.stopped = true
f.calls = append(f.calls, "stop")
return nil
}
func (f *fakeRecoveryProvider) StartStack(string) error {
f.fullStarted = true
f.calls = append(f.calls, "start")
return nil
}
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return f.info, true
}
func (f *fakeRecoveryProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) {
return nil, false
}
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
return f.secrets
}
func (f *fakeRecoveryProvider) RecreateStackDefinitionFromUnit(_, _ string, fullEnv map[string]string) error {
f.gotEnv = fullEnv
f.calls = append(f.calls, "recreate")
return nil
}
func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) error {
f.gotServices = append([]string(nil), services...)
f.calls = append(f.calls, "startsvc:"+strings.Join(services, ","))
return f.startSvcErr
}
// TestCaptureRecoveryUnitCarriesPortableSecretsOnly proves the captured unit (a) contains
// compose+config+manifest, (b) enumerates the existing dumps, and (c) implements the D5 secret split:
// the PORTABLE class is written into the unit's app.yaml, and the WITHHELD class appears NOWHERE in
// the unit.
//
// This test replaces TestCaptureRecoveryUnitIsSecretFree, whose global "no secret value appears in the
// unit" invariant D5 deliberately overturns for the portable class. The wrong-outcome half — the
// withheld value must still leak nowhere — is kept verbatim, because that is the half that is still a
// security boundary.
func TestCaptureRecoveryUnitCarriesPortableSecretsOnly(t *testing.T) {
const (
dataKeyVal = "DATAKEY-must-travel-or-the-data-is-unreadable"
dbPwVal = "DBPASSWORD-must-travel-or-the-app-cannot-authenticate"
withheldVal = "ADMINLOGIN-must-never-reach-the-drive"
)
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive") // in-guest namespace root (basename need not be felhom-data)
if err := os.MkdirAll(stackDir, 0755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
"services:\n app:\n image: example/app:1.2.3\n")
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\n")
// The SOURCE app.yaml holds the withheld admin login too, so the leak check below is not vacuous:
// it fails if anything ever copies the raw app.yaml into the unit instead of the generated one.
mustWrite(t, filepath.Join(stackDir, "app.yaml"),
"deployed: true\nenv:\n DB_PASSWORD: "+dbPwVal+"\n ADMIN_PASSWORD: "+withheldVal+
"\n SUBDOMAIN: example\n")
// Pre-existing dumps (written by the dump flow before capture).
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "example"), "example-postgres.sql"), "dump")
mustWrite(t, filepath.Join(AppVolumeDumpPath(drive, "example"), "example_data.tar"), "tar")
// RecoveryInfo as the adapter builds it: NonSecretEnv holds no secret, PortableSecrets holds the
// decrypted portable class, and ADMIN_PASSWORD is named in SecretEnvVars but NOT portable.
info := RecoveryInfo{
StackDir: stackDir,
DisplayName: "Example",
ImagePins: []string{"example/app:1.2.3"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY", "ADMIN_PASSWORD"},
DataKeyEnvVars: []string{"SECRET_KEY"},
PortableSecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
PortableSecrets: map[string]string{"DB_PASSWORD": dbPwVal, "SECRET_KEY": dataKeyVal},
}
m := &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "system"), // != drive ⇒ drive treated as in-guest, nsRoot = drive
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
version: "vtest",
}
if err := m.CaptureRecoveryUnit("example"); err != nil {
t.Fatalf("capture: %v", err)
}
composeDir := RecoveryUnitComposePath(drive, "example")
for _, f := range []string{"docker-compose.yml", ".felhom.yml", "app.yaml"} {
if _, err := os.Stat(filepath.Join(composeDir, f)); err != nil {
t.Errorf("missing captured config %s: %v", f, err)
}
}
// Manifest structure.
mfData, err := os.ReadFile(RecoveryUnitManifestPath(drive, "example"))
if err != nil {
t.Fatalf("manifest: %v", err)
}
var man RecoveryManifest
if err := json.Unmarshal(mfData, &man); err != nil {
t.Fatalf("manifest parse: %v", err)
}
if man.AppName != "example" || man.ControllerVer != "vtest" {
t.Errorf("manifest meta: app=%q ver=%q", man.AppName, man.ControllerVer)
}
if len(man.ImagePins) != 1 || man.ImagePins[0] != "example/app:1.2.3" {
t.Errorf("image pins: %v", man.ImagePins)
}
if len(man.SecretEnvVars) != 3 {
t.Errorf("secret env-var names: %v (want 3)", man.SecretEnvVars)
}
if len(man.DataKeyEnvVars) != 1 || man.DataKeyEnvVars[0] != "SECRET_KEY" {
t.Errorf("data-key env-vars: %v", man.DataKeyEnvVars)
}
if len(man.DBDumps) != 1 || len(man.VolumeDumps) != 1 {
t.Errorf("dumps enumerated: db=%v vol=%v", man.DBDumps, man.VolumeDumps)
}
// D5: schema 2 + the carried names, so the restore can tell secrets from plain config.
if man.SchemaVersion != 2 {
t.Errorf("schema version = %d, want 2 (D5 units carry secrets)", man.SchemaVersion)
}
if len(man.PortableSecretEnvVars) != 2 {
t.Errorf("portable secret names: %v (want DB_PASSWORD + SECRET_KEY)", man.PortableSecretEnvVars)
}
// The manifest is 0644 — it must record NAMES, never a value.
if s := string(mfData); strings.Contains(s, dbPwVal) || strings.Contains(s, dataKeyVal) {
t.Error("SECRET LEAK: a secret VALUE reached manifest.json (names only)")
}
// app.yaml in the unit must carry the non-secret env AND the portable secrets.
appyPath := filepath.Join(composeDir, "app.yaml")
appy := mustRead(t, appyPath)
if !strings.Contains(appy, "SUBDOMAIN") {
t.Errorf("unit app.yaml missing non-secret env: %s", appy)
}
// THE CONSEQUENCE of D5 at capture time: without these two values on the drive, a guest-less
// restore cannot read the data sitting beside them.
if !strings.Contains(appy, dataKeyVal) {
t.Error("data-encrypting key did NOT travel — a guest-less restore would be impossible")
}
if !strings.Contains(appy, dbPwVal) {
t.Error("DB password did NOT travel — the restored app could not authenticate to its own data")
}
// Secret-bearing ⇒ owner-only.
if fi, err := os.Stat(appyPath); err != nil {
t.Fatal(err)
} else if perm := fi.Mode().Perm(); perm != 0600 {
t.Errorf("unit app.yaml mode = %04o, want 0600 (it carries secrets)", perm)
}
// THE WRONG-OUTCOME CHECK: the withheld class must appear NOWHERE in the unit. This is the half of
// the old secret-free invariant that D5 does not relax.
unitRoot := RecoveryUnitPath(drive, "example")
_ = filepath.WalkDir(unitRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.Contains(mustRead(t, path), withheldVal) {
t.Errorf("SECRET LEAK: withheld admin login %q found in %s", withheldVal, path)
}
return nil
})
}
// TestCaptureRecoveryUnitIdempotent proves the checksum-skip guard: a second capture with unchanged
// config does NOT rewrite the manifest (CreatedAt stable), but a config change DOES.
func TestCaptureRecoveryUnitIdempotent(t *testing.T) {
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive")
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:1\n")
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "ex"), "ex.sql"), "d")
info := RecoveryInfo{StackDir: stackDir, DisplayName: "Ex", ImagePins: []string{"ex/app:1"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "ex"}}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(tmp, "sys"),
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive}, version: "v1"}
manifestPath := RecoveryUnitManifestPath(drive, "ex")
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
first := readManifest(manifestPath)
if first == nil {
t.Fatal("manifest not written")
}
// Second capture, unchanged → skipped (manifest byte-identical incl. CreatedAt).
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
if again := readManifest(manifestPath); again.CreatedAt != first.CreatedAt {
t.Errorf("idempotent capture rewrote manifest: %q -> %q", first.CreatedAt, again.CreatedAt)
}
// Change the compose → must rewrite (config checksum differs).
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:2\n")
m.stackProvider.(*fakeRecoveryProvider).info.ImagePins = []string{"ex/app:2"}
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
changed := readManifest(manifestPath)
if len(changed.ImagePins) != 1 || changed.ImagePins[0] != "ex/app:2" {
t.Errorf("config change not captured: %v", changed.ImagePins)
}
if changed.Checksums["docker-compose.yml"] == first.Checksums["docker-compose.yml"] {
t.Errorf("compose checksum should change after edit")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func mustRead(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}