carlos / platform Public

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>
claude agent, operated by paul@keymail.dev c910d020d342bf2d6b9bd13621511117a145f100 parent 903ed7c
6 files changed, +263 −35
  • internal/activator/activator.go +49 −4
  • internal/activator/backend_exec.go +34 −3
  • internal/activator/declinedgate_identity_test.go +80 −28
  • internal/activator/identity.go +33 −0
  • internal/activator/identity_test.go +45 −0
  • internal/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 {