carlos / platform Public

Run standalone sidecars through the activator, off its request path

Paul Campbell pushed by paul@keymail.dev 46ca6f6103d32ed8f1aa16e77702d8bab6ad405b parent c4fce2f
5 files changed, +708 −72
  • internal/activator/activator.go +347 −27
  • internal/activator/activator_test.go +332 −7
  • internal/host/agent.go +1 −1
  • internal/host/fleetbind.go +14 −19
  • internal/host/fleetbind_test.go +14 −18
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 086540b33..de4a994f0 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -43,6 +43,8 @@ package activator
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"fmt"
"log"
@@ -74,8 +76,16 @@ type Config struct {
Region string // CARLOS_HIBERNATE_REGION (optional; AWS default chain resolves "")
Prefix string // CARLOS_HIBERNATE_PREFIX (optional key prefix inside the bucket)
Idle time.Duration // CARLOS_HIBERNATE_IDLE (default 15m; seconds-granular, e.g. "90s")
+ // AgentSocket is the unix socket a worker child dials to reach back —
+ // the direction that makes a sidecar possible at all: the agent listens,
+ // children call in; the platform never dials a sidecar. Handed to every
+ // worker as CARLOS_AGENT_SOCKET. Empty falls back to DefaultAgentSocket.
+ AgentSocket string
}
+// DefaultAgentSocket is where the agent listens for its worker children.
+const DefaultAgentSocket = "/run/carlos/agent.sock"
+
// ConfigFromEnv reads the CARLOS_HIBERNATE_* env vars. A nil, nil return
// means the bucket is unset: hibernation disabled, don't construct a Service.
func ConfigFromEnv() (*Config, error) {
@@ -84,10 +94,11 @@ func ConfigFromEnv() (*Config, error) {
return nil, nil
}
cfg := &Config{
- Bucket: bucket,
- Region: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")),
- Prefix: strings.Trim(strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_PREFIX")), "/"),
- Idle: 15 * time.Minute,
+ Bucket: bucket,
+ Region: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")),
+ Prefix: strings.Trim(strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_PREFIX")), "/"),
+ Idle: 15 * time.Minute,
+ AgentSocket: DefaultAgentSocket,
}
if v := strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_IDLE")); v != "" {
d, err := time.ParseDuration(v)
@@ -293,6 +304,23 @@ type actEntry struct {
sidecarStarted time.Time // when the current sidecar spawned (backoff reset)
sidecarBackoff time.Duration // last restart delay (doubles, capped)
sidecarRetryAt time.Time // earliest next restart attempt
+
+ // Standalone sidecar ("worker") entries. A worker is this entry's own
+ // proc, not a passenger on someone else's: the co-spawn fields above stay
+ // zero for it, and it never becomes a co-spawn parent either.
+ //
+ // worker, workerIdle, workerMaxRun and workerToken are set once, at entry
+ // construction, BEFORE the wake goroutine is launched, and never written
+ // again — so wake/doWake read them off-lock safely (the `go` statement is
+ // the happens-before edge). spawnedAt and workerVersion are written under
+ // a.mu like every other post-wake field.
+ worker bool // this entry is a sidecar: no socket, no requests
+ workerIdle time.Duration // 0 = fall back to cfg.Idle
+ workerMaxRun time.Duration // 0 = no ceiling
+ workerToken string // the per-wake agent-socket token
+
+ spawnedAt time.Time // when this wake's child spawned — the max_run clock
+ workerVersion string // the build the child reported over the agent socket
}
// Sidecar restart pacing — Restart=always semantics without a supervisor: the
@@ -399,7 +427,7 @@ type Service struct {
sweepStop chan struct{}
sweepDone chan struct{}
- // OnWake, when set, is called with the host after EVERY successful wake
+ // OnWake, when set, is called with the ROUTE after EVERY successful wake
// — fleet boxes use it to publish a binding object to the deployment
// bucket (customer-fleets plan, Task 6) naming this box as the current
// holder of host's lease; nil (every non-fleet deployment) is a pure
@@ -410,7 +438,14 @@ type Service struct {
// hosts, leases, and processes. wake() invokes it in its own goroutine
// (see below) so a slow or failing publish can never delay a request
// that's waiting on this same wake.
- OnWake func(host string)
+ //
+ // The whole route rather than the bare host because the hook's own
+ // refusal is kind-shaped: a binding must never be published for a
+ // sidecar, and the wake path already HAS the route. Passing the host
+ // alone forced the hook to re-read the registry to learn the kind, which
+ // made a security-relevant refusal depend on an I/O call that can fail —
+ // and fail OPEN, since a read error must not stop legitimate bindings.
+ OnWake func(rt registry.Route)
}
// New wires the state machine to its side-effect backend and due-time store.
@@ -613,7 +648,7 @@ func (a *Service) releaseFunc(e *actEntry) func() {
func (a *Service) wake(e *actEntry) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- w, err := a.doWake(ctx, e.route)
+ w, err := a.doWake(ctx, e)
// The member-facing record of this wake (spec §5): composed here, where
// the route and the whole error are in hand — the journal line below the
@@ -622,6 +657,9 @@ func (a *Service) wake(e *actEntry) {
emitEvent(a.Logs, e.route, "error", memberSafeWakeReason(err))
} else {
msg := "instance woke"
+ if e.worker {
+ msg = "sidecar started"
+ }
if w.spawnVersion != "" {
msg += " (version " + w.spawnVersion + ")"
}
@@ -640,6 +678,10 @@ func (a *Service) wake(e *actEntry) {
e.sidecarBackoff = 0
e.sidecarRetryAt = time.Time{} // a wake-time spawn failure retries on the next sweep
e.lastActive = a.now()
+ // The max_run clock starts when the child does. Set for every entry
+ // (it costs a field write) but read only for workers: an instance's
+ // run length is bounded by its idle window and its members.
+ e.spawnedAt = a.now()
// The build this child actually execed, captured at Start() inside the
// backend (not re-read here — a ship during the wake window would make a
// re-read name the new target while the process runs the old binary, and
@@ -648,13 +690,13 @@ func (a *Service) wake(e *actEntry) {
e.spawnVersion = w.spawnVersion
e.staleSince = time.Time{}
if a.OnWake != nil {
- // Fire-and-forget, off this goroutine: e.host is read once here,
+ // Fire-and-forget, off this goroutine: e.route is read once here,
// before this goroutine can be reused for anything else — the
// hook must never share e.host's own goroutine's fate (a wedged
// bucket Put must not hold e.ready closed any longer than the
// wake itself already takes).
- host := e.host
- go a.OnWake(host)
+ rt := e.route
+ go a.OnWake(rt)
}
}
a.mu.Unlock()
@@ -673,7 +715,13 @@ type wokenInstance struct {
spawnVersion string // live/<host> target read at exec — for build convergence
}
-func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance, error) {
+// doWake runs the side effects of one wake. It takes the ENTRY, not the bare
+// route, because two of its steps depend on what kind of child this is — the
+// spawn call and the co-spawn — and those facts (e.worker, e.workerToken) are
+// fixed at entry construction, before the wake goroutine starts, so reading
+// them here without a.mu is safe by construction.
+func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, error) {
+ rt := e.route
host := rt.Host
// 1. The lease first: everything after this line is done as the prefix's
@@ -720,7 +768,21 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance
// on boot, so waiting for the socket guarantees the file exists for
// litestream to open. Writes that land in the gap are picked up by the
// replica's first sync.
- proc, spawnVer, err := a.backend.Start(ctx, rt)
+ //
+ // A worker takes the other door: it binds nothing, so Start's health
+ // probe has no socket to dial and would spend its whole timeout failing
+ // the wake. StartWorker spawns and returns — readiness is the child's own
+ // call over the agent socket, which is why the wake env below carries the
+ // socket path and this wake's token.
+ var (
+ proc Proc
+ spawnVer string
+ )
+ if e.worker {
+ proc, spawnVer, err = a.backend.StartWorker(ctx, rt, a.workerEnv(e))
+ } else {
+ proc, spawnVer, err = a.backend.Start(ctx, rt)
+ }
if err != nil {
_ = lease.Release(ctx)
return nil, fmt.Errorf("start %s: %w", host, err)
@@ -746,6 +808,13 @@ func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance
// failure is loud but never fails the wake: members come before bots, and
// the sweeper retries with backoff.
w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer}
+ // A worker is nobody's parent: it IS a sidecar, so it never co-spawns one.
+ // Skipped outright rather than left to HasSidecar's answer — the staging
+ // file is keyed by host, and a host that happened to have one staged must
+ // not turn a standalone sidecar into a supervisor.
+ if e.worker {
+ return w, nil
+ }
if a.backend.HasSidecar(rt) {
w.sidecarWanted = true
if sc, err := a.backend.StartSidecar(ctx, rt); err != nil {
@@ -806,6 +875,193 @@ func (a *Service) Bounce(host string) bool {
return true
}
+// --- standalone sidecars (workers) -------------------------------------------
+//
+// A worker is a member of this same state machine with the request path
+// removed. Nothing routes to a sidecar — the edge refuses the row on kind, and
+// ServeHTTP gates on `Kind == "instance"` — so no request can ever wake one:
+// every wake arrives from the due-time sweep, an operator command, or the
+// agent's boot pass, and its only sign of life is contact over the agent
+// socket. beginRequest, BeginSidecarRequest and ServeHTTP are untouched.
+
+// newWorkerEntry builds the waking entry for a standalone sidecar: the route's
+// own ceilings (0 = platform default / no ceiling) and a freshly minted
+// per-wake token. Every field it sets is written BEFORE the entry is published
+// to a.entries and the wake goroutine starts, so the wake path may read them
+// without a.mu; nothing rewrites them afterwards.
+func (a *Service) newWorkerEntry(rt registry.Route) *actEntry {
+ return &actEntry{
+ host: rt.Host,
+ route: rt,
+ state: actWaking,
+ ready: make(chan struct{}),
+ worker: true,
+ workerIdle: time.Duration(rt.IdleSeconds) * time.Second,
+ workerMaxRun: time.Duration(rt.MaxRunSeconds) * time.Second,
+ workerToken: newWorkerToken(),
+ }
+}
+
+// newWorkerToken mints the per-wake agent-socket credential. A fresh one every
+// wake is the point: a token from a previous incarnation of the child never
+// matches, so an orphan that survived a stop cannot keep talking to the agent
+// as though it were the live worker. A rand failure is fatal to the wake, not
+// silently downgraded — an empty token would authorize nothing (WorkerToken's
+// "" is the refusal), but a PREDICTABLE one would authorize everyone.
+func newWorkerToken() string {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ // crypto/rand.Read never returns an error on any platform we ship to;
+ // if it somehow did, an unguessable-token-less wake must not proceed.
+ panic("activator: crypto/rand failed: " + err.Error())
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// WakeWorker brings a sidecar up, or does nothing if it is already awake.
+// It is the request-less twin of beginRequest: a sidecar has no socket, so no
+// request can ever wake one — every wake arrives from the due-time sweep, an
+// operator command, or the agent's boot pass.
+//
+// The route arrives whole and is the only input: the idle window and the run
+// ceiling ride on the row, so no caller (and no sweep tick) needs a bucket
+// read to decide a worker has outrun its ceiling.
+//
+// Idempotent: concurrent and repeated calls coalesce on the ONE wake, exactly
+// as concurrent requests do — three calls produce one child.
+func (a *Service) WakeWorker(rt registry.Route) error {
+ deadline := time.NewTimer(wakeWaitBound)
+ defer deadline.Stop()
+ for {
+ a.mu.Lock()
+ e := a.entries[rt.Host]
+ if e == nil {
+ // Asleep → kick off ONE wake and FALL THROUGH to the switch still
+ // holding the lock, waiting on the entry we just created. Unlocking
+ // and re-reading a.entries[host] here is the #184 bug: a wake that
+ // fails deletes its entry, so the initiator would find nothing and
+ // start a second one.
+ e = a.newWorkerEntry(rt)
+ a.entries[rt.Host] = e
+ go a.wake(e)
+ } else if !e.worker {
+ // A live entry for this host that is NOT a worker means the route
+ // changed kind under a running instance (or two rows collided).
+ // Refuse rather than adopt someone else's child: its proc, lease
+ // and idle discipline are the instance path's.
+ a.mu.Unlock()
+ return fmt.Errorf("wake worker %s: an instance entry already holds this host", rt.Host)
+ }
+
+ switch e.state {
+ case actAwake:
+ a.mu.Unlock()
+ return nil // already up: a no-op, not a second child
+
+ case actWaking:
+ ready := e.ready
+ a.mu.Unlock()
+ select {
+ case <-ready:
+ // A failed wake reports to every coalesced caller; retrying
+ // here would turn one bad wake into a storm (beginRequest's
+ // rule, for the same reason).
+ return e.err
+ case <-deadline.C:
+ return errWakeTimeout
+ }
+
+ case actStopping:
+ stopped := e.stopped
+ a.mu.Unlock()
+ select {
+ case <-stopped:
+ continue // entry is gone; loop wakes it fresh
+ case <-deadline.C:
+ return errWakeTimeout
+ }
+ }
+ }
+}
+
+// workerEnv is what the wake hands the child: where to call back, who it is,
+// and this wake's credential. The backend applies it OVER the tenant's own env
+// file so a tenant cannot forge its own token (StartWorker's contract).
+func (a *Service) workerEnv(e *actEntry) map[string]string {
+ sock := a.cfg.AgentSocket
+ if sock == "" {
+ sock = DefaultAgentSocket
+ }
+ name, _, _ := strings.Cut(e.host, ".") // the sidecar's declared name
+ return map[string]string{
+ "CARLOS_AGENT_SOCKET": sock,
+ "CARLOS_SIDECAR_TOKEN": e.workerToken,
+ "CARLOS_SIDECAR_HOST": e.host,
+ "CARLOS_SIDECAR_NAME": name,
+ }
+}
+
+// NoteWorkerContact stamps last-activity from an agent-socket call. It is a
+// worker's only sign of life: nothing routes to a sidecar, so no request will
+// ever stamp one, and without contact the idle clock runs to its window and
+// the sweeper sleeps it.
+func (a *Service) NoteWorkerContact(host string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker {
+ e.lastActive = a.now()
+ }
+}
+
+// NoteWorkerReady records a worker's own "I am up" — last-activity plus the
+// build it reports running. Deliberately the child's claim, not ours: §9.2's
+// honesty rule is that a sidecar which never reports shows unknown, never
+// "current". (Task 8 additionally persists the version through the registry's
+// version setter; this is the in-memory half the agent socket needs today.)
+func (a *Service) NoteWorkerReady(host, version string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker {
+ e.lastActive = a.now()
+ e.workerVersion = version
+ }
+}
+
+// SetWorkerNextDue records a sidecar's next scheduled work, straight to the
+// due-time index — while the child is ALIVE and can say so. That is why the
+// stop path never probes a worker: the answer is already recorded, and a probe
+// of a process we are about to kill could only be worse.
+func (a *Service) SetWorkerNextDue(host string, at int64) error {
+ return a.due.SetNextDue(host, at)
+}
+
+// SleepWorker stops an awake worker now — the /v1/idle call, "I have nothing
+// to do". It is a stop REQUESTED immediately rather than an idle timestamp
+// nudged backwards, so the ordinary stop path (park the DB, release the lease)
+// does the work. Anything other than an awake worker is a no-op: this must
+// never start something, and never touch an instance.
+func (a *Service) SleepWorker(host string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker && e.state == actAwake {
+ emitEvent(a.Logs, e.route, "info", "sidecar reported idle — stopping")
+ a.beginStopLocked(e)
+ }
+}
+
+// WorkerToken reports the live per-wake token for host, or "" — the agent
+// socket's authorization check. A token from a previous wake never matches,
+// and a host that is asleep, waking, stopping or not a worker at all has no
+// token, so every such call is refused.
+func (a *Service) WorkerToken(host string) string {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if e := a.entries[host]; e != nil && e.worker && e.state == actAwake {
+ return e.workerToken
+ }
+ return ""
+}
+
// stop drives awake→asleep: stop the child (its SIGTERM drain is the final
// write), checkpoint what's on disk to S3 and release the lease, then forget
// the entry. The local DB stays in place — it's the warm cache for the next
@@ -815,7 +1071,11 @@ func (a *Service) stop(e *actEntry) {
// convergence cycling, bounce, shutdown all pass through here, so an
// "exited" line WITHOUT this line before it reads as what it is — a
// crash.
- emitEvent(a.Logs, e.route, "info", "instance stopping")
+ stopping := "instance stopping"
+ if e.worker {
+ stopping = "sidecar stopping"
+ }
+ emitEvent(a.Logs, e.route, "info", stopping)
// Co-stop, sidecar FIRST — before the next-due probe and the instance's
// own SIGTERM. Two reasons: the sidecar must never outlive its instance
@@ -845,23 +1105,33 @@ func (a *Service) stop(e *actEntry) {
// not cfg.Idle. That's accepted — a bounce is rare relative to request
// traffic, and each cycle is still a bounded SIGTERM drain, not a busy
// spin.
- dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second)
- due, err := a.backend.NextDue(dueCtx, e.route)
- cancelDue()
- if err != nil {
- log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err)
- due = 0
- }
- if err := a.due.SetNextDue(e.host, due); err != nil {
- log.Printf("activator: record next-due %s: %v", e.host, err)
- } else if due > 0 {
- log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339))
+ //
+ // A WORKER skips this step entirely — both the probe and the write. It has
+ // no socket to answer on, and its next-due already arrived over the agent
+ // socket while it was alive (SetWorkerNextDue wrote it straight to the
+ // registry), which is strictly better than asking a process we are about
+ // to kill. Writing here would be actively wrong: the probe would fail, and
+ // the failure path records 0 — erasing the schedule that is a sleeping
+ // sidecar's ONLY way back up, since nothing can dial one.
+ if !e.worker {
+ dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second)
+ due, err := a.backend.NextDue(dueCtx, e.route)
+ cancelDue()
+ if err != nil {
+ log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err)
+ due = 0
+ }
+ if err := a.due.SetNextDue(e.host, due); err != nil {
+ log.Printf("activator: record next-due %s: %v", e.host, err)
+ } else if due > 0 {
+ log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339))
+ }
}
// Separate budgets: a SIGTERM drain that runs long must not eat the final
// checkpoint's time — the S3 sync is the part that makes sleep durable.
procCtx, cancelProc := context.WithTimeout(context.Background(), actStopTimeout)
- err = e.proc.Stop(procCtx)
+ err := e.proc.Stop(procCtx)
cancelProc()
if err != nil {
log.Printf("activator: stop %s: %v", e.host, err)
@@ -938,11 +1208,33 @@ func (a *Service) sweepOnce() {
a.beginStopLocked(e)
continue
}
- if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle {
+ // The idle predicate is shared verbatim; only its WINDOW can differ.
+ // A worker declares its own on its row (Task 1) so this sweep never
+ // needs a bucket read to decide one has sat idle long enough; zero —
+ // every instance, and any sidecar that declared nothing — falls back
+ // to the platform default, so an instance's behaviour is unchanged.
+ // inflight is always 0 for a worker (nothing routes to it) and
+ // lastActive is stamped by agent-socket contact instead of by a
+ // released request, so the same expression means the right thing for
+ // both.
+ idleWindow := a.cfg.Idle
+ if e.workerIdle > 0 {
+ idleWindow = e.workerIdle
+ }
+ if e.inflight == 0 && now.Sub(e.lastActive) >= idleWindow {
log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second))
a.beginStopLocked(e)
continue
}
+ // A worker that never reports idle still has a ceiling: a wedged
+ // reconnect loop must not hold a lease and a box forever. Zero means
+ // no ceiling — an always-on sidecar is a legitimate shape, so this
+ // must never invent one nobody declared.
+ if e.worker && e.workerMaxRun > 0 && now.Sub(e.spawnedAt) >= e.workerMaxRun {
+ log.Printf("activator: %s hit its max_run of %s — stopping", e.host, e.workerMaxRun)
+ a.beginStopLocked(e)
+ continue
+ }
// Converge onto a shipped build. A running child never re-reads
// live/<host>, so once a ship repoints the symlink this awake child is
// serving a stale binary. Idle hosts converge for free on their next
@@ -982,6 +1274,14 @@ func (a *Service) sweepOnce() {
// only hibernating hosts are activator children and their systemd
// sidecar units stay disabled — the staged sidecar has exactly one
// possible runner.
+ //
+ // Never for a worker: it is a sidecar itself, not a host that has one
+ // (the same refusal doWake makes at wake time — restated here because
+ // this adoption arm can fire on a long-awake entry that never went
+ // through step 6 again).
+ if e.worker {
+ continue
+ }
if !e.sidecarWanted && a.backend.HasSidecar(e.route) {
e.sidecarWanted = true
log.Printf("activator: sidecar staged for awake %s — adopting", e.host)
@@ -1035,7 +1335,17 @@ func (a *Service) sweepOnce() {
// (the lease would let exactly one win, but loudly, forever). Eleven:
// `if a.mesh != nil && !a.mesh.owns(rt.Host) { continue }`.
log.Printf("activator: %s has a scheduled delivery due — waking it", rt.Host)
- e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})}
+ // A sidecar row comes up as a WORKER (registry.DueInstances returns
+ // both kinds, because the due-time index is a sidecar's only timer
+ // wake source — nothing can dial one). Woken through the instance path
+ // it would spawn via Start and spend the whole health-probe timeout
+ // dialling a socket it never binds.
+ var e *actEntry
+ if rt.Kind == "sidecar" {
+ e = a.newWorkerEntry(rt)
+ } else {
+ e = &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})}
+ }
a.entries[rt.Host] = e
go a.wake(e)
}
@@ -1059,6 +1369,16 @@ func (a *Service) RetireExcept(keep map[string]bool) {
if keep[e.host] {
continue
}
+ // Workers are outside this reconcile's world entirely. `keep` is the
+ // EDGE's managed set, built by edge.Reload — which refuses a sidecar
+ // row on kind as the first act of its loop, so a worker's host can
+ // never appear in it. Without this guard every edge reload (any route
+ // change at all) would stop every running standalone sidecar. A
+ // sidecar's removal authority belongs to the sidecar reconcile pass,
+ // not to the edge's route map.
+ if e.worker {
+ continue
+ }
switch e.state {
case actAwake:
log.Printf("activator: %s is no longer hibernate-managed — releasing", e.host)
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index af752ae12..0f12df616 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -154,6 +154,10 @@ type fakeBackend struct {
syncIntervals []time.Duration
dueVal int64 // what the instance reports at the sleep-time next-due probe
dueErr error // when non-nil, the probe fails (dead socket, missing token, …)
+ // dueProbeCount counts NextDue calls. A worker has no socket to answer on,
+ // so the assertion its sleep needs is "the probe never ran at all", not
+ // "the probe returned nothing".
+ dueProbeCount int
// execVerFn models the live/<host> target the child execs at spawn — Start()
// returns it as the spawnVersion, exactly as ExecBackend reads the symlink
@@ -175,9 +179,10 @@ type fakeBackend struct {
// (Task 6's territory); workerErr injects spawn failures, workerEnvs
// records what env each call was handed so a caller-side test can assert
// the per-wake token actually reached the backend.
- workerErr error
- workers []*fakeProc
- workerEnvs []map[string]string
+ workerErr error
+ workers []*fakeProc
+ workerEnvs []map[string]string
+ workerStartN int
// order interleaves backend calls and proc stops (start:instance,
// start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering
// assertions.
@@ -252,6 +257,7 @@ func (b *fakeBackend) Start(_ context.Context, rt registry.Route) (Proc, string,
func (b *fakeBackend) NextDue(context.Context, registry.Route) (int64, error) {
b.mu.Lock()
defer b.mu.Unlock()
+ b.dueProbeCount++
b.order = append(b.order, "nextDue")
if b.dueErr != nil {
return 0, b.dueErr
@@ -296,10 +302,11 @@ func (b *fakeBackend) setSidecarErr(err error) {
b.sidecarErr = err
}
-func (b *fakeBackend) StartWorker(_ context.Context, _ registry.Route, env map[string]string) (Proc, string, error) {
+func (b *fakeBackend) StartWorker(_ context.Context, rt registry.Route, env map[string]string) (Proc, string, error) {
b.mu.Lock()
defer b.mu.Unlock()
- b.order = append(b.order, "start:worker")
+ b.workerStartN++
+ b.order = append(b.order, "startWorker:"+rt.Host)
b.workerEnvs = append(b.workerEnvs, env)
if b.workerErr != nil {
return nil, "", b.workerErr
@@ -309,6 +316,36 @@ func (b *fakeBackend) StartWorker(_ context.Context, _ registry.Route, env map[s
return p, "", nil
}
+// workerStarts counts StartWorker calls; startCount counts Start (the socket
+// path, with its health probe) calls. A worker must only ever appear in the
+// first.
+func (b *fakeBackend) workerStarts() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.workerStartN
+}
+
+func (b *fakeBackend) startCount() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.starts
+}
+
+func (b *fakeBackend) dueProbes() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.dueProbeCount
+}
+
+func (b *fakeBackend) lastWorkerEnv() map[string]string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if len(b.workerEnvs) == 0 {
+ return nil
+ }
+ return b.workerEnvs[len(b.workerEnvs)-1]
+}
+
func (b *fakeBackend) setDue(v int64) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -388,6 +425,17 @@ func testRoute(host string) registry.Route {
DBPath: filepath.Join("/tmp", host+".db"), Hibernate: true}
}
+// testWorkerRoute is a standalone sidecar's row: no socket (nothing can dial
+// it), an argv of its own, and the hibernate flag that makes the sweeper's
+// idle clock apply. Status "ready" mirrors the due-sweep's gate.
+func testWorkerRoute(host string) registry.Route {
+ return registry.Route{Host: host, Kind: "sidecar", Args: `["sidecar","run"]`,
+ DBPath: filepath.Join("/tmp", host+".db"), Hibernate: true, Status: "ready"}
+}
+
+// contains: membership in the backend's order log.
+func contains(events []string, ev string) bool { return slices.Contains(events, ev) }
+
// newTestServiceAwake builds a Service against the fake backend and drives
// host to actAwake via a real beginRequest/release round trip — the same
// wake arrange block used throughout this file (e.g.
@@ -618,9 +666,9 @@ func TestActivatorWakeCallsOnWakeHookAsync(t *testing.T) {
a, _, _, _ := testActivator(t, time.Minute)
block := make(chan struct{})
hookHost := make(chan string, 1)
- a.OnWake = func(host string) {
+ a.OnWake = func(rt registry.Route) {
<-block // would hang beginRequest forever if invoked synchronously
- hookHost <- host
+ hookHost <- rt.Host
}
done := make(chan struct{})
@@ -2048,6 +2096,283 @@ func TestActivatorSidecarRequestsInvisibleToActivity(t *testing.T) {
}
}
+// --- standalone sidecars (workers) -------------------------------------------
+//
+// A worker is a member of the same state machine with the request path removed:
+// nothing can dial it, so every wake arrives from the due-time sweep, an
+// operator command, or the agent's boot pass, and its only sign of life is
+// contact over the agent socket. These tests pin exactly the places where its
+// lifecycle DIVERGES from an instance's — the spawn call, the absent next-due
+// probe, the two ceilings — and the places where it must not (lease release,
+// DB parking, singleflight).
+
+func TestWorkerWakeUsesStartWorkerAndNoProbe(t *testing.T) {
+ a, b, _, _ := testActivator(t, 10*time.Second)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if got := b.events(); !contains(got, "startWorker:bot.x.internal") {
+ t.Fatalf("events %v — a worker must spawn through StartWorker, never Start", got)
+ }
+ if b.startCount() != 0 {
+ t.Fatal("Start (the socket path, with its health probe) must never be called for a worker")
+ }
+}
+
+// The wake hands the child its agent-socket coordinates and the per-wake
+// token — the child's only way to reach back, and the activator's only way to
+// authorize it. WorkerToken must agree with what the child was told, or every
+// call the sidecar makes is a 401.
+func TestWorkerWakeThreadsTokenAndSocketEnv(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ tok := a.WorkerToken("bot.x.internal")
+ if tok == "" {
+ t.Fatal("WorkerToken is empty for an awake worker")
+ }
+ env := b.lastWorkerEnv()
+ if env["CARLOS_SIDECAR_TOKEN"] != tok {
+ t.Fatalf("child got token %q but WorkerToken reports %q — the socket would refuse its own child",
+ env["CARLOS_SIDECAR_TOKEN"], tok)
+ }
+ if env["CARLOS_SIDECAR_HOST"] != "bot.x.internal" || env["CARLOS_SIDECAR_NAME"] != "bot" {
+ t.Fatalf("child env = %v, want the host and its first label", env)
+ }
+ if env["CARLOS_AGENT_SOCKET"] == "" {
+ t.Fatalf("child env = %v, want the agent socket path (the child dials, never the platform)", env)
+ }
+ if a.WorkerToken("nobody.x.internal") != "" {
+ t.Fatal("WorkerToken must be empty for a host with no live worker")
+ }
+}
+
+func TestWorkerSleepsWhenIdleAndRecordsNextDue(t *testing.T) {
+ a, b, clk, reg := testActivator(t, 10*time.Second)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ if err := a.SetWorkerNextDue("bot.x.internal", 4242); err != nil {
+ t.Fatalf("SetWorkerNextDue: %v", err)
+ }
+
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "worker hibernation", func() bool { return a.entryCount() == 0 })
+
+ got, err := reg.Get("bot.x.internal")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.NextDue != 4242 {
+ t.Fatalf("next_due = %d, want 4242 — a worker's next-due arrives over the agent socket while it is ALIVE, not from a probe of a process we are about to kill", got.NextDue)
+ }
+ if b.dueProbes() != 0 {
+ t.Fatal("a worker must never be probed for next-due: it has no socket to answer on")
+ }
+ if !b.lastRep().isClosed() {
+ t.Fatal("the lease must be released and the DB parked on a worker sleep, exactly as for an instance")
+ }
+ if !b.workers[len(b.workers)-1].isStopped() {
+ t.Fatal("the worker child was not stopped")
+ }
+}
+
+func TestWorkerContactDefersSleep(t *testing.T) {
+ a, _, clk, _ := testActivator(t, 10*time.Second)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ clk.advance(9 * time.Second)
+ a.NoteWorkerContact("bot.x.internal")
+ clk.advance(9 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("agent-socket contact must defer the idle clock — it is a worker's only sign of life")
+ }
+}
+
+// The row's own idle window wins over the platform default: the ceilings ride
+// on the route (Task 1) precisely so the sweeper never needs a bucket read to
+// decide a worker has sat idle long enough.
+func TestWorkerRouteIdleWindowOverridesPlatformDefault(t *testing.T) {
+ a, _, clk, _ := testActivator(t, time.Hour) // the platform default would never fire
+ rt := testWorkerRoute("bot.x.internal")
+ rt.IdleSeconds = 30
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ clk.advance(20 * time.Second)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("worker slept before its own idle window elapsed")
+ }
+ clk.advance(11 * time.Second)
+ a.sweepOnce()
+ waitFor(t, "row-scoped idle sleep", func() bool { return a.entryCount() == 0 })
+}
+
+func TestWorkerMaxRunStopsALoopingWorker(t *testing.T) {
+ a, _, clk, _ := testActivator(t, time.Hour) // idle window never fires
+ rt := testWorkerRoute("bot.x.internal")
+ rt.MaxRunSeconds = 60
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ for i := 0; i < 10; i++ { // a worker that keeps talking but never idles
+ clk.advance(10 * time.Second)
+ a.NoteWorkerContact("bot.x.internal")
+ }
+ a.sweepOnce()
+ waitFor(t, "max_run stop", func() bool { return a.entryCount() == 0 })
+}
+
+// No ceiling declared (0) means no ceiling — an always-on worker must not be
+// stopped by a default nobody asked for.
+func TestWorkerWithoutMaxRunKeepsRunning(t *testing.T) {
+ a, _, clk, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ for i := 0; i < 10; i++ {
+ clk.advance(time.Hour)
+ a.NoteWorkerContact("bot.x.internal")
+ a.sweepOnce()
+ }
+ if a.entryCount() != 1 {
+ t.Fatal("a worker with max_run 0 was stopped by a ceiling it never declared")
+ }
+}
+
+func TestSleepWorkerIsImmediate(t *testing.T) {
+ a, _, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ a.SleepWorker("bot.x.internal")
+ waitFor(t, "idle-requested stop", func() bool { return a.entryCount() == 0 })
+}
+
+func TestWakeWorkerIsIdempotent(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ rt := testWorkerRoute("bot.x.internal")
+ for i := 0; i < 3; i++ {
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatalf("WakeWorker #%d: %v", i, err)
+ }
+ }
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d, want 1 — a wake for an already-awake worker is a no-op, not a second child", n)
+ }
+}
+
+// Concurrent wakes coalesce on one entry, the same singleflight the request
+// path gets: three goroutines, one child, one lease.
+func TestWakeWorkerCoalescesConcurrentCallers(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ rt := testWorkerRoute("bot.x.internal")
+
+ var wg sync.WaitGroup
+ for i := 0; i < 3; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := a.WakeWorker(rt); err != nil {
+ t.Errorf("WakeWorker: %v", err)
+ }
+ }()
+ }
+ wg.Wait()
+ if n := b.workerStarts(); n != 1 {
+ t.Fatalf("workerStarts = %d, want 1 — concurrent wakes must coalesce", n)
+ }
+ if acquires, _, _ := b.counts(); acquires != 1 {
+ t.Fatalf("lease acquires = %d, want 1", acquires)
+ }
+}
+
+// A failed worker wake releases the lease and leaves nothing behind, exactly
+// as a failed instance wake does — the error paths that must release a lease
+// are the reason a worker reuses doWake rather than reimplementing it.
+func TestWorkerWakeFailureReleasesLeaseAndClearsEntry(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ b.workerErr = errors.New("exec: no argv")
+
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err == nil {
+ t.Fatal("WakeWorker returned nil for a spawn that failed")
+ }
+ if a.entryCount() != 0 {
+ t.Fatal("a failed worker wake left an entry behind")
+ }
+ if n := b.releaseCount(); n != 1 {
+ t.Fatalf("lease releases = %d, want exactly 1 on a failed worker wake", n)
+ }
+}
+
+// A worker is nobody's parent: the legacy co-spawn step must not run for one,
+// even on a box where the staging file happens to exist.
+func TestWorkerNeverCoSpawnsASidecar(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ b.sidecarStaged = true
+
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ a.sweepOnce() // the sweep's mid-flight adoption must not pick one up either
+ if n := b.sidecarStartCount(); n != 0 {
+ t.Fatalf("sidecar starts = %d for a worker, want 0 — a worker is nobody's parent", n)
+ }
+}
+
+// RetireExcept reconciles the EDGE's managed set — and edge.Reload refuses a
+// sidecar row on kind before it builds that set, so a worker's host can never
+// be in `keep`. Without an explicit exemption every edge reload (any route
+// change at all) would stop every running standalone sidecar.
+func TestRetireExceptLeavesWorkersAlone(t *testing.T) {
+ a, _, _, _ := testActivator(t, time.Hour)
+ if err := a.WakeWorker(testWorkerRoute("bot.x.internal")); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+
+ a.RetireExcept(map[string]bool{}) // an edge reload that manages nothing
+
+ waitFor(t, "the instance to retire", func() bool { return !a.Owns("one.example.com") })
+ if a.WorkerToken("bot.x.internal") == "" {
+ t.Fatal("an edge reload retired a running worker — its host can never appear in the edge's managed set")
+ }
+}
+
+// The due-time sweep is a sidecar's ONLY timer wake source (nothing can dial
+// one), so a due sidecar row must come up through the worker path — StartWorker
+// and no health probe — not the instance one.
+func TestDueSweepWakesASidecarAsAWorker(t *testing.T) {
+ a, b, clk, reg := testActivator(t, time.Hour)
+ rt := testWorkerRoute("bot.x.internal")
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if err := reg.SetNextDue(rt.Host, clk.now().Unix()-1); err != nil {
+ t.Fatalf("set next due: %v", err)
+ }
+
+ a.sweepOnce()
+ waitFor(t, "due worker wake", func() bool { return b.workerStarts() == 1 })
+ if b.startCount() != 0 {
+ t.Fatal("a due sidecar was woken through the instance path (Start + health probe)")
+ }
+}
+
func TestConfigFromEnv(t *testing.T) {
t.Setenv("CARLOS_HIBERNATE_BUCKET", "")
if cfg, err := ConfigFromEnv(); err != nil || cfg != nil {
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0c610dbd8..b7498b778 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -268,7 +268,7 @@ func RunAgent(args []string) {
// MUST be assigned before Start() below spawns the sweeper
// goroutine, which reads svc.OnWake on every successful wake from
// then on — setting it any later is a data race.
- svc.OnWake = fleetBindingHook(fleetStore, identity.Fleet, identity.Label, reg)
+ svc.OnWake = fleetBindingHook(fleetStore, identity.Fleet, identity.Label)
}
if logSink != nil {
svc.Logs = logSink
diff --git a/internal/host/fleetbind.go b/internal/host/fleetbind.go
index 015a3b7de..710ea92f7 100644
--- a/internal/host/fleetbind.go
+++ b/internal/host/fleetbind.go
@@ -283,7 +283,8 @@ func ReconcileFleetBindings(ctx context.Context, s release.Store, reg *registry.
// surviving box, which restamps — is exactly the fallback that covers a
// dropped write here).
//
-// reg is consulted for one thing only: a sidecar host must never have a
+// The hook takes the whole ROUTE, which is what the activator's wake path has
+// in hand anyway, and refuses on its Kind: a sidecar host must never have a
// binding published for it, and this is the write path that carries TAKEOVER
// semantics, so an errant one here does not merely appear — it claims the host
// away from whoever legitimately held it. The filter lives here rather than
@@ -292,28 +293,22 @@ func ReconcileFleetBindings(ctx context.Context, s release.Store, reg *registry.
// (the sidecar worker goes through the same wake() that fires OnWake). A guard
// that depends on a caller's current behaviour is not a guard.
//
-// A route the registry cannot answer for — read error, or no row at all — is
-// published, exactly as it was before this filter existed: only a POSITIVE
-// "this is a sidecar" refuses. That keeps every non-sidecar host's behaviour
-// byte-identical through a transient registry hiccup (a dropped binding is a
-// routing outage; the fallback that covers it is slow), and the case is
-// covered anyway — ReconcileFleetBindings refuses to stamp a sidecar on the
-// reading side, and the edge refuses to bind one at all.
-func fleetBindingHook(store release.Store, fleet, box string, reg *registry.Registry) func(host string) {
- return func(host string) {
- if reg != nil {
- switch rt, err := reg.Get(host); {
- case err != nil:
- log.Printf("agent: fleet binding for %s: route lookup: %v (publishing anyway)", host, err)
- case rt != nil && rt.Kind == "sidecar":
- return
- }
+// Taking the route also removes the registry read this refusal used to need.
+// That read could fail, and a security-relevant check whose input can fail
+// must then decide which way to fail: it published anyway (fail OPEN), because
+// a dropped binding is a routing outage and the fallback that covers it is
+// slow. The route the wake already holds is the same local truth the read was
+// fetching, so the refusal is now total and depends on no I/O at all.
+func fleetBindingHook(store release.Store, fleet, box string) func(rt registry.Route) {
+ return func(rt registry.Route) {
+ if rt.Kind == "sidecar" {
+ return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- b := FleetBinding{Host: host, Fleet: fleet, Box: box, BoundAt: time.Now().UnixMilli()}
+ b := FleetBinding{Host: rt.Host, Fleet: fleet, Box: box, BoundAt: time.Now().UnixMilli()}
if err := WriteFleetBinding(ctx, store, b, true); err != nil {
- log.Printf("agent: fleet binding for %s: %v", host, err)
+ log.Printf("agent: fleet binding for %s: %v", rt.Host, err)
}
}
}
diff --git a/internal/host/fleetbind_test.go b/internal/host/fleetbind_test.go
index 0602e5654..ae54f9463 100644
--- a/internal/host/fleetbind_test.go
+++ b/internal/host/fleetbind_test.go
@@ -49,8 +49,8 @@ func TestPublishedBindingBoxMatchesTunnelChannelName(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- hook := fleetBindingHook(s, id.Fleet, id.Label, testRegistry(t))
- hook("one.example.com")
+ hook := fleetBindingHook(s, id.Fleet, id.Label)
+ hook(registry.Route{Host: "one.example.com", Kind: "instance"})
body, _, err := s.Get(ctx, fleetBindingKey(id.Fleet, "one.example.com"))
if err != nil {
@@ -533,9 +533,9 @@ func TestFleetBindingHookLogsErrorWithoutPanicking(t *testing.T) {
defer log.SetOutput(prev)
store := errPutStore{Store: release.NewDirStore(t.TempDir())}
- hook := fleetBindingHook(store, "pi", "amber-gliding", testRegistry(t))
+ hook := fleetBindingHook(store, "pi", "amber-gliding")
- hook("one.example.com") // must not panic even though every Put fails
+ hook(registry.Route{Host: "one.example.com", Kind: "instance"}) // must not panic even though every Put fails
if !strings.Contains(buf.String(), "fleet binding") {
t.Fatalf("expected the Put failure to be logged, got: %q", buf.String())
@@ -545,9 +545,9 @@ func TestFleetBindingHookLogsErrorWithoutPanicking(t *testing.T) {
func TestFleetBindingHookWritesBinding(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- hook := fleetBindingHook(s, "pi", "amber-gliding", testRegistry(t))
+ hook := fleetBindingHook(s, "pi", "amber-gliding")
- hook("woken.example.com")
+ hook(registry.Route{Host: "woken.example.com", Kind: "instance"})
body, _, err := s.Get(ctx, fleetBindingKey("pi", "woken.example.com"))
if err != nil {
@@ -576,8 +576,8 @@ func TestFleetBindingHookTakesOverFromAnotherBox(t *testing.T) {
t.Fatalf("seed old-box's binding: %v", err)
}
- hook := fleetBindingHook(s, "pi", "new-box", testRegistry(t))
- hook("one.example.com")
+ hook := fleetBindingHook(s, "pi", "new-box")
+ hook(registry.Route{Host: "one.example.com", Kind: "instance"})
body, _, err := s.Get(ctx, fleetBindingKey("pi", "one.example.com"))
if err != nil {
@@ -644,20 +644,16 @@ func TestPublishResidentFleetBindingsSkipsSidecars(t *testing.T) {
// true of the activator today and it is a fact a later task on this same
// branch removes, at which point the hole would open silently. An ordinary
// instance host must still be published, so this asserts both directions.
+//
+// The kind arrives on the route the wake already holds — no registry read, so
+// no I/O failure can make this refusal fail open.
func TestFleetBindingHookNeverPublishesSidecar(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- reg := testRegistry(t)
- if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "instance", Region: "fleet/pi"}); err != nil {
- t.Fatalf("upsert instance: %v", err)
- }
- if err := reg.Upsert(registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/pi"}); err != nil {
- t.Fatalf("upsert sidecar: %v", err)
- }
- hook := fleetBindingHook(s, "pi", "box1", reg)
- hook("app.example.com")
- hook("bot.eleven.bdm.sidecar.internal")
+ hook := fleetBindingHook(s, "pi", "box1")
+ hook(registry.Route{Host: "app.example.com", Kind: "instance", Region: "fleet/pi"})
+ hook(registry.Route{Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar", Region: "fleet/pi"})
if _, _, err := s.Get(ctx, fleetBindingKey("pi", "app.example.com")); err != nil {
t.Fatalf("instance binding missing from the wake publish: %v", err)