carlos / platform Public

settings-4 task 5c: every wake records one identity before the seed and runs only it; sidecars run the recorded file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 15bb1e1a447d50910dee8637707582fef8b99437 parent facb277
8 files changed, +1030 −36
  • internal/activator/activator.go +94 −14
  • internal/activator/activator_test.go +4 −0
  • internal/activator/beforeexec.go +57 −1
  • internal/activator/generations_file.go +12 −4
  • internal/activator/genservice.go +16 −0
  • internal/activator/identity.go +4 −3
  • internal/activator/swap_sidecar_test.go +19 −14
  • internal/activator/wakeidentity_test.go +824 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index c9d690abd..e8f563abc 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -1550,6 +1550,14 @@ func (a *Service) wake(e *actEntry) {
go a.OnWake(rt)
}
}
+ if w != nil && w.pend != 0 {
+ // The cold wake's pending build (step 2b): the generation installed
+ // above, or the failed wake retained as unproven, now names it
+ // itself, or nothing of it ran. Dropped in the same critical
+ // section, so no snapshot sees neither.
+ a.dropPendingGenerationLocked(w.pend)
+ republish = true
+ }
a.mu.Unlock()
close(e.ready)
// A planned wake's outcome, for the scheduler: off a.mu, since OnGen
@@ -1637,6 +1645,12 @@ type wokenInstance struct {
// kill was not proven (genStartUnproven). cleanupFailedWake keeps the
// lease until the sweep proves it, whatever a.gens says.
genUnproven bool
+ // pend is the build a cold wake captured and published as pending at
+ // step 2b (settleWakeIdentity; ruling R-COLDPUBLISH). wake() drops it in
+ // the critical section that ends the wake: by then the entry's
+ // generation (or a retained unproven wake's) names the build, or
+ // nothing of it ran.
+ pend pendingGeneration
// spawnTook is how long Backend.Start took: exec to the socket accepting,
// which is the instance's OWN boot and nothing of the platform's. wakeTook
// is the whole wake around it, filled in by wake() rather than here
@@ -1807,6 +1821,30 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil
return w, fmt.Errorf("restore %s: %w", host, err)
}
+ if ctx.Err() != nil || !a.wakeAllowed(host) {
+ w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil
+ return w, ErrFenced
+ }
+
+ // 2b. One executable identity for this wake (settings spec §4.2, Astra
+ // I1 #5): chosen now, recorded now, under the lease taken in step 1 and
+ // before the seed and the spawn, and the only file this wake runs, its
+ // seed included (settleWakeIdentity). A route whose backend cannot pin
+ // an executable (identityFor nil: a unit-backed row, which systemd execs
+ // from the live link itself) keeps today's calls and records nothing.
+ // Any refusal here has run nothing: the lease goes back and the wake
+ // fails, never blaming the build.
+ ib := identityFor(a.backend, rt)
+ var id release.StagedIdentity
+ if ib != nil {
+ var idErr error
+ id, w.pend, idErr = a.settleWakeIdentity(ctx, ib, e, rt)
+ if idErr != nil {
+ w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil
+ return w, fmt.Errorf("start %s: %w", host, idErr)
+ }
+ ctx = withRecordedBuild(ctx, id.Version)
+ }
// 3. First-boot seed, strictly between the restore and the spawn — so it
// runs before Start's first health probe, and a brand-new instance is born
@@ -1816,13 +1854,16 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
// can never run over recovered data. A seed that fails fails the WHOLE
// wake (the Start-failure precedent: release the lease, surface the
// error) — a half-seeded instance must not serve.
- if ctx.Err() != nil || !a.wakeAllowed(host) {
- w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil
- return w, ErrFenced
- }
+ //
+ // The seed runs the wake's recorded identity (SeedExe) on every wake
+ // kind, planned and pinned included.
if fresh && rt.Seed != "" {
w.stopOutcome.uncertain = true
- if err := a.backend.Seed(ctx, rt); err != nil {
+ seed := func() error { return a.backend.Seed(ctx, rt) }
+ if ib != nil {
+ seed = func() error { return ib.SeedExe(ctx, rt, id) }
+ }
+ if err := seed(); err != nil {
if errors.Is(err, ErrSeedUnproven) {
// A SIGKILL survivor of the seed may still be writing the
// database: releasing the lease here would be exactly the
@@ -1909,8 +1950,17 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
}
} else {
// startWorker publishes the build before its exec (ruling R19
- // item 3), and refuses the spawn when it cannot.
- proc, ident, pend, err = a.startWorker(ctx, rt, env, nil, nil)
+ // item 3), and refuses the spawn when it cannot. With an
+ // identity settled at step 2b it execs exactly that file
+ // (onto): its hook publishes and gates it again, and skips the
+ // record the wake already wrote. The second pending record of
+ // the same build is harmless (the file lists each build once)
+ // and is dropped as the wake's own is.
+ var onto *release.StagedIdentity
+ if ib != nil {
+ onto = &id
+ }
+ proc, ident, pend, err = a.startWorker(ctx, rt, env, onto, nil)
}
spawnVer, w.spawnIdentity = ident.Version, ident
if err == nil && !plan.planned() {
@@ -1946,6 +1996,14 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
var g Gen
proc, spawnVer, g, err = a.startPinnedChecked(ctx, e, rt, *e.pin, lease)
w.gen = g
+ } else if ib != nil {
+ // The identity step 2b recorded, as the next generation, exactly
+ // where startGen would put it: the canonical socket (and, nested,
+ // the original run unit) with the flag off, generation n's own
+ // socket and unit with it on (startGenExe).
+ var g Gen
+ proc, g, err = a.startGenExe(ctx, ib, rt, id)
+ spawnVer, w.gen = g.Build, g
} else {
var g Gen
proc, g, err = a.startGen(ctx, rt)
@@ -2155,9 +2213,18 @@ func (a *Service) bounceLocked(e *actEntry) bool {
// startSidecar is every legacy co-spawned sidecar spawn: the wake tail,
// late attachment and the supervisor (sweepOnce), and a swap's replacement
-// (zero-downtime spec §7.5). With swaps on and a generation whose staged
-// path is known, it execs that path. Otherwise it is today's StartSidecar,
-// live link and all.
+// (zero-downtime spec §7.5). It runs its generation's recorded file by
+// path whatever CARLOS_SWAP says (ruling R-EXEC-SIDECAR): a flag-off
+// sidecar started from the live link could run a different build than its
+// instance, and a nested one would rebind the host's shared app mount to
+// the link. A generation that names no staged file (an instance woken
+// before this binary) has the live link resolved once, and that file run
+// by path. Each exec is recorded first (settings spec §4.2): the wake's
+// co-spawn skips the build its wake already recorded, while a restart or
+// a replacement writes it. A refused record starts nothing and is
+// returned like any start failure, which never fails a wake. Only a route
+// no backend can pin a file for (a unit row) keeps today's StartSidecar,
+// live link and all, recording nothing.
//
// Immediately before the spawn it asks the gate's ownership check (spec
// §7.4): e must still be the host's, unfenced, with no shutdown, and (awake)
@@ -2176,12 +2243,25 @@ func (a *Service) startSidecar(ctx context.Context, e *actEntry, rt registry.Rou
if err != nil {
return nil, err
}
- if a.gens && g.StagedPath != "" {
- if s, ok := a.backend.(SidecarGenStarter); ok {
- return s.StartSidecarGen(ctx, rt, g)
+ s, ok := a.backend.(SidecarGenStarter)
+ if !ok {
+ return a.backend.StartSidecar(ctx, rt)
+ }
+ if g.StagedPath == "" {
+ ib := identityFor(a.backend, rt)
+ if ib == nil {
+ return a.backend.StartSidecar(ctx, rt)
+ }
+ id, err := ib.LiveIdentity(ctx, rt)
+ if err != nil {
+ return nil, err
}
+ g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand
+ }
+ if err := a.recordBuild(ctx, rt.Host, g.Build); err != nil {
+ return nil, err
}
- return a.backend.StartSidecar(ctx, rt)
+ return s.StartSidecarGen(ctx, rt, g)
}
// replaceSidecar is §7.5 step 8: a legacy co-spawned sidecar is restarted,
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 5fa25f5fa..bd6571397 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -382,6 +382,10 @@ type fakeBackend struct {
// LayoutFlat, "": every test that never sets this field gets the
// behavior it always had, since a flat layout never fences a wake.
layoutFn func(rt registry.Route) (Layout, string)
+
+ // ident is the identity capability (settings spec §4.2, Task 5c),
+ // opted into per test (wakeidentity_test.go). Guarded by mu.
+ ident identityFake
}
// Layout is fakeBackend's Classifier implementation, always present so
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go
index cc842b5c5..22ecf4ac3 100644
--- a/internal/activator/beforeexec.go
+++ b/internal/activator/beforeexec.go
@@ -5,6 +5,9 @@ import (
"errors"
"fmt"
"log"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
// errBuildStateUnwritten refuses a spawn whose build could not be recorded
@@ -56,10 +59,63 @@ func (a *Service) recordBuild(ctx context.Context, host, build string) error {
return nil
}
+// settleWakeIdentity is doWake's step 2b (settings spec §4.2, Astra I1
+// #5): the one file this wake runs, its seed included, chosen and recorded
+// under the wake's lease after the restore and before anything execs.
+//
+// - A planned wake runs its plan's boot build, and a pinned wake (a hold
+// swap's) its pin. Step 0.8 published both before the lease.
+// - Any other wake (flag off; flag on with no plan; a worker) captures the
+// live link once (LiveIdentity, which materializes a missing link
+// first). That capture is published as a pending generation before it
+// is recorded (ruling R-COLDPUBLISH): the generations file names a build
+// before its build state does, and root's sweep keeps the file until the
+// process that runs it is gone. A write that fails refuses the wake as
+// a worker spawn's refuses its exec. The pending record is returned so
+// the wake drops it once the entry names the build itself (wake), or the
+// wake failed.
+//
+// Then the record (recordBuild). Any error means nothing was seeded or
+// spawned; the caller releases the lease.
+func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route) (release.StagedIdentity, pendingGeneration, error) {
+ a.mu.Lock()
+ pin, plan := e.pin, e.plan
+ a.mu.Unlock()
+ var (
+ id release.StagedIdentity
+ pend pendingGeneration
+ )
+ switch {
+ case plan.planned():
+ id = plan.Boot
+ case pin != nil:
+ id = release.StagedIdentity{Version: pin.Build, StagedPath: pin.StagedPath, Digest: pin.Digest, Demand: pin.Demand}
+ default:
+ var err error
+ if id, err = ib.LiveIdentity(ctx, rt); err != nil {
+ return release.StagedIdentity{}, 0, err
+ }
+ pend = a.addPendingGeneration(rt.Host, id)
+ if err := a.persistGenerations(); err != nil {
+ log.Printf("activator: %s not woken: the build it would run (%s %s) could not be published first: %v", rt.Host, id.Version, id.StagedPath, err)
+ if e.worker {
+ // A worker's wake reads as its exec-time refusal always has
+ // (startWorker's hook, final review F9).
+ return id, pend, fmt.Errorf("%w: %v", ErrBuildNotPublished, err)
+ }
+ return id, pend, errStagedBuildNotPublished
+ }
+ }
+ if err := a.recordBuild(ctx, rt.Host, id.Version); err != nil {
+ return id, pend, err
+ }
+ return id, pend, nil
+}
+
type recordedBuildKey struct{}
// withRecordedBuild marks ctx as carrying a wake that has already recorded
-// build (Task 5b's wake records once, before its seed and its spawn).
+// build (doWake's step 2b records once, before its seed and its spawn).
func withRecordedBuild(ctx context.Context, build string) context.Context {
return context.WithValue(ctx, recordedBuildKey{}, build)
}
diff --git a/internal/activator/generations_file.go b/internal/activator/generations_file.go
index 5d8080466..e5e617ec1 100644
--- a/internal/activator/generations_file.go
+++ b/internal/activator/generations_file.go
@@ -27,10 +27,18 @@ import (
// §8.5: current, successor, retiring, spawning): the current generation, a
// hold's pinned one, a planned wake's build and its fallback, every
// operation-set member, a failed wake retained until its end is proven, and
-// a socketless attempt's builds. With generations off an instance runs from
-// the live link, which root pins already, so an instance generation is
-// listed only with generations on or when it was exec'd from a staged path
-// (genIdentity); a flag-off file says exactly what plan 4's said.
+// a socketless attempt's builds. An instance generation is listed with
+// generations on, or whenever it was exec'd from a staged path
+// (genIdentity). Since settings Task 5c every exec-backed wake runs its
+// recorded file by path, flag off included, so a flag-off file names those
+// instances' files too; only a generation started from the live link (a
+// backend that cannot pin a file) stays unlisted, the link being root's
+// pin already.
+//
+// A cold wake (no plan, no pin) captures the live link once after its
+// restore and publishes it as a pending build before it records it
+// (settleWakeIdentity, ruling R-COLDPUBLISH); wake() drops that pending
+// build once the entry's generation names the file.
//
// Every staged instance spawn (a planned wake and its fallback, a hold's
// pinned wake, an overlap successor) publishes the entry state that names
diff --git a/internal/activator/genservice.go b/internal/activator/genservice.go
index ec5c9c73b..6debc71bd 100644
--- a/internal/activator/genservice.go
+++ b/internal/activator/genservice.go
@@ -6,6 +6,7 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
// The Service's generation surface (zero-downtime spec §7.1). AppSocket is
@@ -31,6 +32,21 @@ func (a *Service) startGen(ctx context.Context, rt registry.Route) (Proc, Gen, e
return proc, canonicalGen(rt, n, ver), err
}
+// startGenExe is startGen on the identity a wake recorded (settings spec
+// §4.2): the same next number and the same socket choice (perGen is the
+// switch), but the process runs id's file by path, never the live link.
+// A failed start's handle names id too when it is a handle at all (a
+// refusal answers none): a process it may have left running is retained by
+// that handle (P2-13), and the generations file must keep naming the file
+// that process runs (genIdentity lists a staged path whatever the switch).
+func (a *Service) startGenExe(ctx context.Context, ib identityBackend, rt registry.Route, id release.StagedIdentity) (Proc, Gen, error) {
+ proc, g, err := ib.StartGenExe(ctx, rt, nextGen(), a.gens, id)
+ if err != nil && g.N != 0 {
+ g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand
+ }
+ return proc, g, err
+}
+
// AppSocket names host's CURRENT generation's socket, for traffic: the edge
// proxy, scheduled delivery, the next-due probe at a stop, and the status
// probe (spec §7.1). It answers for an awake or stopping entry (a stopping
diff --git a/internal/activator/identity.go b/internal/activator/identity.go
index d400d0e4d..1fe3575ff 100644
--- a/internal/activator/identity.go
+++ b/internal/activator/identity.go
@@ -51,13 +51,14 @@ func identityFor(b Backend, rt registry.Route) identityBackend {
// identityFor is the exec half for an exec row and nil for a unit row:
// systemd execs a unit row's live link itself, so nothing here can pin its
-// file. SplitBackend does not implement identityBackend itself.
+// file. SplitBackend does not implement identityBackend itself. The exec
+// half answers by the same rule (identityFor), so a half that is itself
+// composed answers per route too.
func (b *SplitBackend) identityFor(rt registry.Route) identityBackend {
if rt.Backing == registry.BackingUnit {
return nil
}
- ib, _ := b.pick(rt).(identityBackend)
- return ib
+ return identityFor(b.pick(rt), rt)
}
// stagedExeOf is id as root's control channel names an executable.
diff --git a/internal/activator/swap_sidecar_test.go b/internal/activator/swap_sidecar_test.go
index 76548d7ae..c27769f9e 100644
--- a/internal/activator/swap_sidecar_test.go
+++ b/internal/activator/swap_sidecar_test.go
@@ -50,8 +50,12 @@ func (f *swapFixture) generationSwitched() bool {
return g.N != f.incumbent.N
}
-// Flag off: every legacy sidecar spawn (the wake tail and the supervisor)
-// goes through StartSidecar and the live link, as today.
+// Flag off, on a backend that cannot pin an executable (the fixture never
+// opts into the identity capability, as a unit row's backend has none): a
+// generation with no staged file starts its legacy sidecar (the wake tail
+// and the supervisor) through StartSidecar and the live link, as before.
+// A generation that names its file runs it whatever the flag (ruling
+// R-EXEC-SIDECAR; TestLegacySidecarOwnershipCheckRunsWithSwapOff).
func TestLegacySidecarUsesTheLiveLinkWithSwapOff(t *testing.T) {
f := newSwapFixture(t)
f.a.gens = false
@@ -76,8 +80,9 @@ func TestLegacySidecarUsesTheLiveLinkWithSwapOff(t *testing.T) {
// changes against today, called directly so no earlier sweep check masks it:
// a host that does not own itself (fenced, shutting down, lease lost, no
// longer the host's entry) gets NO co-spawned sidecar, where today's
-// StartSidecar would have spawned one. A host that owns itself gets the
-// live-link sidecar exactly as before, and StartSidecarGen is never used.
+// StartSidecar would have spawned one. A host that owns itself gets its
+// sidecar on the generation's own staged file, with the flag off too
+// (ruling R-EXEC-SIDECAR, settings Task 5c), never the live link.
func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
f := newSwapFixture(t)
f.a.gens = false
@@ -88,16 +93,16 @@ func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
f.a.mu.Unlock()
rep := f.b.lastRep()
- // Owned: today's spawn, through the live link.
+ // Owned: the generation's recorded file, by path.
sc, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false)
if err != nil || sc == nil {
- t.Fatalf("an owned host's sidecar = %v, %v; want the live-link spawn", sc, err)
+ t.Fatalf("an owned host's sidecar = %v, %v; want the generation's spawn", sc, err)
}
- if got := f.b.sidecarStartCount(); got != 1 {
- t.Fatalf("StartSidecar calls = %d, want 1", got)
+ if paths := f.b.sidecarGenPaths(); len(paths) != 1 || paths[0] != f.incumbent.StagedPath {
+ t.Fatalf("StartSidecarGen paths = %v, want the generation's %q", paths, f.incumbent.StagedPath)
}
- if paths := f.b.sidecarGenPaths(); len(paths) != 0 {
- t.Fatalf("StartSidecarGen ran with swaps off: %v", paths)
+ if got := f.b.sidecarStartCount(); got != 0 {
+ t.Fatalf("StartSidecar (live link) calls = %d, want none", got)
}
refused := func(name string, arm, disarm func(), want error) {
@@ -108,8 +113,8 @@ func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
if !errors.Is(err, want) || sc != nil {
t.Fatalf("%s: startSidecar = %v, %v; want %v and no process", name, sc, err, want)
}
- if got := f.b.sidecarStartCount(); got != 1 {
- t.Fatalf("%s: StartSidecar calls = %d, want 1 (none beside a host that does not own itself)", name, got)
+ if got := len(f.b.sidecarGenPaths()); got != 1 || f.b.sidecarStartCount() != 0 {
+ t.Fatalf("%s: sidecar spawns = %d (live link %d), want 1 (none beside a host that does not own itself)", name, got, f.b.sidecarStartCount())
}
}
refused("fenced",
@@ -129,8 +134,8 @@ func TestLegacySidecarOwnershipCheckRunsWithSwapOff(t *testing.T) {
if _, err := f.a.startSidecar(context.Background(), e, rt, f.incumbent, false); err != nil {
t.Fatalf("owned again: %v", err)
}
- if got := f.b.sidecarStartCount(); got != 2 {
- t.Fatalf("StartSidecar calls = %d, want 2", got)
+ if got := len(f.b.sidecarGenPaths()); got != 2 {
+ t.Fatalf("StartSidecarGen calls = %d, want 2", got)
}
}
diff --git a/internal/activator/wakeidentity_test.go b/internal/activator/wakeidentity_test.go
new file mode 100644
index 000000000..465e27096
--- /dev/null
+++ b/internal/activator/wakeidentity_test.go
@@ -0,0 +1,824 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Settings spec §4.2, Task 5c: every unclaimed wake settles ONE executable
+// identity after the restore, records it before the seed and the spawn,
+// and runs only that file; legacy sidecars run their generation's recorded
+// file by path whatever CARLOS_SWAP says.
+
+// identityFake is fakeBackend's identity capability. A test opts in
+// (setIdentity); until then identityFor answers nil, so every test written
+// before Task 5c keeps today's Start and Seed and their events.
+type identityFake struct {
+ on bool
+ // live is the build live/<host> names, "" when the link is missing;
+ // LiveIdentity then materializes materializeTo first.
+ live string
+ materializeTo string
+ materializes int
+ captures int
+ // unversioned makes the live link name a file outside the version
+ // store (Version "").
+ unversioned bool
+ exe []exeCall
+ last release.StagedIdentity
+}
+
+type exeCall struct {
+ n uint64
+ perGen bool
+ id release.StagedIdentity
+}
+
+const unversionedPath = "/srv/hand-placed/app"
+
+func (b *fakeBackend) setIdentity(live string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.ident.on, b.ident.live = true, live
+}
+
+func (b *fakeBackend) setLive(v string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.ident.live = v
+}
+
+func (b *fakeBackend) identityFor(registry.Route) identityBackend {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if !b.ident.on {
+ return nil
+ }
+ return b
+}
+
+// LiveVersion makes New wire a.liveVer to the fake's link. "" (nothing
+// opted in) is exactly the stub New used before.
+func (b *fakeBackend) LiveVersion(string) (string, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.ident.live, nil
+}
+
+func (b *fakeBackend) LiveIdentity(_ context.Context, rt registry.Route) (release.StagedIdentity, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.ident.unversioned {
+ b.ident.captures++
+ return release.StagedIdentity{StagedPath: unversionedPath, Digest: "sha-of-hand-placed"}, nil
+ }
+ if b.ident.live == "" {
+ if b.ident.materializeTo == "" {
+ return release.StagedIdentity{}, fmt.Errorf("no live binary for %s", rt.Host)
+ }
+ b.ident.live = b.ident.materializeTo
+ b.ident.materializes++
+ }
+ b.ident.captures++
+ return fakeIdentity(b.ident.live), nil
+}
+
+func (b *fakeBackend) StartGenExe(_ context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (Proc, Gen, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.starts++
+ b.order = append(b.order, "start "+id.Version)
+ b.startedRoutes = append(b.startedRoutes, rt)
+ b.ident.exe = append(b.ident.exe, exeCall{n: n, perGen: perGen, id: id})
+ b.ident.last = id
+ sock := rt.Socket
+ if perGen {
+ sock = fmt.Sprintf("%s.g%d", rt.Socket, n)
+ }
+ g := Gen{N: n, Socket: sock, Control: sock + ".control"}
+ if b.startErr != nil {
+ return nil, g, b.startErr
+ }
+ proc := &fakeProc{label: "instance", rec: b, stuck: b.procStuck}
+ b.procs = append(b.procs, proc)
+ g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand
+ return proc, g, nil
+}
+
+func (b *fakeBackend) SeedExe(_ context.Context, rt registry.Route, id release.StagedIdentity) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.seeds++
+ b.order = append(b.order, "seed "+id.Version)
+ b.seedRoutes = append(b.seedRoutes, rt)
+ b.ident.last = id
+ return b.seedErr
+}
+
+var _ identityBackend = (*fakeBackend)(nil)
+
+// liveStarts counts Start calls: the live-link spawn the identity path
+// replaces.
+func (b *fakeBackend) liveStarts() int {
+ n := 0
+ for _, ev := range b.events() {
+ if ev == "start:instance" {
+ n++
+ }
+ }
+ return n
+}
+
+// lastExeVersion is the build of the last file StartGenExe or SeedExe ran.
+func (b *fakeBackend) lastExeVersion() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.ident.last.Version
+}
+
+// lastExe is the last StartGenExe call.
+func (b *fakeBackend) lastExe() (exeCall, bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if len(b.ident.exe) == 0 {
+ return exeCall{}, false
+ }
+ return b.ident.exe[len(b.ident.exe)-1], true
+}
+
+func (b *fakeBackend) identityCounts() (captures, materializes int) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.ident.captures, b.ident.materializes
+}
+
+// identityActivator is testActivator with the identity capability on and
+// live/<host> naming live.
+func identityActivator(t *testing.T, live string) (*Service, *fakeBackend) {
+ t.Helper()
+ a, b, _, _ := testActivator(t, time.Hour)
+ b.setIdentity(live)
+ return a, b
+}
+
+// identityRoute is testRoute with its socket and database in the test's
+// own directory: a flag-on wake links the canonical path for real.
+func identityRoute(t *testing.T, host string) registry.Route {
+ t.Helper()
+ rt := testRoute(host)
+ dir := t.TempDir()
+ rt.Socket, rt.DBPath = filepath.Join(dir, "app.sock"), filepath.Join(dir, "app.db")
+ return rt
+}
+
+func seeded(rt registry.Route) registry.Route {
+ rt.Seed = `["seed"]`
+ return rt
+}
+
+// recordInto is a BeforeExec that writes "record <build>" into b's event
+// log before l's own record, so a test reads one ordered log.
+func recordInto(b *fakeBackend, l *execLog) func(context.Context, string, string) error {
+ return func(ctx context.Context, host, build string) error {
+ b.record("record " + build)
+ return l.hook(ctx, host, build)
+ }
+}
+
+// identityEvents is the record, seed and instance start events in b's log,
+// in order: "record <v>", "seed <v>" and "start <v>" for the identity
+// path, "seed" and "start:instance" for today's.
+func identityEvents(b *fakeBackend) []string {
+ var out []string
+ for _, ev := range b.events() {
+ if strings.HasPrefix(ev, "record ") || strings.HasPrefix(ev, "seed") || strings.HasPrefix(ev, "start ") || ev == "start:instance" {
+ out = append(out, ev)
+ }
+ }
+ return out
+}
+
+func (a *Service) entryGen(host string) (Gen, bool) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+ if e == nil || e.state != actAwake {
+ return Gen{}, false
+ }
+ return e.gen, true
+}
+
+// --- the wake ---
+
+func TestTheBuildStateIsWrittenBeforeTheWake(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ l := &execLog{spawns: b.startCount}
+ a.BeforeExec = l.hook
+ rt := identityRoute(t, "one.example.com")
+ done, err := a.beginRequest(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ calls, at, locs := l.seen()
+ if len(calls) != 1 || calls[0] != "one.example.com@v1" || at[0] != 0 {
+ t.Fatalf("records %v at spawn counts %v, want one.example.com@v1 before the spawn", calls, at)
+ }
+ if locs[0] != (BuildStateLocation{Bucket: "test-bucket", Prefix: HibernatePrefix("", rt.Host)}) {
+ t.Fatalf("location = %+v, want the wake's lease", locs[0])
+ }
+ x, ok := b.lastExe()
+ if b.startCount() != 1 || b.liveStarts() != 0 || !ok || x.id != fakeIdentity("v1") || x.perGen || x.n == 0 {
+ t.Fatalf("starts %d, live-link starts %d, exe %+v: want one flag-off start of the recorded v1", b.startCount(), b.liveStarts(), x)
+ }
+ if g, _ := a.entryGen(rt.Host); g.StagedPath != fakeIdentity("v1").StagedPath || g.Build != "v1" || g.N != x.n {
+ t.Fatalf("serving generation %+v, want the recorded file's", g)
+ }
+}
+
+func TestAFailedWriteBlocksTheSpawn(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ b.restoreFresh = true
+ a.BeforeExec = (&execLog{fail: failAll}).hook
+ _, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com")))
+ if !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v, want errBuildStateUnwritten", err)
+ }
+ if b.startCount() != 0 || b.seedCount() != 0 {
+ t.Fatalf("spawned %d, seeded %d with no build state", b.startCount(), b.seedCount())
+ }
+ waitFor(t, "the lease to go back", func() bool { return b.releaseCount() == 1 })
+}
+
+// Review Focus 1, Astra I1 #5: root repoints the live link while the build
+// state is being written. The wake still execs exactly the file it
+// recorded, never the link, and records once.
+func TestTheWakeExecsTheIdentityItRecorded(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ b.restoreFresh = true
+ l := &execLog{}
+ a.BeforeExec = func(ctx context.Context, host, build string) error {
+ b.setLive("v2") // root's repoint lands mid-write
+ return l.hook(ctx, host, build)
+ }
+ done, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if calls, _, _ := l.seen(); len(calls) != 1 || calls[0] != "one.example.com@v1" {
+ t.Fatalf("calls = %v; want one record of v1", calls)
+ }
+ if got := identityEvents(b); !slices.Equal(got, []string{"seed v1", "start v1"}) {
+ t.Fatalf("events %v, want the recorded v1 seeded and started by path", got)
+ }
+ if c, _ := b.identityCounts(); b.liveStarts() != 0 || c != 1 {
+ t.Fatalf("live-link starts %d, identity captures %d: want one capture and no live-link exec", b.liveStarts(), c)
+ }
+}
+
+// Astra I1 #5: the seed runs the recorded identity, after the record.
+func TestTheSeedRunsTheRecordedIdentity(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ b.restoreFresh = true
+ l := &execLog{}
+ a.BeforeExec = recordInto(b, l)
+ done, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if got := identityEvents(b); !slices.Equal(got, []string{"record v1", "seed v1", "start v1"}) {
+ t.Fatalf("events = %v; want record, seed and start, all v1, in that order", got)
+ }
+}
+
+// Astra I1 #5: a planned wake records its boot build before the seed, seeds
+// that build (never the live link), and startPinnedAt does not write it
+// again.
+func TestAPlannedWakeRecordsBeforeTheSeed(t *testing.T) {
+ next := stagedID(t, t.TempDir(), "v3")
+ a, b, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap})
+ b.fakeBackend.setIdentity("v1")
+ b.restoreFresh = true
+ l := &execLog{}
+ a.BeforeExec = recordInto(b.fakeBackend, l)
+ done, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if got := identityEvents(b.fakeBackend); !slices.Equal(got, []string{"record v3", "seed v3"}) {
+ t.Fatalf("events = %v; want the planned build recorded before its seed", got)
+ }
+ if calls, _, _ := l.seen(); len(calls) != 1 {
+ t.Fatalf("calls = %v; startPinnedAt re-recorded the build the wake already recorded", calls)
+ }
+ if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
+ t.Fatalf("spawned %+v, want the planned v3", gens)
+ }
+ if c, _ := b.identityCounts(); c != 0 {
+ t.Fatalf("%d live-link captures: a planned wake runs its plan", c)
+ }
+}
+
+// D6: checkPlan may replan after step 2b has recorded the old plan's
+// boot. startPinnedAt then records the new build before it execs.
+func TestAReplanAfterTheRecordRecordsTheNewBuild(t *testing.T) {
+ dir := t.TempDir()
+ stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
+ a, b, _ := planActivator(t, WakePlan{})
+ b.fakeBackend.setIdentity("v1")
+ var plans atomic.Int32
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ if plans.Add(1) == 1 {
+ return WakePlan{Boot: stale, Mode: ModeOverlap, Check: func(string) error { return errors.New("superseded") }}
+ }
+ return WakePlan{Boot: next, Mode: ModeOverlap}
+ }
+ l := &execLog{spawns: func() int { return len(b.started()) }}
+ a.BeforeExec = l.hook
+ done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ calls, at, _ := l.seen()
+ if !slices.Equal(calls, []string{"one.example.com@v2", "one.example.com@v3"}) || at[1] != 0 {
+ t.Fatalf("records %v at spawn counts %v, want v2 at the wake, then v3 before the replanned exec", calls, at)
+ }
+ if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
+ t.Fatalf("spawned %+v, want the replanned v3", gens)
+ }
+}
+
+func TestANilHookIsToday(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if b.startCount() != 1 || b.lastExeVersion() != "v1" {
+ t.Fatalf("starts %d of %q: a nil BeforeExec changed the wake", b.startCount(), b.lastExeVersion())
+ }
+
+ // Ruling R-EXEC-NOVERSION: a link outside the version store still wakes
+ // with no hook (no replica bucket), and is refused with one.
+ a, b = identityActivator(t, "")
+ b.mu.Lock()
+ b.ident.unversioned = true
+ b.mu.Unlock()
+ done, err = a.beginRequest(context.Background(), identityRoute(t, "two.example.com"))
+ if err != nil {
+ t.Fatalf("an unversioned link with no hook: %v", err)
+ }
+ done()
+ if x, _ := b.lastExe(); x.id.StagedPath != unversionedPath {
+ t.Fatalf("ran %+v, want the resolved file by path", x.id)
+ }
+ a, b = identityActivator(t, "")
+ b.mu.Lock()
+ b.ident.unversioned = true
+ b.mu.Unlock()
+ a.BeforeExec = (&execLog{}).hook
+ if _, err := a.beginRequest(context.Background(), identityRoute(t, "two.example.com")); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v, want an unversioned build refused while a build state is kept", err)
+ }
+ if b.startCount() != 0 {
+ t.Fatalf("%d starts of a build no build state names", b.startCount())
+ }
+}
+
+// A refused record starts nothing on any wake kind: no seed, no instance,
+// no worker, no planned build, and the member reads the fence's wording.
+func TestARefusingHookStartsNothing(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ b.restoreFresh, b.sidecarStaged = true, true
+ a.BeforeExec = (&execLog{fail: failAll}).hook
+ _, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com")))
+ if !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v, want errBuildStateUnwritten", err)
+ }
+ if got := memberSafeWakeReason(err); got != memberSafeWakeReason(ErrFenced) {
+ t.Fatalf("a member reads %q, want the fence's wording", got)
+ }
+ if b.startCount() != 0 || b.seedCount() != 0 || b.sidecarStartCount() != 0 {
+ t.Fatalf("started %d, seeded %d, sidecars %d after a refused record", b.startCount(), b.seedCount(), b.sidecarStartCount())
+ }
+
+ sa, sb, _, _, _ := stagedActivator(t)
+ sb.fakeBackend.setIdentity("v1")
+ sa.BeforeExec = (&execLog{fail: failAll}).hook
+ if err := sa.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("WakeWorker = %v, want errBuildStateUnwritten", err)
+ }
+ if n := len(sb.calls()); n != 0 || sb.workerStarts() != 0 {
+ t.Fatalf("%d staged worker spawns, %d children after a refused record", n, sb.workerStarts())
+ }
+
+ next := stagedID(t, t.TempDir(), "v3")
+ pa, pb, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap})
+ pb.fakeBackend.setIdentity("v1")
+ pb.restoreFresh = true
+ pa.BeforeExec = (&execLog{fail: failAll}).hook
+ if _, err := pa.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com"))); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("planned wake err = %v, want errBuildStateUnwritten", err)
+ }
+ if len(pb.started()) != 0 || pb.seedCount() != 0 {
+ t.Fatalf("planned: spawned %+v, seeded %d after a refused record", pb.started(), pb.seedCount())
+ }
+}
+
+// Astra I3 #3: identity capability is per route. On a SplitBackend whose
+// exec half pins executables, a unit-backed route still wakes by today's
+// Start and Seed, records nothing, and never reaches StartGenExe; an exec
+// row on the same service takes the identity path.
+func TestAUnitRouteOnASplitBackendKeepsTodaysWake(t *testing.T) {
+ exec, unit := &fakeBackend{}, &fakeBackend{}
+ exec.setIdentity("v1")
+ unit.setIdentity("v1")
+ // struct{ Backend } hides the unit fake's identity methods, as
+ // UnitBackend has none.
+ split := &SplitBackend{Exec: exec, Unit: struct{ Backend }{unit}}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, split, reg) // as agent.go builds it
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ unit.restoreFresh = true
+ ur := seeded(identityRoute(t, "u.example.com"))
+ ur.Backing = registry.BackingUnit
+ done, err := a.beginRequest(context.Background(), ur)
+ if err != nil {
+ t.Fatalf("a unit route's wake failed on the split backend: %v", err)
+ }
+ done()
+ calls, _, _ := l.seen()
+ if unit.liveStarts() != 1 || unit.seedCount() != 1 || exec.startCount() != 0 || len(calls) != 0 {
+ t.Fatalf("unit live starts %d, seeds %d, exec starts %d, records %v; want today's unit wake, nothing recorded",
+ unit.liveStarts(), unit.seedCount(), exec.startCount(), calls)
+ }
+ if got := identityEvents(unit); !slices.Equal(got, []string{"seed", "start:instance"}) {
+ t.Fatalf("unit events %v, want today's seed and start", got)
+ }
+ done, err = a.beginRequest(context.Background(), identityRoute(t, "e.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if calls, _, _ := l.seen(); exec.lastExeVersion() != "v1" || len(calls) != 1 || calls[0] != "e.example.com@v1" {
+ t.Fatalf("the exec row did not take the identity path: %q, %v", exec.lastExeVersion(), calls)
+ }
+}
+
+// Astra I2 #6: a cold default instance with no live link still wakes: the
+// Materialize hook runs before the identity is captured, and the identity
+// is the build it materialized.
+func TestADefaultWakeMaterializesBeforeItCapturesTheIdentity(t *testing.T) {
+ a, b := identityActivator(t, "")
+ b.mu.Lock()
+ b.ident.materializeTo = "v1"
+ b.mu.Unlock()
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com"))
+ if err != nil {
+ t.Fatalf("a cold wake failed before materializing: %v", err)
+ }
+ done()
+ calls, _, _ := l.seen()
+ if _, m := b.identityCounts(); m != 1 || len(calls) != 1 || calls[0] != "one.example.com@v1" || b.lastExeVersion() != "v1" {
+ t.Fatalf("materialized %d, recorded %v, ran %q", m, calls, b.lastExeVersion())
+ }
+}
+
+// CARLOS_SWAP on, no plan: the recorded file runs as generation n on its
+// own socket, exactly where startGen would have put it.
+func TestAFlagOnUnplannedWakeRunsTheRecordedFileAtGenN(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ a.gens = true
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ rt := identityRoute(t, "one.example.com")
+ done, err := a.beginRequest(context.Background(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ x, ok := b.lastExe()
+ if !ok || !x.perGen || x.n == 0 || x.id != fakeIdentity("v1") || b.liveStarts() != 0 {
+ t.Fatalf("exe %+v (live-link starts %d), want the recorded v1 at its own generation", x, b.liveStarts())
+ }
+ if calls, _, _ := l.seen(); len(calls) != 1 || calls[0] != "one.example.com@v1" {
+ t.Fatalf("records %v", calls)
+ }
+ g, _ := a.entryGen(rt.Host)
+ if g.N != x.n || g.Socket != fmt.Sprintf("%s.g%d", rt.Socket, x.n) || g.StagedPath != fakeIdentity("v1").StagedPath {
+ t.Fatalf("serving generation %+v, want generation %d of the recorded file", g, x.n)
+ }
+}
+
+// Ruling R-COLDPUBLISH: the generations file names the captured build
+// before its build state does, so root cannot sweep the file between the
+// capture and the exec. A refused wake's pending build is dropped again.
+func TestTheColdWakePublishesBeforeItRecords(t *testing.T) {
+ a, b := identityActivator(t, "v1")
+ a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
+ path := fakeIdentity("v1").StagedPath
+ names := func() bool {
+ g, err := release.ReadGenerations(a.GenerationsFile)
+ return err == nil && g != nil && slices.Contains(g.Paths["one.example.com"], path)
+ }
+ var publishedFirst atomic.Bool
+ l := &execLog{}
+ a.BeforeExec = func(ctx context.Context, host, build string) error {
+ publishedFirst.Store(names())
+ return l.hook(ctx, host, build)
+ }
+ done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if !publishedFirst.Load() {
+ t.Fatal("the build state was written before the generations file named the build")
+ }
+ waitFor(t, "the serving generation to stay published", names)
+ if b.startCount() != 1 {
+ t.Fatalf("starts = %d", b.startCount())
+ }
+
+ a, _ = identityActivator(t, "v1")
+ a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
+ a.BeforeExec = (&execLog{fail: failAll}).hook
+ if _, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")); !errors.Is(err, errBuildStateUnwritten) {
+ t.Fatalf("err = %v", err)
+ }
+ waitFor(t, "the refused wake's pending build to be dropped", func() bool { return !names() })
+}
+
+// A hold swap's pinned wake records the pin before the seed, seeds the pin
+// (never the live link), and startPinnedAt does not record it again.
+func TestAPinnedWakeSeedsThePin(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.fakeBackend.setIdentity("v1")
+ f.b.restoreFresh = true
+ f.a.mu.Lock()
+ f.a.entries[f.host].route.Seed = `["seed"]`
+ f.a.mu.Unlock()
+ l := &execLog{}
+ f.a.BeforeExec = recordInto(f.b.fakeBackend, l)
+ res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
+ if res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ // start:instance is the fixture's incumbent, woken before the test
+ // opted into the identity capability.
+ if got := identityEvents(f.b.fakeBackend); !slices.Equal(got, []string{"start:instance", "record v2", "seed v2"}) {
+ t.Fatalf("events %v, want the pin recorded, then seeded", got)
+ }
+ if calls, _, _ := l.seen(); len(calls) != 1 {
+ t.Fatalf("records %v: the pin was recorded twice", calls)
+ }
+ if c, _ := f.b.identityCounts(); c != 0 {
+ t.Fatalf("%d live-link captures: a pinned wake runs its pin", c)
+ }
+}
+
+// A worker wake execs the identity it recorded at step 2b (onto), so a
+// repoint during the record cannot change the worker's build, and its
+// exec-time hook does not write the same build again.
+func TestAWorkerWakeExecsTheIdentityItRecorded(t *testing.T) {
+ a, b, _, _, _ := stagedActivator(t)
+ b.fakeBackend.setIdentity("v1")
+ l := &execLog{}
+ a.BeforeExec = func(ctx context.Context, host, build string) error {
+ b.fakeBackend.setLive("v2")
+ return l.hook(ctx, host, build)
+ }
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if got := b.calls(); len(got) != 1 || got[0] == nil || *got[0] != fakeIdentity("v1") {
+ t.Fatalf("staged spawns %v, want one onto the recorded v1", got)
+ }
+ if calls, _, _ := l.seen(); len(calls) != 1 || calls[0] != host+"@v1" {
+ t.Fatalf("records %v, want one of v1", calls)
+ }
+ if got := a.entryServing(host); got != fakeIdentity("v1") {
+ t.Fatalf("serving %+v, want the recorded v1", got)
+ }
+}
+
+// --- legacy sidecars (ruling R-EXEC-SIDECAR) ---
+
+// sidecarActivator is a flag-off service whose backend can start a
+// sidecar from a generation (planGenBackend) and pin an identity, with a
+// sidecar staged for every host.
+func sidecarActivator(t *testing.T, live string) (*Service, *planGenBackend) {
+ t.Helper()
+ b := newPlanGenBackend()
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg)
+ b.sidecarStaged = true
+ if live != "" {
+ b.fakeBackend.setIdentity(live)
+ }
+ return a, b
+}
+
+// crashSidecar kills host's legacy sidecar and runs the supervisor.
+func crashSidecar(t *testing.T, a *Service, host string) {
+ t.Helper()
+ a.mu.Lock()
+ e := a.entries[host]
+ var p *fakeProc
+ if e != nil {
+ p, _ = e.sidecar.(*fakeProc)
+ }
+ a.mu.Unlock()
+ if p == nil {
+ t.Fatalf("%s has no sidecar to crash", host)
+ }
+ p.kill()
+ a.sweepOnce()
+}
+
+// Flag off: the co-spawned sidecar runs the recorded file by path, not
+// whatever the live link names by then, and the wake's record covers it.
+func TestAFlagOffSidecarRunsTheRecordedFile(t *testing.T) {
+ a, b := sidecarActivator(t, "v1")
+ l := &execLog{}
+ a.BeforeExec = func(ctx context.Context, host, build string) error {
+ b.fakeBackend.setLive("v2")
+ return l.hook(ctx, host, build)
+ }
+ done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ sc := b.sidecarsOn()
+ if len(sc) != 1 || sc[0].StagedPath != fakeIdentity("v1").StagedPath || sc[0].Build != "v1" || b.sidecarStartCount() != 0 {
+ t.Fatalf("sidecars on %+v, %d live-link sidecar starts; want the recorded v1 by path", sc, b.sidecarStartCount())
+ }
+ if calls, _, _ := l.seen(); len(calls) != 1 {
+ t.Fatalf("records %v: the co-spawn wrote the wake's build again", calls)
+ }
+}
+
+// A supervisor restart is a new exec against the instance's data: it
+// records its generation's build first, and runs that file.
+func TestASidecarRestartRecordsItsBuild(t *testing.T) {
+ a, b := sidecarActivator(t, "v1")
+ l := &execLog{spawns: func() int { return len(b.sidecarsOn()) }}
+ a.BeforeExec = l.hook
+ host := "one.example.com"
+ done, err := a.beginRequest(context.Background(), identityRoute(t, host))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ b.fakeBackend.setLive("v2") // a repoint since the wake
+ crashSidecar(t, a, host)
+ waitFor(t, "the sidecar restart", func() bool { return len(b.sidecarsOn()) == 2 && a.sidecarSettled(host) })
+ calls, at, _ := l.seen()
+ if !slices.Equal(calls, []string{host + "@v1", host + "@v1"}) || at[1] != 1 {
+ t.Fatalf("records %v at sidecar counts %v, want the restart's v1 before its exec", calls, at)
+ }
+ if sc := b.sidecarsOn(); sc[1].StagedPath != fakeIdentity("v1").StagedPath {
+ t.Fatalf("restarted on %+v, want the generation's recorded file", sc[1])
+ }
+}
+
+// A refused sidecar record starts no sidecar and is logged as a sidecar
+// start failure; the instance keeps serving, at the wake and at a restart.
+func TestARefusedSidecarRecordStartsNoSidecar(t *testing.T) {
+ a, b := sidecarActivator(t, "v1")
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ host := "one.example.com"
+ done, err := a.beginRequest(context.Background(), identityRoute(t, host))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ l.mu.Lock()
+ l.fail = failAll
+ l.mu.Unlock()
+ crashSidecar(t, a, host)
+ waitFor(t, "the refused restart", func() bool {
+ calls, _, _ := l.seen()
+ return len(calls) == 2 && a.sidecarSettled(host)
+ })
+ if n := len(b.sidecarsOn()); n != 1 || b.sidecarStartCount() != 0 {
+ t.Fatalf("%d generation sidecars, %d live-link sidecars after a refused record", n, b.sidecarStartCount())
+ }
+ if _, ok := a.entryGen(host); !ok {
+ t.Fatal("the instance stopped serving over its sidecar's record")
+ }
+
+ // At the wake: a replanned build is recorded again by its sidecar (the
+ // wake's context names the first plan's), and that record is refused.
+ dir := t.TempDir()
+ stale, next := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
+ pa, pb, _ := planActivator(t, WakePlan{})
+ pb.fakeBackend.setIdentity("v1")
+ pb.sidecarStaged = true
+ var plans atomic.Int32
+ pa.Planner = func(context.Context, registry.Route) WakePlan {
+ if plans.Add(1) == 1 {
+ return WakePlan{Boot: stale, Mode: ModeOverlap, Check: func(string) error { return errors.New("superseded") }}
+ }
+ return WakePlan{Boot: next, Mode: ModeOverlap}
+ }
+ var n atomic.Int32
+ pl := &execLog{fail: func(string) bool { return n.Add(1) == 3 }}
+ pa.BeforeExec = pl.hook
+ done, err = pa.beginRequest(context.Background(), identityRoute(t, host))
+ if err != nil {
+ t.Fatalf("the wake failed over its sidecar's record: %v", err)
+ }
+ done()
+ if calls, _, _ := pl.seen(); !slices.Equal(calls, []string{host + "@v2", host + "@v3", host + "@v3"}) {
+ t.Fatalf("records %v, want the wake's, the replanned spawn's and the sidecar's", calls)
+ }
+ if sc := pb.sidecarsOn(); len(sc) != 0 || pb.sidecarStartCount() != 0 {
+ t.Fatalf("sidecars %+v (%d live): a sidecar ran unrecorded", sc, pb.sidecarStartCount())
+ }
+ if g, ok := pa.entryGen(host); !ok || g.Build != "v3" {
+ t.Fatalf("serving %+v (%v), want v3", g, ok)
+ }
+}
+
+// An instance whose generation names no staged file (awake from before
+// this binary): its sidecar resolves the live link once, records that
+// build, and runs that file by path.
+func TestASidecarOnAnUnstagedGenerationResolvesRecordsAndRunsByPath(t *testing.T) {
+ a, b := sidecarActivator(t, "")
+ host := "one.example.com"
+ done, err := a.beginRequest(context.Background(), identityRoute(t, host))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if g, _ := a.entryGen(host); g.StagedPath != "" || b.sidecarStartCount() != 1 {
+ t.Fatalf("setup: generation %+v, %d live-link sidecars", g, b.sidecarStartCount())
+ }
+ b.fakeBackend.setIdentity("v1")
+ l := &execLog{}
+ a.BeforeExec = l.hook
+ b.lastSidecar().kill()
+ a.sweepOnce()
+ waitFor(t, "the sidecar restart", func() bool { return len(b.sidecarsOn()) == 1 && a.sidecarSettled(host) })
+ if sc := b.sidecarsOn(); sc[0].StagedPath != fakeIdentity("v1").StagedPath || sc[0].Build != "v1" {
+ t.Fatalf("restarted on %+v, want the resolved v1 by path", sc[0])
+ }
+ if calls, _, _ := l.seen(); !slices.Equal(calls, []string{host + "@v1"}) {
+ t.Fatalf("records %v, want the resolved v1", calls)
+ }
+ if c, _ := b.identityCounts(); c != 1 || b.sidecarStartCount() != 1 {
+ t.Fatalf("captures %d, live-link sidecars %d: want one capture and no new live-link start", c, b.sidecarStartCount())
+ }
+}
+
+// Flag on: a sidecar attached late to the incumbent and its replacement
+// after an overlap switch each record their generation's build before
+// their exec (the replacement runs off any wake's context, so it writes).
+func TestASidecarReplacementRecordsTheNewBuild(t *testing.T) {
+ f := newSwapFixture(t)
+ f.b.sidecarStaged = true
+ l := &execLog{spawns: func() int { return len(f.b.sidecarGenPaths()) }}
+ f.a.BeforeExec = l.hook
+ f.a.sweepOnce()
+ waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 })
+ if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v", res)
+ }
+ waitFor(t, "the replacement", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 2 })
+ calls, at, _ := l.seen()
+ want := []string{f.host + "@v1", f.host + "@v2", f.host + "@v2"}
+ if !slices.Equal(calls, want) || at[0] != 0 || at[2] != 1 {
+ t.Fatalf("records %v at sidecar counts %v, want %v: the attachment's v1, the successor's v2, then the replacement's v2 before its exec", calls, at, want)
+ }
+ if paths := f.b.sidecarGenPaths(); paths[1] != f.staged {
+ t.Fatalf("replacement ran %q, want %q", paths[1], f.staged)
+ }
+}