carlos / platform Public

host: carlos restart -app X cycles sidecars; document Bounce for workers

Paul Campbell pushed by paul@keymail.dev 4e28ec9817491450c4f793006db01ff7e50dc15c parent b2d1b5f
4 files changed, +252 −17
  • internal/activator/activator.go +26 −1
  • internal/host/agent.go +13 −1
  • internal/host/restart.go +86 −12
  • internal/host/restart_test.go +127 −3
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 8342a704e..4ff0c1f2f 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -977,6 +977,27 @@ func (a *Service) Owns(host string) bool {
// caveat that in-flight requests are not waited for. It never starts
// anything: not-awake (waking, stopping, asleep, unknown) is a no-op,
// and the instance picks the new config up at its natural next wake.
+//
+// NOT kind-gated (Task 11 item 4, carried from Task 6's review) — e's
+// entry has no branch on e.worker, so this stops an awake WORKER exactly
+// as it stops an instance, and stop() itself is worker-aware (it skips
+// the socket-only next-due probe, co-stops any attached sidecar first,
+// etc.), so nothing here needs a kind check to behave correctly. The
+// EXPLICIT decision this makes is what it deliberately does NOT do: start
+// anything back up. For an instance that is exactly right — the next
+// HTTP request is the wake half of the cycle, and Bounce owns only the
+// stop half. A worker has no such request path; nothing dials one. A
+// caller that wants a WORKER actually cycled — not just stopped — must
+// pair this call with its own WakeWorker once Bounce returns true (see
+// host/restart.go's cycleWorkers, the one caller that does): an
+// always-on (Hibernate: false) worker left unpaired is stranded until the
+// agent restarts (the boot pass that starts one runs once, at arming,
+// never again), and a hibernating one stays silent until its schedule
+// next comes due — neither of which "carlos restart" asked for. This is
+// why SyncExec (appconfig/syncexec.go) never calls Bounce for a sidecar
+// route at all: an automatic, unpaired bounce on every ordinary
+// config-sync pass would carry exactly that risk with no operator command
+// behind it.
func (a *Service) Bounce(host string) bool {
a.mu.Lock()
defer a.mu.Unlock()
@@ -984,7 +1005,11 @@ func (a *Service) Bounce(host string) bool {
if e == nil || e.state != actAwake {
return false
}
- emitEvent(a.Logs, e.route, "info", "instance restarting (carlos restart)")
+ msg := "instance restarting (carlos restart)"
+ if e.worker {
+ msg = "sidecar restarting (carlos restart)"
+ }
+ emitEvent(a.Logs, e.route, "info", msg)
a.beginStopLocked(e)
return true
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 9c196c2f2..7b14e2087 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1340,6 +1340,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
restartCache := map[[2]string]string{}
restartSeed(ctx, reg, store, dd, restartCache)
restartBounce := bounceFn(svc)
+ restartWake := wakeFn(svc)
d := &fastDeps{
reg: reg, store: store, pub: pub, optDir: optDir,
restart: restart, passOpts: opts,
@@ -1372,7 +1373,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
log.Printf("agent: restart check: list routes: %v", err)
return
}
- restartRequestLeg(ctx, routes, store, dd, restartBounce, restartCache)
+ restartRequestLeg(ctx, routes, store, dd, restartBounce, restartWake, restartCache)
},
}
log.Printf("agent: fast converge loop armed (2s + jitter: serial poll + canary/* + unstaged routes)")
@@ -2321,6 +2322,17 @@ func bounceFn(svc *activator.Service) func(host string) bool {
return svc.Bounce
}
+// wakeFn adapts an activator to cycleWorkers' wake parameter, nil-safe like
+// bounceFn: a box with no activator armed gets a nil callback, which
+// cycleWorkers reads as "nothing to wake" rather than panicking on a nil
+// activator.
+func wakeFn(svc *activator.Service) func(registry.Route) error {
+ if svc == nil {
+ return nil
+ }
+ return svc.WakeWorker
+}
+
// openDeployStore opens the release store the box adopts from:
// CARLOS_DEPLOYMENT_BUCKET (S3), CARLOS_DEPLOYMENT_DIR (a directory — tests
// and single-box no-AWS deployments), or — on a fleet box with neither set —
diff --git a/internal/host/restart.go b/internal/host/restart.go
index 7ebe61726..fd2e7febd 100644
--- a/internal/host/restart.go
+++ b/internal/host/restart.go
@@ -59,24 +59,49 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// restartEligible widens release.Adoptable BY NAME, for this feature alone:
+// a route release.Adoptable would cycle, plus a sidecar. release.Adoptable
+// itself is deliberately NOT changed — it is the most reused predicate in
+// the repo (adopt membership, capability convergence, socket and backing
+// advisories, status pages), and every one of those is correct to exclude
+// a sidecar. Widening it centrally would silently enrol sidecars in
+// capability drop-ins and adopt bookkeeping that make no sense for them.
+// `carlos restart -app X` genuinely wants sidecars too (design §9.3), so it
+// opts in here, explicitly, rather than through Adoptable itself.
+func restartEligible(rt registry.Route) bool {
+ return release.Adoptable(rt) || rt.IsSidecar()
+}
+
+// restartCandidates filters routes to the ones the restart feature may
+// cycle: restartEligible, applied. Exported to this package's tests as the
+// single predicate restartApps, bounceExecRoutes' membership check and
+// cycleWorkers all trace back to, so the three cannot silently diverge on
+// who counts.
+func restartCandidates(routes []registry.Route) []registry.Route {
+ var out []registry.Route
+ for _, rt := range routes {
+ if restartEligible(rt) {
+ out = append(out, rt)
+ }
+ }
+ return out
+}
+
// restartApps lists the distinct (account, app) pairs with at least one
// restartable route on this box — the apps whose restart generation this box
// reads when a serial bump (or an hourly pass) gives it a reason to.
//
-// The membership predicate is release.Adoptable, deliberately the SAME one the
-// adopt pass moves and the fast sweep counts: a route a pass would not adopt
-// is not one whose process this feature has any business cycling, and a second
-// copy of that judgement here is exactly the drift that would let the two
-// disagree. It admits addr-backed routes that name their own unit (issue
-// #110's consoles) — which is half of why #113 exists — because Adoptable
-// filters on lifecycle and app, never on backing.
+// The membership predicate is restartCandidates (release.Adoptable plus a
+// sidecar — see its doc comment): a route neither would cycle is not one
+// whose process this feature has any business reading a restart signal
+// for, and a second copy of that judgement here is exactly the drift that
+// would let them disagree. It admits addr-backed routes that name their
+// own unit (issue #110's consoles) — which is half of why #113 exists —
+// because Adoptable filters on lifecycle and app, never on backing.
func restartApps(routes []registry.Route) [][2]string {
seen := map[[2]string]bool{}
var out [][2]string
- for _, rt := range routes {
- if !release.Adoptable(rt) {
- continue
- }
+ for _, rt := range restartCandidates(routes) {
k := [2]string{release.RouteAccount(rt), rt.App}
if seen[k] {
continue
@@ -203,6 +228,49 @@ func bounceExecRoutes(routes []registry.Route, moved [][2]string, bounce func(ho
return n
}
+// cycleWorkers is bounceExecRoutes' sidecar counterpart, and deliberately a
+// separate function rather than a widened bounceExecRoutes: a sidecar is
+// activator-managed exactly like a socket-exec instance, but Bounce alone
+// cannot cycle one the way it cycles an instance. Nothing dials a worker
+// the way a request dials an instance — an instance's next request supplies
+// the wake half of "stop now, respawn with fresh config"; a worker stopped
+// here has no other wake source at all (an always-on one is stranded until
+// the agent restarts, since the boot pass that starts it runs once, at
+// arming, never again; a hibernating one stays silent until its schedule
+// next comes due, which "carlos restart" did not ask for). Stop and wake
+// are therefore ONE operation for a sidecar — see
+// activator.Service.Bounce's own doc comment, which states the same
+// contract from the activator's side.
+//
+// wake is called ONLY for a host bounce actually stopped (true): a sidecar
+// that was already asleep is left asleep, the same "never wake a sleeper"
+// rule bounceExecRoutes' own HIBERNATE handling already gives an instance.
+// A nil bounce or wake (no activator armed) is a working no-op.
+func cycleWorkers(routes []registry.Route, moved [][2]string, bounce func(host string) bool, wake func(rt registry.Route) error) int {
+ if bounce == nil || wake == nil || len(moved) == 0 {
+ return 0
+ }
+ want := make(map[[2]string]bool, len(moved))
+ for _, k := range moved {
+ want[k] = true
+ }
+ n := 0
+ for _, rt := range routes {
+ if !rt.IsSidecar() || !want[[2]string{release.RouteAccount(rt), rt.App}] {
+ continue
+ }
+ if !bounce(rt.Host) {
+ continue
+ }
+ n++
+ log.Printf("agent: restart requested: bounced sidecar %s", rt.Host)
+ if err := wake(rt); err != nil {
+ log.Printf("agent: restart requested: re-wake sidecar %s: %v", rt.Host, err)
+ }
+ }
+ return n
+}
+
// restartRequestMarker is the file the agent's restart leg touches to ask a
// root pass to run now: carlos-restart.path watches it (PathChanged) and starts
// carlos-restart.service (`carlos adopt -restart-only`), which owns the half
@@ -598,8 +666,13 @@ func restartPass(ctx context.Context, routes []registry.Route, store release.Sto
// The bounce goes first, and not only for tidiness: it is in-process and
// instant, while the marker write can block on a busy /data, and the children
// bounced here are the ones cycled here or nowhere.
+//
+// wake is svc.WakeWorker in production (wakeFn), nil-safe like bounce: it is
+// cycleWorkers' other half, called only for the sidecars this leg actually
+// stops (see cycleWorkers' doc comment for why a sidecar needs both calls,
+// where an instance needs only bounce).
func restartRequestLeg(ctx context.Context, routes []registry.Route, store release.Store, dd string,
- bounce func(host string) bool, cache map[[2]string]string) bool {
+ bounce func(host string) bool, wake func(rt registry.Route) error, cache map[[2]string]string) bool {
moved, readErrs := restartGenPass(ctx, routes, store, cache)
if len(readErrs) > 0 {
// ONE line per pass, never one per app: the count carries the scale,
@@ -610,6 +683,7 @@ func restartRequestLeg(ctx context.Context, routes []registry.Route, store relea
return false
}
bounceExecRoutes(routes, moved, bounce)
+ cycleWorkers(routes, moved, bounce, wake)
if err := requestRootRestart(dd); err != nil {
log.Printf("agent: request root restart pass: %v", err)
return false
diff --git a/internal/host/restart_test.go b/internal/host/restart_test.go
index bf59e7a98..30582937e 100644
--- a/internal/host/restart_test.go
+++ b/internal/host/restart_test.go
@@ -264,7 +264,7 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) {
// Baseline (what restartSeed does at arming): silent, no marker.
askRestart(t, ctx, s, "bab", "eleven")
- if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked {
+ if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, cache); asked {
t.Fatal("the baselining leg asked for a root pass — first sight must be silent")
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
@@ -272,7 +272,7 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) {
}
askRestart(t, ctx, s, "bab", "eleven")
- if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); !asked {
+ if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, cache); !asked {
t.Fatal("a moved restart generation must ask for a root pass")
}
if !slices.Equal(bounced, []string{"exec.example.com"}) {
@@ -284,7 +284,7 @@ func TestRestartRequestLegBouncesAndRelays(t *testing.T) {
// And a leg with nothing moved neither bounces nor re-asks.
bounced = nil
- if asked := restartRequestLeg(ctx, routes, s, dd, bounce, cache); asked {
+ if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, cache); asked {
t.Fatal("an unmoved generation asked for a root pass")
}
if len(bounced) != 0 {
@@ -704,3 +704,127 @@ func TestSeededBaselineIsWhatTheRootPassTrusts(t *testing.T) {
t.Fatalf("the first root pass cycled %v, want the requested route", rec.hosts)
}
}
+
+// ---- Task 11: sidecars in the restart pass -------------------------------
+
+// TestRestartPassCyclesSidecars: release.Adoptable requires Kind ==
+// "instance" and deliberately stays that way (adopt membership, capability
+// convergence, socket/backing advisories, status pages all correctly
+// exclude a sidecar) — restartCandidates is the widened, NAMED opt-in
+// `carlos restart -app X` needs instead.
+func TestRestartPassCyclesSidecars(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "bot.x.sidecar.internal", Kind: "sidecar", App: "eleven", Account: "bdm", Status: "ready"},
+ }
+ if len(restartCandidates(routes)) != 1 {
+ t.Fatal("carlos restart -app X must cycle that app's sidecars too")
+ }
+}
+
+// TestRestartCandidatesStillExcludesNonAdoptableNonSidecarRoutes: the
+// widening is additive — everything release.Adoptable already excluded for
+// a non-sidecar route (a Platform alias, a mid-provision route) stays
+// excluded.
+func TestRestartCandidatesStillExcludesNonAdoptableNonSidecarRoutes(t *testing.T) {
+ alias := execRoute("alias.oncarlos.com", "bab", "eleven")
+ alias.Platform = true
+ pending := execRoute("new.example.com", "bab", "titogo")
+ pending.Status = "creating"
+ if got := restartCandidates([]registry.Route{alias, pending}); len(got) != 0 {
+ t.Fatalf("restartCandidates = %v, want none", got)
+ }
+}
+
+// TestRestartAppsIncludesASidecarOnlyApp: an app whose only route on this
+// box is a sidecar must still be tracked — restartApps (via
+// restartCandidates) is what both restartGenPass and restartPass use to
+// decide which apps' restart generation to read at all. Without this, a
+// sidecar-only app's `carlos restart` request is never even noticed.
+func TestRestartAppsIncludesASidecarOnlyApp(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "bot.x.sidecar.internal", Kind: "sidecar", App: "eleven", Account: "bdm", Status: "ready"},
+ }
+ apps := restartApps(routes)
+ if len(apps) != 1 || apps[0] != [2]string{"bdm", "eleven"} {
+ t.Fatalf("restartApps = %v, want [[bdm eleven]]", apps)
+ }
+}
+
+// ---- Task 11: cycling a sidecar's own child ------------------------------
+
+// alwaysOnSidecarRoute and hibernatingSidecarRoute are cycleWorkers'
+// fixtures — Hibernate governs nothing in cycleWorkers itself (bounce's own
+// awake check does the filtering), but the two shapes exist because
+// activator.Service.Bounce's stop half behaves identically for both and
+// cycleWorkers must too.
+func sidecarRoute(host, acct, app string) registry.Route {
+ return registry.Route{Host: host, Kind: "sidecar", App: app, Account: acct, Status: "ready"}
+}
+
+// TestCycleWorkersBouncesAndWakesARunningSidecar: nothing dials a worker
+// the way a request dials an instance, so Bounce's stop half — reused
+// verbatim from bounceExecRoutes' own bounce callback — must be paired
+// with an explicit wake here, or the sidecar stays down (see
+// activator.Service.Bounce's doc comment). Only a HOST BOUNCE ACTUALLY
+// STOPPED gets woken.
+func TestCycleWorkersBouncesAndWakesARunningSidecar(t *testing.T) {
+ routes := []registry.Route{sidecarRoute("bot.x.sidecar.internal", "bdm", "eleven")}
+ moved := [][2]string{{"bdm", "eleven"}}
+ var bounced, woken []string
+ bounce := func(h string) bool { bounced = append(bounced, h); return true }
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ n := cycleWorkers(routes, moved, bounce, wake)
+ if n != 1 || !slices.Equal(bounced, []string{"bot.x.sidecar.internal"}) {
+ t.Fatalf("bounced %v (n=%d), want exactly the sidecar", bounced, n)
+ }
+ if !slices.Equal(woken, []string{"bot.x.sidecar.internal"}) {
+ t.Fatalf("woken %v, want the bounced sidecar re-woken", woken)
+ }
+}
+
+// TestCycleWorkersLeavesASleepingSidecarAsleep: Bounce is a no-op on
+// anything not awake, and cycleWorkers must never wake a sleeper just
+// because a restart was requested — the same "restarting a process that is
+// not running is meaningless" rule restartPass applies to a hibernating
+// instance.
+func TestCycleWorkersLeavesASleepingSidecarAsleep(t *testing.T) {
+ routes := []registry.Route{sidecarRoute("bot.x.sidecar.internal", "bdm", "eleven")}
+ moved := [][2]string{{"bdm", "eleven"}}
+ var woken []string
+ bounce := func(h string) bool { return false } // asleep: Bounce's own contract
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ if n := cycleWorkers(routes, moved, bounce, wake); n != 0 {
+ t.Fatalf("cycleWorkers = %d, want 0 — nothing was running", n)
+ }
+ if len(woken) != 0 {
+ t.Fatalf("woken %v, want none — a sleeping sidecar must stay asleep", woken)
+ }
+}
+
+// TestCycleWorkersOnlyTouchesMovedAppsAndSidecarRoutes: the same
+// app-scoping bounceExecRoutes gives an instance, and never an ordinary
+// instance route even for a moved app (that is bounceExecRoutes' job, not
+// this one's).
+func TestCycleWorkersOnlyTouchesMovedAppsAndSidecarRoutes(t *testing.T) {
+ routes := []registry.Route{
+ sidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven"),
+ sidecarRoute("bot.titogo.sidecar.internal", "bdm", "titogo"), // different app
+ execRoute("inst.example.com", "bdm", "eleven"), // not a sidecar
+ }
+ moved := [][2]string{{"bdm", "eleven"}}
+ var bounced []string
+ bounce := func(h string) bool { bounced = append(bounced, h); return true }
+ wake := func(rt registry.Route) error { return nil }
+ if n := cycleWorkers(routes, moved, bounce, wake); n != 1 || !slices.Equal(bounced, []string{"bot.eleven.sidecar.internal"}) {
+ t.Fatalf("bounced %v (n=%d), want only the moved app's sidecar", bounced, n)
+ }
+}
+
+// TestCycleWorkersWithoutAnActivatorOrWakeIsANoop: a box with no activator
+// armed gets nil for both callbacks — a working no-op, not a panic.
+func TestCycleWorkersWithoutAnActivatorOrWakeIsANoop(t *testing.T) {
+ routes := []registry.Route{sidecarRoute("bot.x.sidecar.internal", "bdm", "eleven")}
+ if n := cycleWorkers(routes, [][2]string{{"bdm", "eleven"}}, nil, nil); n != 0 {
+ t.Fatalf("cycleWorkers = %d with no activator armed, want 0 and no panic", n)
+ }
+}