Files
felhom-controller/controller/internal/stacks/start_services_test.go
T
admin 78ff991f1c 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.
2026-07-20 17:01:52 +02:00

135 lines
5.5 KiB
Go

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")
}
}