carlos / platform Public

zdd-4 task 7: SidecarAdoptable, a second admission predicate; Adoptable not widened

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fdec1bf42540f35676fa2d1e00e2422c9b968ced parent a6cd71e
3 files changed, +241 −3
  • internal/release/adopt.go +36 −2
  • internal/release/materialize.go +2 −1
  • internal/release/sidecar_adopt_test.go +203 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 7117204f9..89bec3ec0 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -367,7 +367,7 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
return err
}
adopts := func(rt registry.Route) bool {
- return Adoptable(rt) && (extra == nil || extra(rt))
+ return (Adoptable(rt) || SidecarAdoptable(rt)) && (extra == nil || extra(rt))
}
selected := make(map[string]hostwrite.Work, len(routes))
for _, rt := range routes {
@@ -875,11 +875,15 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// Awake hosts first, hibernating second: repoints have no health gate, so
// let an awake host discover (and poison) a broken build before any
// sleeper is moved onto it.
- var hibernating []registry.Route
+ var hibernating, sidecars []registry.Route
for _, rt := range routes {
if !adopts(rt) {
continue
}
+ if rt.IsSidecar() {
+ sidecars = append(sidecars, rt)
+ continue
+ }
if rt.Hibernate {
hibernating = append(hibernating, rt)
continue
@@ -902,6 +906,17 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// binary its symlink no longer names.
adoptHost(ctx, selected[rt.Host], reg, s, optDir, bake, poisoned, rt, ptr, cfg.restartHibernating, now, cfg, routeHolds(rt), bakeIgnore(rt))
}
+ // Native sidecars LAST, and never through a restart callback (spec
+ // §7.9). The pass stages, repoints and stamps. The running worker moves
+ // when the activator's drift check sees the link change, which plan 5
+ // queues as a paced release attempt; a Bounce here would stop an always-on
+ // worker with nothing to start it. Last for the reason sleepers go after
+ // awake hosts: a repoint has no health gate, so a build an instance
+ // poisons this pass never reaches one.
+ for _, rt := range sidecars {
+ ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
+ adoptHost(ctx, selected[rt.Host], reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg, routeHolds(rt), bakeIgnore(rt))
+ }
return nil
}
@@ -929,6 +944,21 @@ func Adoptable(rt registry.Route) bool {
(rt.Status == "" || rt.Status == "ready")
}
+// SidecarAdoptable is the pass's SECOND admission predicate (zero-downtime
+// deploys spec §7.9): a native sidecar row (Kind "sidecar") of a known app,
+// not platform-minted, and ready. Its build follows the channel on its own
+// row, as sidecar_reconcile.go provisions it, through the same signed
+// selection, bake holds, staging, repoint and stamp an instance gets.
+//
+// Adoptable is deliberately NOT widened: capability drop-ins, the fast
+// sweep's membership, config sync, the restart passes and the console's
+// Restartable flag all keep their own answer for a sidecar. The pass opts in
+// by name, and only the pass.
+func SidecarAdoptable(rt registry.Route) bool {
+ return rt.IsSidecar() && rt.App != "" && !rt.Platform &&
+ (rt.Status == "" || rt.Status == "ready")
+}
+
// CapabilityRoute reports whether this route may materialize capabilities:
// a lifecycle-managed instance of a known app, unit-backed, non-platform,
// ready (spec §4e). It is Adoptable() plus the unit backing, deliberately —
@@ -1563,6 +1593,10 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
if cfg.bakeDir != "" {
_ = ClearRefusal(cfg.bakeDir, rt.Host)
}
+ if rt.IsSidecar() {
+ fmt.Printf(" %s: repointed %s -> %s (%s; sidecar, its worker moves onto it on the agent's drift check)\n", rt.Host, cur, ver, channel)
+ return
+ }
if rt.Hibernate {
// Two different facts, one grep. With no restart callback this pass
// genuinely only moved a symlink, and the next wake is when the new
diff --git a/internal/release/materialize.go b/internal/release/materialize.go
index c3617e128..474fd9f31 100644
--- a/internal/release/materialize.go
+++ b/internal/release/materialize.go
@@ -10,7 +10,8 @@ import (
)
// MaterializeHost stages host's channel head and points its live symlink —
-// the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5).
+// the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5),
+// for an instance or a native sidecar (zero-downtime deploys spec §7.9).
// It IS the adopt pass, filtered to one host with restart=nil (the host is
// cold; the activator execing it is the "restart"): bake holds, signature
// verification, staging, and the Version stamp are all the pass's own —
diff --git a/internal/release/sidecar_adopt_test.go b/internal/release/sidecar_adopt_test.go
new file mode 100644
index 000000000..1f85ce8a5
--- /dev/null
+++ b/internal/release/sidecar_adopt_test.go
@@ -0,0 +1,203 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func addSidecar(t *testing.T, reg *registry.Registry, host, app, channel string, hibernate bool) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "sidecar", App: app, Channel: channel, Hibernate: hibernate,
+ Args: `["sidecar","run"]`, Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", host, err)
+ }
+}
+
+func TestSidecarAdoptableIsASecondPredicateNotAWiderOne(t *testing.T) {
+ base := registry.Route{Host: "bot.example.com", Kind: "sidecar", App: "myapp", Status: "ready"}
+ for _, c := range []struct {
+ name string
+ mut func(*registry.Route)
+ sidecar, instance bool
+ }{
+ {"ready sidecar", func(*registry.Route) {}, true, false},
+ {"status empty", func(r *registry.Route) { r.Status = "" }, true, false},
+ {"no app", func(r *registry.Route) { r.App = "" }, false, false},
+ {"platform", func(r *registry.Route) { r.Platform = true }, false, false},
+ {"creating", func(r *registry.Route) { r.Status = "creating" }, false, false},
+ {"an instance", func(r *registry.Route) { r.Kind = "instance" }, false, true},
+ {"static", func(r *registry.Route) { r.Kind = "static" }, false, false},
+ } {
+ rt := base
+ c.mut(&rt)
+ if got := SidecarAdoptable(rt); got != c.sidecar {
+ t.Errorf("%s: SidecarAdoptable = %v, want %v", c.name, got, c.sidecar)
+ }
+ if got := Adoptable(rt); got != c.instance {
+ t.Errorf("%s: Adoptable = %v, want %v (Adoptable is not widened)", c.name, got, c.instance)
+ }
+ }
+ unit := base
+ unit.Backing = registry.BackingUnit
+ if CapabilityRoute(unit) {
+ t.Error("CapabilityRoute admitted a sidecar: no capability drop-ins are ever written for one")
+ }
+}
+
+// Spec §7.9: sidecars go through the same selection, staging, repoint and
+// stamp, and the pass never restarts them; RestartHibernating changes nothing
+// for them.
+func TestPassAdoptsASidecarWithoutRestartingIt(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, "web.example.com", "myapp", "canary/x", false)
+ addSidecar(t, reg, "bot.example.com", "myapp", "canary/x", false)
+ addSidecar(t, reg, "nap.example.com", "myapp", "canary/x", true)
+ var restarted []string
+ restart := func(h string) error { restarted = append(restarted, h); return nil }
+ if err := Pass(ctx, reg, s, pub, opt, restart, RestartHibernating(restart)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ for _, h := range []string{"web.example.com", "bot.example.com", "nap.example.com"} {
+ if v, _ := LiveVersion(opt, h); v != "v1" {
+ t.Errorf("%s live = %q, want v1", h, v)
+ }
+ if rt, _ := reg.Get(h); rt == nil || rt.Version != "v1" {
+ t.Errorf("%s row = %+v, want Version v1 (root's stamp)", h, rt)
+ }
+ }
+ if !reflect.DeepEqual(restarted, []string{"web.example.com"}) {
+ t.Fatalf("restarted = %v; the pass must never restart a sidecar", restarted)
+ }
+}
+
+// Sidecars go after every instance, so a build an instance poisons this pass
+// never reaches a sidecar.
+func TestSidecarIsNotMovedOntoABuildAnInstancePoisoned(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("good"))
+ shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("bad"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, "web.example.com", "myapp", "canary/x", false)
+ addSidecar(t, reg, "bot.example.com", "myapp", "canary/x", false)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ restart := func(h string) error {
+ if h == "web.example.com" {
+ return errors.New("did not come up")
+ }
+ return nil
+ }
+ if err := Pass(ctx, reg, s, pub, opt, restart); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := LiveVersion(opt, "bot.example.com"); v != "v1" {
+ t.Fatalf("sidecar live = %q, want v1: a build that failed an instance this pass must not reach it", v)
+ }
+}
+
+// Spec §7.9: sidecars take the same bake holds.
+func TestSidecarWaitsOutItsBakeWindow(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil {
+ t.Fatal(err)
+ }
+ addSidecar(t, reg, "bot.example.com", "myapp", "stable", false)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := LiveVersion(opt, "bot.example.com"); v != "" {
+ t.Fatalf("sidecar live = %q, want unmoved inside stable's bake window", v)
+ }
+}
+
+// Spec §7.9: cold materialization of a new sidecar uses the same leg.
+func TestMaterializeHostMaterializesANewSidecar(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addSidecar(t, reg, "bot.example.com", "myapp", "canary/x", true)
+ if err := MaterializeHost(ctx, reg, s, pub, opt, t.TempDir(), "bot.example.com"); err != nil {
+ t.Fatalf("MaterializeHost: %v", err)
+ }
+ if _, err := os.Stat(LiveLink(opt, "bot.example.com")); err != nil {
+ t.Fatalf("live link does not resolve: %v", err)
+ }
+}
+
+// Unchanged behaviour: what happens to instances does not depend on whether
+// the box also carries a sidecar.
+func TestInstanceOutcomesDoNotDependOnTheSidecarLeg(t *testing.T) {
+ type outcome struct {
+ restarted []string
+ live, row string
+ }
+ run := func(withSidecar bool) outcome {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, "web.example.com", "myapp", "canary/x", false)
+ addRoute(t, reg, "nap.example.com", "myapp", "canary/x", true)
+ if withSidecar {
+ addSidecar(t, reg, "bot.example.com", "myapp", "canary/x", false)
+ }
+ var o outcome
+ restart := func(h string) error { o.restarted = append(o.restarted, h); return nil }
+ if err := Pass(ctx, reg, s, pub, opt, restart, RestartHibernating(restart)); err != nil {
+ t.Fatal(err)
+ }
+ o.live, _ = LiveVersion(opt, "web.example.com")
+ if rt, _ := reg.Get("nap.example.com"); rt != nil {
+ o.row = rt.Version
+ }
+ return o
+ }
+ without, with := run(false), run(true)
+ if !reflect.DeepEqual(without, with) {
+ t.Fatalf("instance outcomes changed when a sidecar was present:\nwithout %+v\nwith %+v", without, with)
+ }
+}