fix(backup): O4 — generate a replacement for unrecoverable resettable secrets on restore
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
This commit is contained in:
@@ -827,6 +827,42 @@ func generateValue(spec string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecretForField generates a replacement value for a stack's RESETTABLE secret deploy-field
|
||||
// (O4: the restore-from-unit path uses this — via backup.SetSecretGenerator — when a resettable
|
||||
// secret cannot be recovered from the guest's app.yaml, so the app redeploys with a fresh credential
|
||||
// instead of a blank one that fails compose-up).
|
||||
//
|
||||
// Returns ok=false when the field is unknown, has no generator spec, or — deliberately — is a
|
||||
// DATA-ENCRYPTING key: data-keys are NEVER generated (regenerating one would render stored data
|
||||
// unreadable; the restore's fail-closed gate refuses before this point, this is defense-in-depth).
|
||||
// The generated VALUE is never logged — names only.
|
||||
func (m *Manager) GenerateSecretForField(stackName, envVar string) (string, bool) {
|
||||
s, ok := m.GetStack(stackName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
meta := LoadMetadata(filepath.Dir(s.ComposePath))
|
||||
for _, f := range meta.DeployFields {
|
||||
if f.EnvVar != envVar {
|
||||
continue
|
||||
}
|
||||
if f.DataKey {
|
||||
m.logger.Printf("[WARN] [stacks] GenerateSecretForField(%s/%s): refusing — field is a data-encrypting key", stackName, envVar)
|
||||
return "", false
|
||||
}
|
||||
if (f.Type != "secret" && f.Type != "password") || f.Generate == "" {
|
||||
return "", false
|
||||
}
|
||||
value, err := generateValue(f.Generate)
|
||||
if err != nil || value == "" {
|
||||
m.logger.Printf("[ERROR] [stacks] GenerateSecretForField(%s/%s): generator %q failed: %v", stackName, envVar, f.Generate, err)
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// InjectMissingFields checks deployed stacks for new deploy_fields that are not
|
||||
// yet in app.yaml and auto-generates values for secret/domain fields.
|
||||
// Called after sync (for updated stacks) and on startup (for all deployed stacks).
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newSecretGenManager builds a Manager with one stack whose .felhom.yml declares the O4 test
|
||||
// fields: a generatable resettable secret, a data-key, and a spec-less secret.
|
||||
func newSecretGenManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
stackDir := filepath.Join(t.TempDir(), "app")
|
||||
if err := os.MkdirAll(stackDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := `display_name: App
|
||||
deploy_fields:
|
||||
- env_var: DB_PASSWORD
|
||||
type: secret
|
||||
generate: "password:24"
|
||||
- env_var: SECRET_KEY
|
||||
type: secret
|
||||
generate: "hex:32"
|
||||
data_key: true
|
||||
- env_var: ADMIN_TOKEN
|
||||
type: secret
|
||||
- env_var: SUBDOMAIN
|
||||
type: subdomain
|
||||
default: app
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
stacks: map[string]*Stack{
|
||||
"app": {Name: "app", ComposePath: filepath.Join(stackDir, "docker-compose.yml")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSecretForField covers the O4 generator seam's contract: spec-conformant values for
|
||||
// resettable secrets, and REFUSAL for data-keys (frozen fail-closed territory), spec-less fields,
|
||||
// non-secret fields, and unknown stacks/vars.
|
||||
func TestGenerateSecretForField(t *testing.T) {
|
||||
m := newSecretGenManager(t)
|
||||
|
||||
t.Run("resettable secret with spec → spec-conformant value", func(t *testing.T) {
|
||||
v, ok := m.GenerateSecretForField("app", "DB_PASSWORD")
|
||||
if !ok {
|
||||
t.Fatal("expected generation for DB_PASSWORD (generate: password:24)")
|
||||
}
|
||||
if len(v) != 24 || !regexp.MustCompile(`^[A-Za-z0-9]+$`).MatchString(v) {
|
||||
t.Errorf("value does not conform to password:24 (len=%d)", len(v))
|
||||
}
|
||||
// Distinct per call (crypto/rand-backed, not a constant).
|
||||
if v2, _ := m.GenerateSecretForField("app", "DB_PASSWORD"); v2 == v {
|
||||
t.Error("two generations returned the same value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data-key → REFUSED even with a generate spec", func(t *testing.T) {
|
||||
if v, ok := m.GenerateSecretForField("app", "SECRET_KEY"); ok || v != "" {
|
||||
t.Error("a data-encrypting key must NEVER be generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no generate spec → refused", func(t *testing.T) {
|
||||
if _, ok := m.GenerateSecretForField("app", "ADMIN_TOKEN"); ok {
|
||||
t.Error("spec-less secret must not be generated (Scenario G: proceed-with-warn instead)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-secret field / unknown var / unknown stack → refused", func(t *testing.T) {
|
||||
if _, ok := m.GenerateSecretForField("app", "SUBDOMAIN"); ok {
|
||||
t.Error("non-secret field must not be generated")
|
||||
}
|
||||
if _, ok := m.GenerateSecretForField("app", "NOPE"); ok {
|
||||
t.Error("unknown env var must not be generated")
|
||||
}
|
||||
if _, ok := m.GenerateSecretForField("ghost", "DB_PASSWORD"); ok {
|
||||
t.Error("unknown stack must not be generated")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user