activator: a declined box pins the live link once, and a refused sidecar runs the instance's build
A legacy sidecar on an unstaged generation whose live link names a build the declined gate refuses now tries the instance's own build: BuildIdentity names and hashes its staged copy (VersionPath under the row's account; SplitBackend forwards it for exec rows), the gate is asked about it, and it runs only if both pass. Otherwise the original refusal stands. A held build never runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+263
−35
internal/activator/activator.go+49 −4internal/activator/backend_exec.go+34 −3internal/activator/declinedgate_identity_test.go+80 −28internal/activator/identity.go+33 −0internal/activator/identity_test.go+45 −0internal/activator/wakeidentity_test.go+22 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index a13216dd6..d815c595e 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -2335,9 +2335,11 @@ func (a *Service) bounceLocked(e *actEntry) bool { |
| // 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 |
| +// before this binary, or the recovery probe's) has the live link resolved |
| +// once, and that file run by path. On a declined box whose gate refuses |
| +// that build, the instance's own build runs instead when the gate passes |
| +// it (sidecarOwnBuild); otherwise the refusal stands. 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, |
| @@ -2390,9 +2392,16 @@ func (a *Service) startSidecar(ctx context.Context, e *actEntry, rt registry.Rou |
| // check (startSidecarChild), so a declined box's live exec gate is |
| // asked here, before the record (askLiveExecGate). A refusal starts |
| // nothing, as the backend's would, and the supervisor backs off. |
| + // A refused link whose build is not the instance's own falls back |
| + // to the instance's build (sidecarOwnBuild), so a sidecar is not |
| + // stranded beside an instance the gate already let run. |
| if id.Version != "" { |
| if err := a.askLiveExecGate(rt, id.Version); err != nil { |
| - return nil, err |
| + own, ok := a.sidecarOwnBuild(rt, g.Build, id.Version) |
| + if !ok { |
| + return nil, err |
| + } |
| + id = own |
| } |
| } |
| g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand |
| @@ -2413,6 +2422,42 @@ func (a *Service) startSidecar(ctx context.Context, e *actEntry, rt registry.Rou |
| return s.StartSidecarGen(ctx, rt, g) |
| } |
| +// sidecarOwnBuild is an unstaged sidecar's recovery when the declined gate |
| +// refuses the build its live link names (readiness 2 Task 5): root |
| +// repointed the link at a held build while the instance kept serving its |
| +// own, and the supervisor would otherwise retry the held build forever. own |
| +// is the generation's build (the instance's), refused the link's. When own |
| +// is a different, named build, its staged copy is named and hashed through |
| +// the backend (buildIdentifier) and the gate is asked about it too; both |
| +// passing returns that identity, which startSidecar then records and runs |
| +// by path as it would the link's. Anything else reports false, and the |
| +// caller keeps the original refusal. A held build is never run: the gate |
| +// has passed whatever this returns. The hash runs outside a.mu, as the |
| +// link's own capture does. |
| +func (a *Service) sidecarOwnBuild(rt registry.Route, own, refused string) (release.StagedIdentity, bool) { |
| + if own == "" || own == refused { |
| + return release.StagedIdentity{}, false |
| + } |
| + bi, ok := a.backend.(buildIdentifier) |
| + if !ok { |
| + return release.StagedIdentity{}, false |
| + } |
| + id, err := bi.BuildIdentity(rt, own) |
| + if err != nil { |
| + log.Printf("activator: %s: sidecar not started: this box will not start %s, the build its live link names, and the instance's own build %s cannot be named: %v", rt.Host, refused, own, err) |
| + return release.StagedIdentity{}, false |
| + } |
| + if id.Version != own { |
| + return release.StagedIdentity{}, false |
| + } |
| + if err := a.askLiveExecGate(rt, id.Version); err != nil { |
| + log.Printf("activator: %s: sidecar not started: this box will not start %s, the build its live link names, nor the instance's own build %s: %v", rt.Host, refused, own, err) |
| + return release.StagedIdentity{}, false |
| + } |
| + log.Printf("activator: %s: sidecar starting on the instance's own build %s (%s): this box will not start %s, the build its live link names", rt.Host, id.Version, id.StagedPath, refused) |
| + return id, true |
| +} |
| + |
| // replaceSidecar is §7.5 step 8: a legacy co-spawned sidecar is restarted, |
| // stop-then-start, onto the generation just switched to. It claims the |
| // supervisor's interlock (sidecarStarting, sidecarStartDone) for the whole |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index e011c9281..a7f6d8316 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -204,9 +204,11 @@ type ExecBackend struct { |
| // Service.SetLiveExecGate, before Start), so the plan-time refusal is |
| // repeated at the real launch with the build actually being started |
| // (Astra R1 #6, the R2/R3 declined residuals). It is asked by a flat |
| - // instance's live-link spawn (which then execs the staged path it |
| - // resolved, not the link), a nested instance's live-link start, and a |
| - // worker's spawn from the live link. Nil, on every other box: each of |
| + // instance's or sidecar's live-link spawn (which then execs the staged |
| + // path it resolved, not the link), a nested instance's or sidecar's |
| + // live-link start and a seed from the live link (each pins the link |
| + // first, declinedLivePin, and runs the file it pinned), and a worker's |
| + // spawn from the live link. Nil, on every other box: each of |
| // those spawns is exactly as it was. It reads only memory, the row it |
| // is handed and the live link, and writes nothing: the activator asks |
| // it under its own lock too (convergeWorkerLocked). |
| @@ -1377,6 +1379,35 @@ func (b *ExecBackend) declinedLivePin(ctx context.Context, rt registry.Route) (r |
| return id, nil |
| } |
| +// BuildIdentity is buildIdentifier's answer: build's staged executable of |
| +// rt's app on this box, at the path root's validation expects |
| +// (release.VersionPath under the row's account; instancectl's |
| +// validateStagedExe), hashed now. It reads no link. An invalid version, an |
| +// app name that is not one path segment, or a copy that is not a regular |
| +// file it can read names nothing. Whether build may run is the caller's |
| +// question (the live exec gate), not this one's. |
| +func (b *ExecBackend) BuildIdentity(rt registry.Route, build string) (release.StagedIdentity, error) { |
| + if !release.ValidVersion(build) { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: %q is not a build version", rt.Host, build) |
| + } |
| + if rt.App == "" || rt.App == "." || rt.App == ".." || filepath.Base(rt.App) != rt.App { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: app %q names no staged copy", rt.Host, rt.App) |
| + } |
| + path := release.VersionPath(b.OptDir, release.RouteAccount(rt), build, rt.App) |
| + fi, err := os.Lstat(path) |
| + if err != nil { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %w", rt.Host, build, err) |
| + } |
| + if !fi.Mode().IsRegular() { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %s is not a regular file", rt.Host, build, path) |
| + } |
| + sum, err := release.FileSHA256(path) |
| + if err != nil { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: build %s: %w", rt.Host, build, err) |
| + } |
| + return release.StagedIdentity{Version: build, StagedPath: path, Digest: sum}, nil |
| +} |
| + |
| // Start dispatches a wake by layout (design §8.1): a nested row starts as a |
| // unit through the root instance-control channel, a fenced or conflicting |
| // row refuses outright, and a flat or foreign row runs today's exec child. |
diff --git a/internal/activator/declinedgate_identity_test.go b/internal/activator/declinedgate_identity_test.go| index b78303d57..79ee5dc7d 100644 |
| --- a/internal/activator/declinedgate_identity_test.go |
| +++ b/internal/activator/declinedgate_identity_test.go |
| @@ -83,34 +83,86 @@ func TestTheDeclinedGateIsAskedAboutTheCapturedWorkerWake(t *testing.T) { |
| } |
| // A sidecar whose generation names no staged file resolves the live link |
| -// and runs that file by path: the gate is asked about it first, and a |
| -// refusal records and starts nothing (the instance keeps serving). |
| +// and runs that file by path: the gate is asked about it first. When the |
| +// gate refuses the link's build (root repointed it at held v2 while the |
| +// instance kept serving v1), the sidecar runs the instance's own build |
| +// instead, once that build's staged copy is named (BuildIdentity) and the |
| +// gate passes it too (readiness 2 Task 5, recon A §3a): otherwise nothing |
| +// ever restarts it. With the instance's build refused as well, or its copy |
| +// unreadable, the refusal stands: nothing is recorded or started, and the |
| +// instance keeps serving. |
| func TestTheDeclinedGateIsAskedAboutAnUnstagedSidecarsCapture(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 gen, _ := a.entryGen(host); gen.StagedPath != "" || b.sidecarStartCount() != 1 { |
| - t.Fatalf("setup: generation %+v, %d live-link sidecars", gen, b.sidecarStartCount()) |
| - } |
| - b.fakeBackend.setIdentity("v2") |
| - l := &execLog{} |
| - a.BeforeExec = l.hook |
| - g := &liveGate{refuse: map[string]bool{"v2": true}} |
| - a.SetLiveExecGate(g.gate) |
| - b.lastSidecar().kill() |
| - a.sweepOnce() |
| - waitFor(t, "the refused restart", func() bool { return len(g.builds()) > 0 && a.sidecarSettled(host) }) |
| - if sc := b.sidecarsOn(); len(sc) != 0 || b.sidecarStartCount() != 1 { |
| - t.Fatalf("sidecars %+v, %d live-link: held v2 ran beside the instance", sc, b.sidecarStartCount()) |
| - } |
| - if calls, _, _ := l.seen(); len(calls) != 0 { |
| - t.Fatalf("records %v: a build the box will not start was recorded", calls) |
| - } |
| - if _, ok := a.entryGen(host); !ok { |
| - t.Fatal("the instance stopped serving over its sidecar's refusal") |
| - } |
| + setup := func(t *testing.T, refuse map[string]bool, ownErr error) (*Service, *planGenBackend, *liveGate, *execLog) { |
| + t.Helper() |
| + a, b := sidecarActivator(t, "") |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if gen, _ := a.entryGen(host); gen.StagedPath != "" || b.sidecarStartCount() != 1 { |
| + t.Fatalf("setup: generation %+v, %d live-link sidecars", gen, b.sidecarStartCount()) |
| + } |
| + // The instance serves v1 from a generation that names no staged |
| + // file (woken before this binary, or the recovery probe's). |
| + a.mu.Lock() |
| + a.entries[host].gen.Build = "v1" |
| + a.mu.Unlock() |
| + b.fakeBackend.setIdentity("v2") |
| + b.fakeBackend.mu.Lock() |
| + b.fakeBackend.ident.buildIDErr = ownErr |
| + b.fakeBackend.mu.Unlock() |
| + l := &execLog{} |
| + a.BeforeExec = l.hook |
| + g := &liveGate{refuse: refuse} |
| + a.SetLiveExecGate(g.gate) |
| + b.lastSidecar().kill() |
| + a.sweepOnce() |
| + return a, b, g, l |
| + } |
| + stranded := func(t *testing.T, a *Service, b *planGenBackend, g *liveGate, l *execLog, asked []string) { |
| + t.Helper() |
| + waitFor(t, "the refused restart", func() bool { return len(g.builds()) >= len(asked) && a.sidecarSettled(host) }) |
| + if sc := b.sidecarsOn(); len(sc) != 0 || b.sidecarStartCount() != 1 { |
| + t.Fatalf("sidecars %+v, %d live-link: a held build ran beside the instance", sc, b.sidecarStartCount()) |
| + } |
| + if calls, _, _ := l.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a build the box will not start was recorded", calls) |
| + } |
| + if got := g.builds(); !slices.Equal(got, asked) { |
| + t.Fatalf("gate asked %v, want %v", got, asked) |
| + } |
| + if _, ok := a.entryGen(host); !ok { |
| + t.Fatal("the instance stopped serving over its sidecar's refusal") |
| + } |
| + } |
| + t.Run("the instance's own build runs", func(t *testing.T) { |
| + a, b, g, l := setup(t, map[string]bool{"v2": true}, nil) |
| + waitFor(t, "the sidecar on v1", func() bool { return len(b.sidecarsOn()) == 1 && a.sidecarSettled(host) }) |
| + own := fakeIdentity("v1") |
| + if sc := b.sidecarsOn()[0]; sc.Build != "v1" || sc.StagedPath != own.StagedPath || sc.Digest != own.Digest { |
| + t.Fatalf("sidecar on %+v, want the instance's own v1 (%+v)", sc, own) |
| + } |
| + if got := g.builds(); !slices.Equal(got, []string{"v2", "v1"}) { |
| + t.Fatalf("gate asked %v, want [v2 v1]", got) |
| + } |
| + if got := b.buildIdentities(); !slices.Equal(got, []string{"v1"}) { |
| + t.Fatalf("BuildIdentity asked %v, want [v1]", got) |
| + } |
| + if calls, _, _ := l.seen(); !slices.Equal(calls, []string{host + "@v1"}) { |
| + t.Fatalf("records %v, want v1 recorded before its sidecar ran", calls) |
| + } |
| + if b.sidecarStartCount() != 1 { |
| + t.Fatalf("%d live-link sidecars: the recovery used the live link", b.sidecarStartCount()) |
| + } |
| + }) |
| + t.Run("the instance's own build is held too", func(t *testing.T) { |
| + a, b, g, l := setup(t, map[string]bool{"v2": true, "v1": true}, nil) |
| + stranded(t, a, b, g, l, []string{"v2", "v1"}) |
| + }) |
| + t.Run("the instance's own copy cannot be named", func(t *testing.T) { |
| + a, b, g, l := setup(t, map[string]bool{"v2": true}, errors.New("staged copy unreadable")) |
| + stranded(t, a, b, g, l, []string{"v2"}) |
| + }) |
| } |
diff --git a/internal/activator/identity.go b/internal/activator/identity.go| index 1fe3575ff..4ca81c55a 100644 |
| --- a/internal/activator/identity.go |
| +++ b/internal/activator/identity.go |
| @@ -2,6 +2,8 @@ package activator |
| import ( |
| "context" |
| + "errors" |
| + "fmt" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -61,6 +63,37 @@ func (b *SplitBackend) identityFor(rt registry.Route) identityBackend { |
| return identityFor(b.pick(rt), rt) |
| } |
| +// buildIdentifier names build's staged executable of rt's app on this box, |
| +// hashed now: VersionPath(OptDir, RouteAccount(rt), build, rt.App) and its |
| +// sha256, refused for an invalid version or an unreadable file. A legacy |
| +// sidecar whose live link names a build the declined gate refuses runs its |
| +// instance's own build through it instead (Service.startSidecar). |
| +type buildIdentifier interface { |
| + BuildIdentity(rt registry.Route, build string) (release.StagedIdentity, error) |
| +} |
| + |
| +var ( |
| + _ buildIdentifier = (*ExecBackend)(nil) |
| + _ buildIdentifier = (*SplitBackend)(nil) |
| +) |
| + |
| +// errUnitNoBuildIdentity refuses BuildIdentity for a unit row: systemd |
| +// execs its live link itself, so no staged file can be named for it. |
| +var errUnitNoBuildIdentity = errors.New("activator: a unit-backed row runs its live link, so no staged build can be named for it") |
| + |
| +// BuildIdentity forwards to the exec half for exec rows. A unit row, or an |
| +// exec half that cannot name a build, refuses. |
| +func (b *SplitBackend) BuildIdentity(rt registry.Route, build string) (release.StagedIdentity, error) { |
| + if rt.Backing == registry.BackingUnit { |
| + return release.StagedIdentity{}, errUnitNoBuildIdentity |
| + } |
| + bi, ok := b.Exec.(buildIdentifier) |
| + if !ok { |
| + return release.StagedIdentity{}, fmt.Errorf("%s: the exec backend cannot name a staged build", rt.Host) |
| + } |
| + return bi.BuildIdentity(rt, build) |
| +} |
| + |
| // stagedExeOf is id as root's control channel names an executable. |
| func stagedExeOf(id release.StagedIdentity) *instancectl.StagedExe { |
| return &instancectl.StagedExe{Path: id.StagedPath, Version: id.Version, Digest: id.Digest} |
diff --git a/internal/activator/identity_test.go b/internal/activator/identity_test.go| index 71aa69e4c..b3e580090 100644 |
| --- a/internal/activator/identity_test.go |
| +++ b/internal/activator/identity_test.go |
| @@ -672,6 +672,51 @@ func TestADeclinedNestedSeedRunsThePinnedFile(t *testing.T) { |
| } |
| } |
| +// BuildIdentity names build's staged copy of rt's app at the path root's |
| +// validation expects (VersionPath under the row's account) and hashes it |
| +// now. An invalid version or app, or a missing copy, names nothing. The |
| +// SplitBackend forwards it for exec rows and refuses unit rows. |
| +func TestBuildIdentityNamesTheStagedCopy(t *testing.T) { |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "own.example.com", App: "blog"} |
| + path := release.VersionPath(b.OptDir, release.RouteAccount(rt), "v1", "blog") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("#!/bin/sh\n# v1\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum, err := release.FileSHA256(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := release.StagedIdentity{Version: "v1", StagedPath: path, Digest: sum} |
| + if id, err := b.BuildIdentity(rt, "v1"); err != nil || id != want { |
| + t.Fatalf("BuildIdentity = %+v, %v; want %+v", id, err, want) |
| + } |
| + for _, bad := range []string{"", "..", "../v1", "v1/blog", "v9"} { |
| + if id, err := b.BuildIdentity(rt, bad); err == nil { |
| + t.Errorf("BuildIdentity(%q) = %+v, want a refusal", bad, id) |
| + } |
| + } |
| + for _, app := range []string{"", "..", "a/b"} { |
| + bad := rt |
| + bad.App = app |
| + if id, err := b.BuildIdentity(bad, "v1"); err == nil { |
| + t.Errorf("BuildIdentity for app %q = %+v, want a refusal", app, id) |
| + } |
| + } |
| + split := &SplitBackend{Exec: b, Unit: testExecBackend(t)} |
| + if id, err := split.BuildIdentity(rt, "v1"); err != nil || id != want { |
| + t.Fatalf("SplitBackend.BuildIdentity = %+v, %v; want %+v", id, err, want) |
| + } |
| + unitRow := rt |
| + unitRow.Backing = registry.BackingUnit |
| + if id, err := split.BuildIdentity(unitRow, "v1"); err == nil { |
| + t.Fatalf("a unit row's BuildIdentity = %+v, want a refusal", id) |
| + } |
| +} |
| + |
| // A control channel that cannot name an executable for the original |
| // template refuses a generation-0 start that names one. It never drops the |
| // file and binds the live link instead. |
diff --git a/internal/activator/wakeidentity_test.go b/internal/activator/wakeidentity_test.go| index 4959c0a12..b0affa772 100644 |
| --- a/internal/activator/wakeidentity_test.go |
| +++ b/internal/activator/wakeidentity_test.go |
| @@ -43,6 +43,28 @@ type identityFake struct { |
| onLiveIdentity func(*identityFake) |
| exe []exeCall |
| last release.StagedIdentity |
| + // buildIDs is every build BuildIdentity was asked to name, and |
| + // buildIDErr its answer when set (a staged copy that cannot be read). |
| + buildIDs []string |
| + buildIDErr error |
| +} |
| + |
| +// BuildIdentity is the fake's buildIdentifier: build's staged file as |
| +// fakeIdentity names it, or buildIDErr. |
| +func (b *fakeBackend) BuildIdentity(_ registry.Route, build string) (release.StagedIdentity, error) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.ident.buildIDs = append(b.ident.buildIDs, build) |
| + if b.ident.buildIDErr != nil { |
| + return release.StagedIdentity{}, b.ident.buildIDErr |
| + } |
| + return fakeIdentity(build), nil |
| +} |
| + |
| +func (b *fakeBackend) buildIdentities() []string { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return slices.Clone(b.ident.buildIDs) |
| } |
| type exeCall struct { |