| index eb9ad4e20..3f24130df 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -113,17 +113,38 @@ func (l *fakeLease) Replicate(_ context.Context, _ string, syncInterval time.Dur |
| } |
| return l.rep, nil |
| } |
| -func (l *fakeLease) Release(context.Context) error { return nil } |
| + |
| +// Release counts on the backend: every doWake bail-out must give the lease |
| +// back exactly once, and the only way to assert that from outside is to count |
| +// the calls the wake made. |
| +func (l *fakeLease) Release(context.Context) error { |
| + if l.b != nil { |
| + l.b.mu.Lock() |
| + l.b.releases++ |
| + l.b.mu.Unlock() |
| + } |
| + return nil |
| +} |
| |
| type fakeBackend struct { |
| mu sync.Mutex |
| acquires int |
| restores int |
| starts int |
| + seeds int |
| + releases int // lease releases — the failure paths' assertion |
| acquireErr error |
| - startGate chan struct{} // when non-nil, Start() blocks until closed |
| - reps []*fakeRep |
| - procs []*fakeProc |
| + // restoreFresh is what Restore reports: a first boot (local DB absent AND |
| + // no replica). False by default, so every pre-seed test doubles as the |
| + // "a restored instance is never seeded" case. |
| + restoreFresh bool |
| + restoreErr error |
| + seedErr error |
| + seedRoutes []registry.Route |
| + startErr error |
| + startGate chan struct{} // when non-nil, Start() blocks until closed |
| + reps []*fakeRep |
| + procs []*fakeProc |
| // syncIntervals records, per wake, the replication-class tick doWake |
| // threads into Replicate() — the ladder's seam assertion. |
| syncIntervals []time.Duration |
| @@ -176,11 +197,21 @@ func (b *fakeBackend) Acquire(context.Context, registry.Route) (Lease, error) { |
| return &fakeLease{rep: rep, b: b}, nil |
| } |
| |
| -func (b *fakeBackend) Restore(context.Context, registry.Route) error { |
| +func (b *fakeBackend) Restore(context.Context, registry.Route) (bool, error) { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| b.restores++ |
| - return nil |
| + b.order = append(b.order, "restore") |
| + return b.restoreFresh, b.restoreErr |
| +} |
| + |
| +func (b *fakeBackend) Seed(_ context.Context, rt registry.Route) error { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.seeds++ |
| + b.order = append(b.order, "seed") |
| + b.seedRoutes = append(b.seedRoutes, rt) |
| + return b.seedErr |
| } |
| |
| func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, error) { |
| @@ -189,9 +220,13 @@ func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, erro |
| b.order = append(b.order, "start:instance") |
| gate := b.startGate |
| fn := b.execVerFn |
| + startErr := b.startErr |
| proc := &fakeProc{label: "instance", rec: b} |
| b.procs = append(b.procs, proc) |
| b.mu.Unlock() |
| + if startErr != nil { |
| + return nil, "", startErr |
| + } |
| if gate != nil { |
| <-gate |
| } |
| @@ -261,6 +296,18 @@ func (b *fakeBackend) counts() (acquires, restores, starts int) { |
| return b.acquires, b.restores, b.starts |
| } |
| |
| +func (b *fakeBackend) seedCount() int { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.seeds |
| +} |
| + |
| +func (b *fakeBackend) releaseCount() int { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.releases |
| +} |
| + |
| func (b *fakeBackend) lastProc() *fakeProc { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| @@ -569,6 +616,157 @@ func TestActivatorWakeThreadsReplicationClass(t *testing.T) { |
| } |
| } |
| |
| +// --- first-wake seed --------------------------------------------------------- |
| +// |
| +// The seam spec §6 pins: fresh means the local DB was absent AND no replica |
| +// existed — a first boot, never a restore — and the route's seed runs strictly |
| +// between Restore and Start, i.e. before the first health probe ever reaches |
| +// the instance. A brand-new instance is therefore born seeded, and a restored |
| +// one is never re-seeded over its own data. |
| + |
| +func TestActivatorSeedsFreshInstanceBeforeStart(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreFresh = true |
| + rt := testRoute("fresh.example.com") |
| + rt.Seed = `["seed"]` |
| + |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + if got := b.seedCount(); got != 1 { |
| + t.Fatalf("seed calls = %d, want 1 on a fresh boot with a seed", got) |
| + } |
| + ev := b.events() |
| + r, s, st := indexOf(ev, "restore"), indexOf(ev, "seed"), indexOf(ev, "start:instance") |
| + if r == -1 || s == -1 || st == -1 || !(r < s && s < st) { |
| + t.Fatalf("seed order wrong: %v (want restore < seed < start:instance — seeded before the first health probe)", ev) |
| + } |
| + b.mu.Lock() |
| + seededHost := b.seedRoutes[0].Host |
| + b.mu.Unlock() |
| + if seededHost != rt.Host { |
| + t.Fatalf("seeded host = %q, want %q", seededHost, rt.Host) |
| + } |
| +} |
| + |
| +// A restore is never a first boot: the instance's data came back from the |
| +// bucket, and running the seed over it is exactly the corruption this fresh |
| +// flag exists to prevent. |
| +func TestActivatorSkipsSeedWhenRestored(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreFresh = false |
| + rt := testRoute("restored.example.com") |
| + rt.Seed = `["seed"]` |
| + |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + if got := b.seedCount(); got != 0 { |
| + t.Fatalf("seed calls = %d on a restored instance, want 0", got) |
| + } |
| +} |
| + |
| +// No seed declared: a fresh boot just boots (every route added before this |
| +// feature, and every app that creates its own schema). |
| +func TestActivatorSkipsSeedWhenRouteHasNone(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreFresh = true |
| + rt := testRoute("noseed.example.com") // rt.Seed == "" |
| + |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + if got := b.seedCount(); got != 0 { |
| + t.Fatalf("seed calls = %d for a route with no seed, want 0", got) |
| + } |
| +} |
| + |
| +// A failed seed fails the WHOLE wake — a half-seeded instance must not serve — |
| +// and gives the lease back exactly once, the Start-failure precedent. Nothing |
| +// is spawned, and the entry is gone so the next request retries from scratch. |
| +func TestActivatorSeedFailureFailsWakeAndReleasesLease(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreFresh = true |
| + b.seedErr = errors.New("seed exited 1") |
| + rt := testRoute("badseed.example.com") |
| + rt.Seed = `["seed"]` |
| + |
| + _, err := a.beginRequest(context.Background(), rt) |
| + if err == nil { |
| + t.Fatal("wake succeeded despite a failing seed") |
| + } |
| + if !strings.Contains(err.Error(), "seed "+rt.Host) { |
| + t.Fatalf("wake error = %q, want it to name the seed and the host", err) |
| + } |
| + if _, _, starts := b.counts(); starts != 0 { |
| + t.Fatalf("instance was spawned after a failed seed (starts=%d)", starts) |
| + } |
| + if got := b.releaseCount(); got != 1 { |
| + t.Fatalf("lease releases = %d after a failed seed, want exactly 1", got) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("failed wake left an entry behind") |
| + } |
| +} |
| + |
| +// The other two doWake bail-outs release the lease exactly once too — the |
| +// property the seed step slots into the middle of. |
| +func TestActivatorWakeFailuresReleaseLeaseOnce(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + inject func(*fakeBackend) |
| + want string |
| + }{ |
| + {"restore", func(b *fakeBackend) { b.restoreErr = errors.New("s3 down") }, "restore "}, |
| + {"start", func(b *fakeBackend) { b.startErr = errors.New("never healthy") }, "start "}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + tc.inject(b) |
| + rt := testRoute(tc.name + "-fail.example.com") |
| + |
| + _, err := a.beginRequest(context.Background(), rt) |
| + if err == nil { |
| + t.Fatalf("wake succeeded despite a failing %s", tc.name) |
| + } |
| + if !strings.Contains(err.Error(), tc.want+rt.Host) { |
| + t.Fatalf("wake error = %q, want it to name %q and the host", err, tc.want) |
| + } |
| + if got := b.releaseCount(); got != 1 { |
| + t.Fatalf("lease releases = %d after a failed %s, want exactly 1", got, tc.name) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("failed wake left an entry behind") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A restore failure never reaches the seed: seeding a host whose data we |
| +// couldn't materialize is precisely the "half-seeded instance" case. |
| +func TestActivatorRestoreFailureSkipsSeed(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.restoreErr = errors.New("s3 down") |
| + rt := testRoute("restorefail.example.com") |
| + rt.Seed = `["seed"]` |
| + |
| + if _, err := a.beginRequest(context.Background(), rt); err == nil { |
| + t.Fatal("wake succeeded despite a failing restore") |
| + } |
| + if got := b.seedCount(); got != 0 { |
| + t.Fatalf("seed calls = %d after a failed restore, want 0", got) |
| + } |
| +} |
| + |
| // After the idle window with nothing in flight, the sweeper hibernates the |
| // instance: process stopped, replication closed (final checkpoint + lease |
| // release), entry forgotten; the next request wakes it afresh. |
| @@ -2481,3 +2679,319 @@ func TestExecBackendStartPurgesHostCacheOnSpawn(t *testing.T) { |
| t.Fatal("another host's cached responses must survive a spawn") |
| } |
| } |
| + |
| +// --- ExecBackend: fresh detection + seed -------------------------------------- |
| + |
| +// stageSeedScript stages host's live binary as a shell script with a chosen |
| +// body — the seed tests need exits and stderr the recording scripts above |
| +// don't produce. Returns nothing: each test knows the files its body writes. |
| +func stageSeedScript(t *testing.T, b *ExecBackend, host, ver, body string) { |
| + t.Helper() |
| + vdir := filepath.Join(b.OptDir, "versions", ver) |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"+body), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// The warm cache — the local DB still on disk from the last sleep — is never a |
| +// first boot, and the bucket isn't even consulted. |
| +func TestExecBackendRestoreWarmCacheIsNotFresh(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "warm.example.com" |
| + if err := os.WriteFile(b.defaultDBPath(host), []byte("db"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) { |
| + called = true |
| + return false, nil |
| + }) |
| + |
| + fresh, err := b.Restore(context.Background(), registry.Route{Host: host, Kind: "instance"}) |
| + if err != nil { |
| + t.Fatalf("Restore: %v", err) |
| + } |
| + if fresh { |
| + t.Fatal("warm cache reported fresh — the seed would run over live data") |
| + } |
| + if called { |
| + t.Fatal("warm cache consulted the bucket") |
| + } |
| +} |
| + |
| +// DB absent but a replica exists: the data came back from S3, so this is a |
| +// restore, not a first boot. |
| +func TestExecBackendRestoreFromReplicaIsNotFresh(t *testing.T) { |
| + b := testExecBackend(t) |
| + swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) { |
| + return true, nil |
| + }) |
| + |
| + fresh, err := b.Restore(context.Background(), registry.Route{Host: "restored.example.com", Kind: "instance"}) |
| + if err != nil { |
| + t.Fatalf("Restore: %v", err) |
| + } |
| + if fresh { |
| + t.Fatal("a restored instance reported fresh — a seed here would overwrite recovered data") |
| + } |
| +} |
| + |
| +// DB absent AND no replica in the bucket: the one true first boot. |
| +func TestExecBackendRestoreNoReplicaIsFresh(t *testing.T) { |
| + b := testExecBackend(t) |
| + swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) { |
| + return false, nil |
| + }) |
| + |
| + fresh, err := b.Restore(context.Background(), registry.Route{Host: "brand-new.example.com", Kind: "instance"}) |
| + if err != nil { |
| + t.Fatalf("Restore: %v", err) |
| + } |
| + if !fresh { |
| + t.Fatal("a never-replicated host did not report fresh — it would never be seeded") |
| + } |
| +} |
| + |
| +// A restore ERROR is not a first boot: "we couldn't tell" must never be |
| +// reported as "there was nothing there". |
| +func TestExecBackendRestoreErrorIsNotFresh(t *testing.T) { |
| + b := testExecBackend(t) |
| + swapRestoreReplica(t, func(context.Context, string, string, string, string) (bool, error) { |
| + return false, errors.New("s3: connection refused") |
| + }) |
| + |
| + fresh, err := b.Restore(context.Background(), registry.Route{Host: "unreachable.example.com", Kind: "instance"}) |
| + if err == nil { |
| + t.Fatal("Restore swallowed the replica error") |
| + } |
| + if fresh { |
| + t.Fatal("a failed restore reported fresh") |
| + } |
| +} |
| + |
| +// swapRestoreReplica points the replica-restore seam at a stub for one test. |
| +func swapRestoreReplica(t *testing.T, fn func(ctx context.Context, dbPath, bucket, prefix, region string) (bool, error)) { |
| + t.Helper() |
| + prev := restoreReplica |
| + restoreReplica = fn |
| + t.Cleanup(func() { restoreReplica = prev }) |
| +} |
| + |
| +// The seed contract, end to end through a real exec: the app's declared argv |
| +// followed by --db <path>, under the instance's own merged env layers, with |
| +// the agent's CARLOS_* vars scrubbed and the purge/control vars (Start's, not |
| +// the seed's) absent. |
| +func TestExecBackendSeedRunsDeclaredArgvWithDBPath(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes) |
| + host := "seeded.example.com" |
| + argvPath, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + |
| + cfgEnv := "SHARED=bucket\nBUCKET_ONLY=1\n" |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte(cfgEnv), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ownEnv := "SHARED=instance\nCARLOS_ADMIN_TOKEN=real\n" |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".env"), []byte(ownEnv), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_HIBERNATE_BUCKET", "agent-only") |
| + |
| + rt := registry.Route{Host: host, Kind: "instance", Seed: `["seed","--fixtures"]`} |
| + if err := b.Seed(context.Background(), rt); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + |
| + gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) |
| + wantArgv := []string{"seed", "--fixtures", "--db", b.defaultDBPath(host)} |
| + if !slices.Equal(gotArgv, wantArgv) { |
| + t.Fatalf("seed argv = %v, want %v", gotArgv, wantArgv) |
| + } |
| + |
| + child := map[string]string{} |
| + for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + child[k] = v |
| + } |
| + } |
| + if got := child["SHARED"]; got != "instance" { |
| + t.Fatalf("seed SHARED = %q, want %q — the instance's own env must win over bucket config", got, "instance") |
| + } |
| + if got := child["BUCKET_ONLY"]; got != "1" { |
| + t.Fatalf("seed BUCKET_ONLY = %q, want %q — bucket config must flow through", got, "1") |
| + } |
| + if got := child["CARLOS_ADMIN_TOKEN"]; got != "real" { |
| + t.Fatalf("seed CARLOS_ADMIN_TOKEN = %q, want %q — the seed gets the instance's own env file", got, "real") |
| + } |
| + if _, ok := child["CARLOS_HIBERNATE_BUCKET"]; ok { |
| + t.Fatal("the agent's own CARLOS_* config leaked into the seed process") |
| + } |
| + if _, ok := child["CARLOS_CONTROL_SOCKET"]; ok { |
| + t.Fatal("seed was handed a control socket — the purge channel belongs to the serving process, not a one-shot seed") |
| + } |
| + if _, ok := child["CARLOS_PURGE"]; ok { |
| + t.Fatal("seed was told a purge channel exists — that layering belongs to Start") |
| + } |
| +} |
| + |
| +// The route's own DB path wins over the <DataDir>/<host>.db default, exactly |
| +// as it does for the spawn. |
| +func TestExecBackendSeedHonoursRouteDBPath(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "custom-db.example.com" |
| + argvPath, _ := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + dbPath := filepath.Join(t.TempDir(), "elsewhere.db") |
| + |
| + if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", DBPath: dbPath, Seed: `["seed"]`}); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) |
| + wantArgv := []string{"seed", "--db", dbPath} |
| + if !slices.Equal(gotArgv, wantArgv) { |
| + t.Fatalf("seed argv = %v, want %v", gotArgv, wantArgv) |
| + } |
| +} |
| + |
| +// A seed that exits non-zero fails, naming the host, the exit state, and what |
| +// it printed — the wake dies with it (see doWake), so this error is the whole |
| +// diagnosis an operator gets. |
| +func TestExecBackendSeedNonZeroExitFails(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "badseed.example.com" |
| + stageSeedScript(t, b, host, "v1", "echo 'schema is corrupt' >&2\nexit 1\n") |
| + |
| + err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}) |
| + if err == nil { |
| + t.Fatal("a seed exiting 1 was reported as success") |
| + } |
| + for _, want := range []string{host, "exit status 1", "schema is corrupt"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Fatalf("seed error = %q, want it to contain %q", err, want) |
| + } |
| + } |
| +} |
| + |
| +// Unparseable seed argv is an ERROR, never a silent skip: a route whose seed |
| +// column got mangled must fail loudly at the first wake rather than boot an |
| +// empty instance that looks fine. |
| +func TestExecBackendSeedRejectsMalformedArgv(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "malformed.example.com" |
| + argvPath, _ := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + |
| + err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `seed --fixtures`}) |
| + if err == nil { |
| + t.Fatal("malformed seed argv was silently skipped") |
| + } |
| + if !strings.Contains(err.Error(), host) { |
| + t.Fatalf("seed parse error = %q, want it to name the host", err) |
| + } |
| + if _, statErr := os.Stat(argvPath); statErr == nil { |
| + t.Fatal("the binary was execed despite unparseable seed argv") |
| + } |
| +} |
| + |
| +// An empty argv array is a no-op, not an exec of the bare binary (defensive: |
| +// doWake only calls Seed for a non-empty route seed). |
| +func TestExecBackendSeedEmptyArgvRunsNothing(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "empty.example.com" |
| + argvPath, _ := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + |
| + if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `[]`}); err != nil { |
| + t.Fatalf("empty seed argv: %v", err) |
| + } |
| + if _, statErr := os.Stat(argvPath); statErr == nil { |
| + t.Fatal("an empty seed argv execed the binary") |
| + } |
| +} |
| + |
| +// A seed that never returns is bounded by seedTimeout — a wedged seed must |
| +// fail the wake, not hold the lease until the wake's own 5m budget expires. |
| +func TestExecBackendSeedIsTimeBounded(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "hangs.example.com" |
| + stageSeedScript(t, b, host, "v1", "sleep 30\n") |
| + |
| + prevTimeout, prevGrace := seedTimeout, seedDrainGrace |
| + seedTimeout, seedDrainGrace = 200*time.Millisecond, 200*time.Millisecond |
| + t.Cleanup(func() { seedTimeout, seedDrainGrace = prevTimeout, prevGrace }) |
| + |
| + start := time.Now() |
| + err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}) |
| + if err == nil { |
| + t.Fatal("a hanging seed was reported as success") |
| + } |
| + if !strings.Contains(err.Error(), "timed out") { |
| + t.Fatalf("seed error = %q, want it to say the seed timed out", err) |
| + } |
| + // The bound that matters: the script sleeps 30s, and a seed still running |
| + // when Seed returns would be holding the lease behind a wake that already |
| + // failed. |
| + if elapsed := time.Since(start); elapsed > 5*time.Second { |
| + t.Fatalf("seed took %s — the timeout did not bound it", elapsed) |
| + } |
| +} |
| + |
| +// A missing live binary is the same loud failure the spawn path gives: the |
| +// seed goes through ensureLiveLink, so a host with nothing staged never |
| +// silently "seeds". |
| +func TestExecBackendSeedRequiresLiveBinary(t *testing.T) { |
| + b := testExecBackend(t) |
| + err := b.Seed(context.Background(), registry.Route{Host: "unstaged.example.com", Kind: "instance", Seed: `["seed"]`}) |
| + if err == nil { |
| + t.Fatal("seed with no live binary reported success") |
| + } |
| + if !strings.Contains(err.Error(), "unstaged.example.com") { |
| + t.Fatalf("seed error = %q, want it to name the host", err) |
| + } |
| +} |
| + |
| +// A crashed seed takes its half-built database with it. Otherwise the next |
| +// wake stats a file that exists, calls it a warm cache, reports NOT fresh, and |
| +// boots the instance on a half-seeded database — the exact outcome failing the |
| +// wake was meant to prevent. |
| +func TestExecBackendSeedFailureRemovesHalfBuiltDB(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "crashed.example.com" |
| + // argv is: seed --db <path> → $3 is the database path. |
| + stageSeedScript(t, b, host, "v1", "printf half > \"$3\"\necho 'died mid-schema' >&2\nexit 1\n") |
| + |
| + if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err == nil { |
| + t.Fatal("a seed exiting 1 was reported as success") |
| + } |
| + if _, err := os.Stat(b.defaultDBPath(host)); err == nil { |
| + t.Fatal("a failed seed left its half-built database behind — the next wake would treat it as a warm cache") |
| + } |
| +} |
| + |
| +// The cleanup is guarded: a database that already existed when Seed was called |
| +// is never ours to delete (the wake path can't produce this — Restore reported |
| +// fresh — but a direct call must not be able to destroy live data). |
| +func TestExecBackendSeedFailureKeepsPreexistingDB(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "existing-db.example.com" |
| + stageSeedScript(t, b, host, "v1", "echo nope >&2\nexit 1\n") |
| + if err := os.WriteFile(b.defaultDBPath(host), []byte("live data"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err == nil { |
| + t.Fatal("a seed exiting 1 was reported as success") |
| + } |
| + got, err := os.ReadFile(b.defaultDBPath(host)) |
| + if err != nil || string(got) != "live data" { |
| + t.Fatalf("pre-existing database = %q, %v; want it untouched by a failed seed", got, err) |
| + } |
| +} |