a52851e79e
The proceed-path for a missing RESETTABLE secret redeployed the app with the secret blank (compose "Defaulting to a blank string" → exit 1, live-hit in the 2026-07-04 drill Phase 5). Now the restore generates a fresh credential instead: - stacks.Manager.GenerateSecretForField: replacement value from the field's catalog generate spec via the deploy flow's generateValue (no logic copied); refuses data-keys (defense-in-depth), spec-less and non-secret fields. - backup.Manager.SetSecretGenerator seam (wired in main.go), consulted in RestoreFromRecoveryUnit AFTER the untouched fail-closed gate, for missing names NOT in DataKeyEnvVars. The generated value rides fullEnv into RecreateStackFromUnit → RedeployFromEnv → SaveAppConfig, so it persists encrypted in the guest app.yaml and round-trips on the next backup/restore (no second write path). reconcileRestoreSecrets stays pure and untouched. - WARNs now discriminate: "generated replacement for X (credential was reset)" vs "X unrecoverable and has no generator — app may fail to start". Values are never logged (asserted in test). - Residual case (documented, not pretended away): if a restored volume tar carries the OLD internal credential hash, the app may still fail auth until a manual in-DB reset — generation fully fixes only the fresh-init case. Companion red-proof: pre-fix behaviour (generation skipped) fails TestRestoreGeneratesMissingResettableSecret on the non-empty DB_PASSWORD assertion (verified, reverted). Data-key gate proven unreachable by generation in TestRestoreGenerationNeverReachesDataKeys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
135 lines
5.5 KiB
Go
135 lines
5.5 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newSecretGenUnit lays out a recovery unit whose manifest names one resettable secret
|
|
// (DB_PASSWORD) and one data-key (SECRET_KEY) — the O4 test fixture.
|
|
func newSecretGenUnit(t *testing.T) (drive string) {
|
|
t.Helper()
|
|
drive = filepath.Join(t.TempDir(), "drive")
|
|
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
|
|
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
|
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
|
|
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}}
|
|
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return drive
|
|
}
|
|
|
|
// TestRestoreGeneratesMissingResettableSecret proves Scenario F: with the data-key recovered but
|
|
// DB_PASSWORD unrecoverable, the restore PROCEEDS and RecreateStackFromUnit receives a NON-EMPTY
|
|
// generated DB_PASSWORD (pre-O4 it was simply absent → compose deployed blank → exit 1). Also
|
|
// proves the generator is consulted for the resettable secret ONLY — never a data-key — and that
|
|
// the generated VALUE never reaches the logs.
|
|
// COMPANION red-proof: reverting to the pre-fix behaviour (no generation) fails the non-empty
|
|
// DB_PASSWORD assertion.
|
|
func TestRestoreGeneratesMissingResettableSecret(t *testing.T) {
|
|
const genValue = "generated-secret-value-do-not-log"
|
|
drive := newSecretGenUnit(t)
|
|
fake := &fakeRecoveryProvider{
|
|
hdd: drive,
|
|
running: true,
|
|
secrets: map[string]string{"SECRET_KEY": "deadbeef"}, // DB_PASSWORD unrecoverable
|
|
}
|
|
var logBuf bytes.Buffer
|
|
m := &Manager{logger: log.New(&logBuf, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
|
stackProvider: fake}
|
|
var genCalls []string
|
|
m.generateSecret = func(stackName, envVar string) (string, bool) {
|
|
genCalls = append(genCalls, envVar)
|
|
return genValue, true
|
|
}
|
|
|
|
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("restore must proceed for a missing RESETTABLE secret: %v", err)
|
|
}
|
|
if fake.gotEnv == nil {
|
|
t.Fatal("recreate was not called")
|
|
}
|
|
if fake.gotEnv["DB_PASSWORD"] != genValue {
|
|
t.Errorf("DB_PASSWORD = %q, want the generated replacement (pre-O4: absent → blank deploy)", fake.gotEnv["DB_PASSWORD"])
|
|
}
|
|
if fake.gotEnv["SECRET_KEY"] != "deadbeef" {
|
|
t.Errorf("recovered data-key must pass through verbatim, got %q", fake.gotEnv["SECRET_KEY"])
|
|
}
|
|
if len(genCalls) != 1 || genCalls[0] != "DB_PASSWORD" {
|
|
t.Errorf("generator consulted for %v, want exactly [DB_PASSWORD] (never data-keys)", genCalls)
|
|
}
|
|
logs := logBuf.String()
|
|
if !strings.Contains(logs, "generated replacement") || !strings.Contains(logs, "DB_PASSWORD") {
|
|
t.Errorf("WARN must name the reset credential; logs:\n%s", logs)
|
|
}
|
|
// Secrets safety: the generated VALUE must never be logged — names only.
|
|
if strings.Contains(logs, genValue) {
|
|
t.Errorf("SECRET LEAK: generated value found in logs:\n%s", logs)
|
|
}
|
|
}
|
|
|
|
// TestRestoreProceedsWhenNoGenerator proves Scenario G: a missing resettable secret with NO
|
|
// generator (seam returns ok=false, or no seam wired) still proceeds — env var absent, loud WARN
|
|
// that the app may fail to start — and never invents a default.
|
|
func TestRestoreProceedsWhenNoGenerator(t *testing.T) {
|
|
run := func(t *testing.T, wire bool) {
|
|
drive := newSecretGenUnit(t)
|
|
fake := &fakeRecoveryProvider{
|
|
hdd: drive,
|
|
running: true,
|
|
secrets: map[string]string{"SECRET_KEY": "deadbeef"},
|
|
}
|
|
var logBuf bytes.Buffer
|
|
m := &Manager{logger: log.New(&logBuf, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
|
stackProvider: fake}
|
|
if wire {
|
|
m.generateSecret = func(string, string) (string, bool) { return "", false } // no spec (Scenario G)
|
|
}
|
|
|
|
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
|
t.Fatalf("restore must still proceed: %v", err)
|
|
}
|
|
if _, present := fake.gotEnv["DB_PASSWORD"]; present {
|
|
t.Errorf("no generator → the secret must stay absent, not be invented: %v", fake.gotEnv)
|
|
}
|
|
logs := logBuf.String()
|
|
if !strings.Contains(logs, "no generator") || !strings.Contains(logs, "may fail to start") || !strings.Contains(logs, "DB_PASSWORD") {
|
|
t.Errorf("upgraded WARN must name the var and the may-fail consequence; logs:\n%s", logs)
|
|
}
|
|
}
|
|
t.Run("generator wired, field has no spec", func(t *testing.T) { run(t, true) })
|
|
t.Run("no generator wired at all", func(t *testing.T) { run(t, false) })
|
|
}
|
|
|
|
// TestRestoreGenerationNeverReachesDataKeys proves the frozen gate is untouched by O4: a missing
|
|
// DATA-KEY still refuses fail-closed BEFORE any generation — the generator is never consulted and
|
|
// the app is never recreated, even with a generator eagerly offering values.
|
|
func TestRestoreGenerationNeverReachesDataKeys(t *testing.T) {
|
|
drive := newSecretGenUnit(t)
|
|
fake := &fakeRecoveryProvider{
|
|
hdd: drive,
|
|
secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing
|
|
}
|
|
m := &Manager{logger: log.New(bytes.NewBuffer(nil), "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
|
stackProvider: fake}
|
|
var genCalls []string
|
|
m.generateSecret = func(_, envVar string) (string, bool) {
|
|
genCalls = append(genCalls, envVar)
|
|
return "eager-value", true
|
|
}
|
|
|
|
if err := m.RestoreFromRecoveryUnit("app"); err == nil {
|
|
t.Fatal("missing data-key must still refuse fail-closed")
|
|
}
|
|
if len(genCalls) != 0 {
|
|
t.Errorf("generator consulted for %v — must be unreachable when the gate refuses", genCalls)
|
|
}
|
|
if fake.gotEnv != nil {
|
|
t.Errorf("recreate must not run on refusal: %v", fake.gotEnv)
|
|
}
|
|
}
|