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:
@@ -3,10 +3,123 @@ package backup
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captureFixtureUnit writes a real stack tree, runs the REAL CaptureRecoveryUnit over it, and returns
|
||||
// the drive (namespace root) holding the resulting unit.
|
||||
//
|
||||
// Fixtures come from a CAPTURED unit rather than hand-written YAML deliberately: the capture side and
|
||||
// the restore side must meet at real bytes on a real filesystem, so a change to the on-disk shape
|
||||
// (header text, key ordering, the non-secret/portable split) cannot pass by having a test agree with
|
||||
// itself. Everything from buildUnitAppYaml through readUnitEnv is production code here.
|
||||
func captureFixtureUnit(t *testing.T, portable map[string]string) (drive string) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
stackDir := filepath.Join(tmp, "stack")
|
||||
drive = filepath.Join(tmp, "drive")
|
||||
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\n db:\n image: postgres:16\n")
|
||||
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: App\n")
|
||||
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
||||
|
||||
var names []string
|
||||
for _, n := range []string{"DB_PASSWORD", "SECRET_KEY"} {
|
||||
if _, ok := portable[n]; ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
info := RecoveryInfo{
|
||||
StackDir: stackDir,
|
||||
DisplayName: "App",
|
||||
ImagePins: []string{"example/app:1"},
|
||||
NonSecretEnv: map[string]string{"SUBDOMAIN": "trips"},
|
||||
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
|
||||
DataKeyEnvVars: []string{"SECRET_KEY"},
|
||||
PortableSecretEnvVars: names,
|
||||
PortableSecrets: portable,
|
||||
}
|
||||
m := &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(tmp, "system"),
|
||||
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
|
||||
version: "vtest",
|
||||
}
|
||||
if err := m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("capture fixture: %v", err)
|
||||
}
|
||||
return drive
|
||||
}
|
||||
|
||||
// TestRestoreFromRecoveryUnitWithGuestAbsent is D5's entire claim, as a test rather than a
|
||||
// description: a Tier-1/2 restore SUCCEEDS when the guest's app.yaml is unavailable.
|
||||
//
|
||||
// SEAM (R-125): injection is at Manager.stackProvider only — i.e. the docker/compose operations and the
|
||||
// guest's app.yaml decrypt. RecoverStackSecrets returning nil IS the guest being gone: it is exactly
|
||||
// what the real adapter returns when the stack or its app.yaml cannot be read (main.go
|
||||
// GetStack/LoadAppConfigDecrypted nil paths). Everything under test is production code: the unit was
|
||||
// written by the real CaptureRecoveryUnit, read back by the real readUnitEnv, and reconciled by the
|
||||
// real reconcileRestoreSecrets.
|
||||
func TestRestoreFromRecoveryUnitWithGuestAbsent(t *testing.T) {
|
||||
const (
|
||||
dataKey = "deadbeefdeadbeef"
|
||||
dbPw = "pw-from-the-drive"
|
||||
)
|
||||
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": dbPw, "SECRET_KEY": dataKey})
|
||||
|
||||
// The guest is GONE: no secrets recoverable from it at all.
|
||||
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
||||
|
||||
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("restore must succeed from the drive alone, got: %v", err)
|
||||
}
|
||||
if fake.gotEnv == nil {
|
||||
t.Fatal("recreate was not called — the restore did not reach the redeploy")
|
||||
}
|
||||
// The consequence: the app is redeployed with the key that decrypts the data beside it.
|
||||
if fake.gotEnv["SECRET_KEY"] != dataKey {
|
||||
t.Errorf("data-encrypting key not recovered from the unit: %q", fake.gotEnv["SECRET_KEY"])
|
||||
}
|
||||
if fake.gotEnv["DB_PASSWORD"] != dbPw {
|
||||
t.Errorf("DB password not recovered from the unit: %q", fake.gotEnv["DB_PASSWORD"])
|
||||
}
|
||||
if fake.gotEnv["SUBDOMAIN"] != "trips" {
|
||||
t.Errorf("plain config lost: %v", fake.gotEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed proves D5 did not soften the gate: with the
|
||||
// data key in NEITHER the unit nor the guest, the restore still REFUSES and mutates nothing.
|
||||
func TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed(t *testing.T) {
|
||||
// Unit carries only the DB password — the data key is absent from both sources.
|
||||
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": "pw"})
|
||||
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
||||
|
||||
err := m.RestoreFromRecoveryUnit("app")
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed refusal when the data key is in neither source")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SECRET_KEY") {
|
||||
t.Errorf("refusal should name the missing data key, got: %v", err)
|
||||
}
|
||||
if fake.gotEnv != nil {
|
||||
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
|
||||
}
|
||||
if fake.stopped {
|
||||
t.Error("the live app must not be stopped when the restore refuses")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
|
||||
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
|
||||
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
|
||||
@@ -15,7 +128,7 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
|
||||
newUnit := func(t *testing.T) (drive string) {
|
||||
tmp := t.TempDir()
|
||||
drive = filepath.Join(tmp, "drive")
|
||||
// stripped (secret-free) app.yaml in the unit
|
||||
// A schema-1 unit: no portable secrets, so the guest is the only source (pre-D5 behaviour).
|
||||
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
|
||||
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
||||
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
|
||||
@@ -49,6 +162,21 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema-1 unit still restores from the guest — no regression", func(t *testing.T) {
|
||||
// An old unit carries nothing; the guest must still be able to supply everything.
|
||||
drive := newUnit(t)
|
||||
fake := &fakeRecoveryProvider{hdd: drive, running: true,
|
||||
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}}
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
|
||||
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("a pre-D5 unit must still restore: %v", err)
|
||||
}
|
||||
if fake.gotEnv["SECRET_KEY"] != "deadbeef" {
|
||||
t.Errorf("guest fallback lost the data key: %v", fake.gotEnv)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
|
||||
drive := newUnit(t)
|
||||
fake := &fakeRecoveryProvider{
|
||||
@@ -67,13 +195,82 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestReconcileRestoreSecretsPrecedence pins the D5 precedence rule in BOTH directions. An undefined
|
||||
// precedence between two sources of a decryption key is a data-loss bug waiting for its first
|
||||
// disagreement, so this is not a style question.
|
||||
//
|
||||
// The UNIT wins: its secrets were captured in the same run as the dumps beside them, so the unit's
|
||||
// value is the one that matches the data about to be restored. The guest's value is merely the most
|
||||
// recent — and a rotated key does not decrypt data encrypted with the old one.
|
||||
func TestReconcileRestoreSecretsPrecedence(t *testing.T) {
|
||||
nonSecret := map[string]string{"SUBDOMAIN": "trips"}
|
||||
names := []string{"DB_PASSWORD", "SECRET_KEY"}
|
||||
dataKeys := []string{"SECRET_KEY"}
|
||||
|
||||
t.Run("both sources disagree — the UNIT wins", func(t *testing.T) {
|
||||
unit := map[string]string{"DB_PASSWORD": "unit-pw", "SECRET_KEY": "unit-key"}
|
||||
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("missing: %v", missing)
|
||||
}
|
||||
if full["SECRET_KEY"] != "unit-key" {
|
||||
t.Errorf("data key: got %q, want the UNIT's value (it matches the restored data)", full["SECRET_KEY"])
|
||||
}
|
||||
if full["DB_PASSWORD"] != "unit-pw" {
|
||||
t.Errorf("DB password: got %q, want the UNIT's value (it matches the restored data dir hash)", full["DB_PASSWORD"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unit silent — the GUEST fills in", func(t *testing.T) {
|
||||
// The withheld class (admin logins) is never in the unit, so this direction must work too.
|
||||
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
|
||||
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest, names, dataKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if full["SECRET_KEY"] != "guest-key" || full["DB_PASSWORD"] != "guest-pw" {
|
||||
t.Errorf("guest fallback not applied: %v", full)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unit present but EMPTY for a name — the guest fills in", func(t *testing.T) {
|
||||
// An empty value is not a value; it must not shadow a good one from the guest.
|
||||
unit := map[string]string{"SECRET_KEY": ""}
|
||||
guest := map[string]string{"SECRET_KEY": "guest-key", "DB_PASSWORD": "guest-pw"}
|
||||
full, _, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("an empty unit value must fall through to the guest, got: %v", err)
|
||||
}
|
||||
if full["SECRET_KEY"] != "guest-key" {
|
||||
t.Errorf("empty unit value shadowed the guest: %q", full["SECRET_KEY"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a portable secret never shadows plain config", func(t *testing.T) {
|
||||
// GetStackRecoveryInfo keeps the two sets disjoint; if that ever breaks, the merge order in
|
||||
// buildUnitAppYaml decides silently. Pin the intended outcome.
|
||||
full, _, err := reconcileRestoreSecrets(map[string]string{"DB_PASSWORD": "should-not-win"},
|
||||
map[string]string{"DB_PASSWORD": "unit-pw"}, nil, []string{"DB_PASSWORD"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if full["DB_PASSWORD"] != "unit-pw" {
|
||||
t.Errorf("the secret source must win over a stray non-secret entry: %q", full["DB_PASSWORD"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
|
||||
func TestReconcileRestoreSecrets(t *testing.T) {
|
||||
nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}
|
||||
|
||||
t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
|
||||
recovered := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
|
||||
guest := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
||||
[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -87,9 +284,9 @@ func TestReconcileRestoreSecrets(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data_key missing — FAIL CLOSED (refuse)", func(t *testing.T) {
|
||||
recovered := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
|
||||
full, _, err := reconcileRestoreSecrets(nonSecret, recovered,
|
||||
t.Run("data_key missing from BOTH sources — FAIL CLOSED (refuse)", func(t *testing.T) {
|
||||
guest := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
|
||||
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
||||
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
|
||||
@@ -99,17 +296,34 @@ func TestReconcileRestoreSecrets(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data_key empty value — FAIL CLOSED", func(t *testing.T) {
|
||||
recovered := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
|
||||
_, _, err := reconcileRestoreSecrets(nonSecret, recovered, []string{"SECRET_KEY"}, []string{"SECRET_KEY"})
|
||||
t.Run("data_key empty in both — FAIL CLOSED", func(t *testing.T) {
|
||||
guest := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
|
||||
_, _, err := reconcileRestoreSecrets(nonSecret, map[string]string{"SECRET_KEY": ""}, guest,
|
||||
[]string{"SECRET_KEY"}, []string{"SECRET_KEY"})
|
||||
if err == nil {
|
||||
t.Fatal("empty data-key value must fail closed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data_key recovered from the UNIT — no refusal", func(t *testing.T) {
|
||||
// The D5 case: the guest is gone but the unit carries the key, so the gate must NOT fire.
|
||||
unit := map[string]string{"SECRET_KEY": "deadbeef", "DB_PASSWORD": "pw"}
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, nil,
|
||||
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
||||
if err != nil {
|
||||
t.Fatalf("the unit's data key must satisfy the gate: %v", err)
|
||||
}
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("nothing should be missing: %v", missing)
|
||||
}
|
||||
if full["SECRET_KEY"] != "deadbeef" {
|
||||
t.Errorf("data key wrong: %v", full)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
|
||||
recovered := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
|
||||
guest := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
|
||||
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
|
||||
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
|
||||
if err != nil {
|
||||
t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
|
||||
@@ -125,3 +339,38 @@ func TestReconcileRestoreSecrets(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestReadUnitEnvSplitsByManifest proves the split is driven by the manifest's portable names, and that
|
||||
// a schema-1 unit (no names) degrades to "everything is plain config" rather than losing entries.
|
||||
func TestReadUnitEnvSplitsByManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.yaml")
|
||||
mustWrite(t, path, "deployed: true\nenv:\n SUBDOMAIN: trips\n DB_PASSWORD: pw\n SECRET_KEY: key\n")
|
||||
|
||||
t.Run("named secrets land in unitSecrets, the rest in nonSecret", func(t *testing.T) {
|
||||
nonSecret, unitSecrets := readUnitEnv(path, []string{"DB_PASSWORD", "SECRET_KEY"})
|
||||
if nonSecret["SUBDOMAIN"] != "trips" || len(nonSecret) != 1 {
|
||||
t.Errorf("nonSecret = %v, want only SUBDOMAIN", nonSecret)
|
||||
}
|
||||
if unitSecrets["DB_PASSWORD"] != "pw" || unitSecrets["SECRET_KEY"] != "key" || len(unitSecrets) != 2 {
|
||||
t.Errorf("unitSecrets = %v, want the two named secrets", unitSecrets)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema-1 (no portable names) — everything is plain config, nothing lost", func(t *testing.T) {
|
||||
nonSecret, unitSecrets := readUnitEnv(path, nil)
|
||||
if len(unitSecrets) != 0 {
|
||||
t.Errorf("a schema-1 unit carries no secrets, got %v", unitSecrets)
|
||||
}
|
||||
if len(nonSecret) != 3 {
|
||||
t.Errorf("nonSecret should keep every entry, got %v", nonSecret)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent file — empty maps, no panic", func(t *testing.T) {
|
||||
nonSecret, unitSecrets := readUnitEnv(filepath.Join(dir, "nope.yaml"), []string{"DB_PASSWORD"})
|
||||
if len(nonSecret) != 0 || len(unitSecrets) != 0 {
|
||||
t.Errorf("want empty maps, got %v / %v", nonSecret, unitSecrets)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user