v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths
Closes R-47. No new agent coupling — MinAgent stays 0.90.0. The replay needs a running DB container, so both restore paths started the WHOLE stack first, giving the application a window to rebuild the very schema objects the dump was about to create. Measured live on 2026-07-19 (H4, DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds before the dump's CREATE INDEX, the replay aborted "already exists" under ON_ERROR_STOP=1, and immich reported schema drift. The data survived only because pg_dump emits COPY before CREATE INDEX. Both paths now open a DB-ONLY window: only the stack's database service(s) come up, the dump is replayed with the app still down, and the full start runs only after the replay exits 0. Fail-closed: a dump with no identifiable DB service refuses BEFORE the first mutation. Every exit from the window still does a best-effort full start, so a failed restore never leaves a box with a database and no application. New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty list — argument-less `up -d` is a full start); RedeployFromEnv split into PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden `up -d` inside the old name is what carried the defect on the local path. 19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed effects, replay-failure bring-up, parser decoys, empty-list refusal); three companion red-proofs run and reverted. 23/23 packages green. Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-47 (v0.153.0) — the two seams the restore paths need in order to replay a DB dump without the
|
||||
// application racing it: a scoped bring-up, and a persist-without-start.
|
||||
|
||||
// newR47Manager builds a Manager with one stack whose .felhom.yml declares a locked data-key and a
|
||||
// plain field, so the persist half's locked-field and encryption behaviour is observable.
|
||||
func newR47Manager(t *testing.T) (*Manager, string) {
|
||||
t.Helper()
|
||||
stackDir := filepath.Join(t.TempDir(), "app")
|
||||
if err := os.MkdirAll(stackDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := `display_name: App
|
||||
deploy_fields:
|
||||
- env_var: SECRET_KEY
|
||||
type: secret
|
||||
locked_after_deploy: true
|
||||
- env_var: SUBDOMAIN
|
||||
type: subdomain
|
||||
- env_var: TIMEZONE
|
||||
type: text
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
encKey: []byte("0123456789abcdef0123456789abcdef"), // 32 bytes → AES-256
|
||||
stacks: map[string]*Stack{
|
||||
"app": {Name: "app", ComposePath: filepath.Join(stackDir, "docker-compose.yml")},
|
||||
},
|
||||
}
|
||||
return m, stackDir
|
||||
}
|
||||
|
||||
// TestStartStackServicesRefusesEmptyList is the whole reason this function is not a thin wrapper.
|
||||
// `docker compose up -d` with no service arguments is a FULL start — the exact behaviour the DB-only
|
||||
// window exists to avoid. So an empty list must be an ERROR, never a silent pass-through: a caller
|
||||
// that computed zero DB services has, by definition, nothing it may safely start.
|
||||
//
|
||||
// The refusal is also asserted to happen WITHOUT reaching compose: it fires for an unknown stack
|
||||
// too, which proves nothing was executed (a real `up` would need a stack dir and a docker daemon).
|
||||
func TestStartStackServicesRefusesEmptyList(t *testing.T) {
|
||||
m, _ := newR47Manager(t)
|
||||
|
||||
for _, svcs := range [][]string{nil, {}} {
|
||||
err := m.StartStackServices("app", svcs)
|
||||
if err == nil {
|
||||
t.Fatalf("an empty service list (%v) must be refused — argument-less `up -d` is a FULL start", svcs)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty service list") {
|
||||
t.Fatalf("refusal must name the cause, got: %v", err)
|
||||
}
|
||||
}
|
||||
// Unknown stack: refused at the lookup, still without touching compose.
|
||||
if err := m.StartStackServices("nope", []string{"db"}); err == nil {
|
||||
t.Fatal("an unknown stack must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistUnitRedeployConfigPersistsWithoutStarting is the split's contract. RedeployFromEnv used
|
||||
// to be persist+start in one call, which is why the local restore path could not put the DB-only
|
||||
// window between them. This asserts the persist half is COMPLETE on its own — app.yaml written with
|
||||
// the deployed marker, the locked field recorded, the secret encrypted at rest and decryptable, and
|
||||
// the in-memory stack flipped to deployed — so RedeployFromEnv's public behaviour is unchanged by
|
||||
// being expressed as this function plus the untouched up-and-report tail.
|
||||
func TestPersistUnitRedeployConfigPersistsWithoutStarting(t *testing.T) {
|
||||
m, stackDir := newR47Manager(t)
|
||||
const secret = "s3cr3t-data-key-value"
|
||||
env := map[string]string{"SECRET_KEY": secret, "SUBDOMAIN": "app", "TIMEZONE": "Europe/Budapest", "HDD_PATH": "/mnt/drv"}
|
||||
|
||||
if err := m.PersistUnitRedeployConfig("app", env); err != nil {
|
||||
t.Fatalf("PersistUnitRedeployConfig: %v", err)
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(stackDir, "app.yaml")
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("app.yaml was not written — the persist half is incomplete: %v", err)
|
||||
}
|
||||
// Secrets safety: the plaintext must not be at rest in app.yaml.
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatal("SECRET LEAK: the secret value is stored in plaintext in app.yaml")
|
||||
}
|
||||
|
||||
got := LoadAppConfigDecrypted(stackDir, m.encKey)
|
||||
if got == nil {
|
||||
t.Fatal("app.yaml does not load back")
|
||||
}
|
||||
if !got.Deployed || got.DeployedAt == "" {
|
||||
t.Errorf("app must be marked deployed with a timestamp, got deployed=%v at=%q", got.Deployed, got.DeployedAt)
|
||||
}
|
||||
if got.Env["SECRET_KEY"] != secret {
|
||||
t.Errorf("SECRET_KEY did not round-trip through encryption, got %q", got.Env["SECRET_KEY"])
|
||||
}
|
||||
if got.Env["SUBDOMAIN"] != "app" || got.Env["HDD_PATH"] != "/mnt/drv" {
|
||||
t.Errorf("non-secret env did not persist verbatim: %v", got.Env)
|
||||
}
|
||||
// Secrets and subdomains are implicitly locked-after-deploy; a plain text field is not. Recording
|
||||
// exactly those is part of the persist half, and losing it in the split would silently unlock a
|
||||
// data-key field on the next config edit.
|
||||
if !reflect.DeepEqual(got.LockedFields, []string{"SECRET_KEY", "SUBDOMAIN"}) {
|
||||
t.Errorf("locked fields = %v, want [SECRET_KEY SUBDOMAIN] (TIMEZONE must NOT be locked)", got.LockedFields)
|
||||
}
|
||||
|
||||
// In-memory state must agree, because StartStack (the caller's next step) reads it.
|
||||
s, ok := m.GetStack("app")
|
||||
if !ok || !s.Deployed {
|
||||
t.Fatalf("in-memory stack not marked deployed (ok=%v), so the follow-up start would treat it as undeployed", ok)
|
||||
}
|
||||
if s.AppConfig == nil || s.AppConfig.Env["SECRET_KEY"] == "" {
|
||||
t.Error("in-memory AppConfig not populated by the persist half")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistUnitRedeployConfigRejectsUnknownStack keeps the failure direction the same as the
|
||||
// unsplit function's: an unknown stack is an error, not a silently-created app.yaml somewhere.
|
||||
func TestPersistUnitRedeployConfigRejectsUnknownStack(t *testing.T) {
|
||||
m, _ := newR47Manager(t)
|
||||
if err := m.PersistUnitRedeployConfig("nope", map[string]string{"A": "b"}); err == nil {
|
||||
t.Fatal("an unknown stack must be refused")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user