fleet bindings: holding box publishes to bucket, platform agent stamps routes
Fleet boxes are outbound-only and can never write the platform's registry directly, so a box's activator publishes a FleetBinding bucket object (fleets/<fleet>/bindings/<host>.json) on every successful wake and once more at startup for already-resident routes. The platform's adopt loop reads every fleet's bindings back (newest BoundAt wins, corrupt objects skipped) and stamps registry.Route.FleetBox to match. - internal/host/fleetbind.go: FleetBinding, WriteFleetBinding, ReconcileFleetBindings, fleetBindingHook, publishResidentFleetBindings. - internal/activator/activator.go: Service.OnWake hook, fired async off wake()'s success path so a slow/failing publish can never delay a request. - internal/host/agent.go: wires OnWake + the startup pass when CARLOS_FLEET is set; domainsLoop calls ReconcileFleetBindings on platform boxes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+676
−0
internal/activator/activator.go+22 −0internal/activator/activator_test.go+53 −0internal/host/agent.go+47 −0internal/host/fleetbind.go+219 −0internal/host/fleetbind_test.go+335 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 9014bfe37..cb29b9fe2 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -375,6 +375,19 @@ type Service struct { |
| sweepStop chan struct{} |
| sweepDone chan struct{} |
| + |
| + // OnWake, when set, is called with the host 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 |
| + // no-op. Deliberately a callback rather than a fleet-aware field on |
| + // Config: the activator has no notion of "fleet" anywhere else in this |
| + // package, and threading FleetName/BoxLabel through Config would leak a |
| + // customer-fleets concept into a package that otherwise only knows |
| + // 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) |
| } |
| // New wires the state machine to its side-effect backend and due-time store. |
| @@ -592,6 +605,15 @@ func (a *Service) wake(e *actEntry) { |
| // cycle. Fresh wake, so it can't be stale yet. |
| e.spawnVersion = w.spawnVersion |
| e.staleSince = time.Time{} |
| + if a.OnWake != nil { |
| + // Fire-and-forget, off this goroutine: e.host 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) |
| + } |
| } |
| a.mu.Unlock() |
| close(e.ready) |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index d14d19014..90f7818e0 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -585,6 +585,59 @@ func TestActivatorWakeCoalesces(t *testing.T) { |
| } |
| } |
| +// OnWake (customer-fleets plan, Task 6: a fleet box's binding publish) must |
| +// fire after a successful wake, named with the right host, and — the whole |
| +// point of running it via `go a.OnWake(host)` rather than calling it inline — |
| +// must NOT be able to hold the wake open: beginRequest returns and releases |
| +// long before the hook is allowed to finish. |
| +func TestActivatorWakeCallsOnWakeHookAsync(t *testing.T) { |
| + a, _, _, _ := testActivator(t, time.Minute) |
| + block := make(chan struct{}) |
| + hookHost := make(chan string, 1) |
| + a.OnWake = func(host string) { |
| + <-block // would hang beginRequest forever if invoked synchronously |
| + hookHost <- host |
| + } |
| + |
| + done := make(chan struct{}) |
| + go func() { |
| + release, err := a.beginRequest(context.Background(), testRoute("hook.example.com")) |
| + if err != nil { |
| + t.Errorf("beginRequest: %v", err) |
| + return |
| + } |
| + release() |
| + close(done) |
| + }() |
| + |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("beginRequest never returned — OnWake appears to be blocking the wake path") |
| + } |
| + |
| + close(block) |
| + select { |
| + case host := <-hookHost: |
| + if host != "hook.example.com" { |
| + t.Fatalf("OnWake called with %q, want hook.example.com", host) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("OnWake was never called after a successful wake") |
| + } |
| +} |
| + |
| +// A nil OnWake (every non-fleet deployment, and every test above this one) |
| +// must be a pure no-op — no crash, wake behaves exactly as before Task 6. |
| +func TestActivatorNilOnWakeIsNoop(t *testing.T) { |
| + a, _, _, _ := testActivator(t, time.Minute) |
| + release, err := a.beginRequest(context.Background(), testRoute("nohook.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| +} |
| + |
| // The wake threads the route's replication class into Replicate() as the |
| // litestream sync interval — the per-plan durability ladder's seam. Unset |
| // (every pre-ladder route) must thread 0, i.e. litestream's default: a deploy |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d71df4650..ce95c264e 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -177,6 +177,30 @@ func RunAgent(args []string) { |
| log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
| } |
| + // Fleet box binding publish (customer-fleets plan, Task 6): CARLOS_FLEET |
| + // set marks this agent as a fleet box (Task 5's marker, reused here — see |
| + // this file's package doc comment). Arms two publish paths against the |
| + // SAME store handle: the activator's OnWake hook (one publish per |
| + // successful wake) and a one-shot startup pass over every route already |
| + // resident in this box's local registry (covers an agent restart, and |
| + // routes that never go through activator.wake at all). Both write |
| + // through fleetbind.go's WriteFleetBinding, an UNPREFIXED store — see its |
| + // doc comment for why this deliberately does NOT go through the |
| + // CARLOS_STATUS_PREFIX-wrapped store statusLoop/metricsLoop use. |
| + if fleet := strings.TrimSpace(os.Getenv("CARLOS_FLEET")); fleet != "" { |
| + boxLabel := status.Label(readMachineID()) |
| + if fleetStore, err := openDeployStore(context.Background()); err != nil { |
| + log.Printf("agent: CARLOS_FLEET=%s set but deploy store unavailable: %v — fleet bindings disabled", fleet, err) |
| + } else { |
| + if svc != nil { |
| + svc.OnWake = fleetBindingHook(fleetStore, fleet, boxLabel) |
| + } else { |
| + log.Printf("agent: CARLOS_FLEET=%s set but the hibernation activator is not armed — wake-time fleet bindings will never publish", fleet) |
| + } |
| + go publishResidentFleetBindings(fleetStore, fleet, boxLabel, reg) |
| + } |
| + } |
| + |
| go adoptLoop(reg) |
| go canaryAdoptLoop(reg) |
| go statusLoop(reg) |
| @@ -893,6 +917,14 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| } else if cfg.ReleasePub == nil { |
| log.Printf("agent: domains loop auto-purge disabled: CARLOS_RELEASE_PUBKEY is not set — trashed apps will be hidden but never destroyed") |
| } |
| + // Platform-only: a fleet box (CARLOS_FLEET set) never reads other fleets' |
| + // bindings — it only ever WRITES its own (fleetbind.go's |
| + // fleetBindingHook/publishResidentFleetBindings, wired in RunAgent). |
| + // Reusing shouldSkipPublisherElection rather than re-testing the env var |
| + // here: both questions are exactly "is this box a fleet box", so one |
| + // predicate, already covered by TestShouldSkipPublisherElection, answers |
| + // both. |
| + isFleetBox := shouldSkipPublisherElection(os.Getenv) |
| delay := time.Minute |
| for { |
| time.Sleep(delay) |
| @@ -900,6 +932,21 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| if err := domains.Reconcile(ctx, store, reg, cfg); err != nil { |
| log.Printf("agent: domains pass: %v", err) |
| } |
| + // Fleet binding materialization (customer-fleets plan, Task 6): turn |
| + // every fleet's published fleets/*/bindings/*.json object into this |
| + // platform box's own registry.Route.FleetBox, on the SAME store and |
| + // cadence domainsLoop already has open — see ReconcileFleetBindings' |
| + // doc comment (fleetbind.go) for the corrupt-object/newest-wins |
| + // contract. Riding this loop rather than a new one is a real |
| + // coupling: it means fleet-binding reconciliation is inert whenever |
| + // PLATFORM_APPS_DOMAIN is unset (this loop's own inert gate, above), |
| + // even though bindings have nothing to do with custom domains. Flagged |
| + // for review rather than fixed unasked — see this task's report. |
| + if !isFleetBox { |
| + if err := ReconcileFleetBindings(ctx, store, reg); err != nil { |
| + log.Printf("agent: fleet bindings reconcile: %v", err) |
| + } |
| + } |
| } |
| } |
diff --git a/internal/host/fleetbind.go b/internal/host/fleetbind.go| new file mode 100644 |
| index 000000000..a5ba263b7 |
| --- /dev/null |
| +++ b/internal/host/fleetbind.go |
| @@ -0,0 +1,219 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "os" |
| + "path" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// FleetBinding is the deployment-bucket object a fleet box publishes to say |
| +// "as of BoundAt, Box (a status.Label) is the box currently serving Host". |
| +// Fleet boxes are outbound-only — they dial the platform edge over a |
| +// reverse tunnel and can never write the platform box's registry directly |
| +// (customer-fleets design spec, "Routing to N boxes: the lease stamp") — so |
| +// this bucket object is the ONLY channel a fleet box has to tell the |
| +// platform which of its boxes currently holds a given hostname's lease. |
| +// ReconcileFleetBindings is the platform-side reader that turns it back |
| +// into registry.Route.FleetBox. |
| +type FleetBinding struct { |
| + Host string `json:"host"` |
| + Fleet string `json:"fleet"` |
| + Box string `json:"box"` // status.Label of the holding box |
| + BoundAt int64 `json:"bound_at"` // unix seconds |
| +} |
| + |
| +// fleetBindingKey is the one place the object's key is spelled, shared by |
| +// the writer and the reader below: fleets/<fleet>/bindings/<host>.json, |
| +// ABSOLUTE in the deployment bucket. |
| +// |
| +// Deliberately not routed through release.NewPrefixStore's |
| +// CARLOS_STATUS_PREFIX rebasing: that wrapper exists so a fleet box's own |
| +// status/metrics/alerts heartbeats land under its private |
| +// "fleets/<name>/" corner instead of the platform's shared paths — but a |
| +// binding is not a heartbeat, it is a cross-box MESSAGE the platform box |
| +// must read back, and it already carries its own fleet name (b.Fleet) in |
| +// the struct. Both WriteFleetBinding and ReconcileFleetBindings take an |
| +// UNPREFIXED release.Store and compute this absolute key themselves, so |
| +// there is exactly one key format to keep in sync, and a fleet box's own |
| +// CARLOS_STATUS_PREFIX (which does apply to its status/metrics stores) has |
| +// no bearing on where its bindings land. |
| +func fleetBindingKey(fleet, host string) string { |
| + return path.Join("fleets", fleet, "bindings", host+".json") |
| +} |
| + |
| +// WriteFleetBinding publishes b to the deployment bucket, replacing whatever |
| +// is currently at its key (create-or-replace). s must be an UNPREFIXED |
| +// store handle — see fleetBindingKey's doc comment. The read-etag-then-Put |
| +// dance mirrors status.WriteBox exactly, and for the same reason: a box |
| +// republishes to the SAME key on every wake and once more at startup, so a |
| +// bare create-only Put (ifMatch "") would fail every write after the first |
| +// with ErrCASConflict. |
| +func WriteFleetBinding(ctx context.Context, s release.Store, b FleetBinding) error { |
| + body, err := json.Marshal(b) |
| + if err != nil { |
| + return err |
| + } |
| + key := fleetBindingKey(b.Fleet, b.Host) |
| + etag, err := currentBindingEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + // Lost the race against a concurrent republish (another wake, or the |
| + // startup pass, writing the same key) — re-read once and retry, the |
| + // same single-retry contract status.WriteBox uses. |
| + etag, err = currentBindingEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// currentBindingEtag reads key's current etag for a replace-only Put, |
| +// folding "doesn't exist yet" into "" (create-only) — the same convention |
| +// status.currentEtag and metrics' currentAlertEtag each carry locally rather |
| +// than sharing. |
| +func currentBindingEtag(ctx context.Context, s release.Store, key string) (string, error) { |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", nil |
| + } |
| + return "", err |
| + } |
| + return etag, nil |
| +} |
| + |
| +// ReconcileFleetBindings is the platform-side half of fleet routing: list |
| +// every fleet's fleets/<fleet>/bindings/*.json object, and for each host |
| +// whose newest binding names a fleet box THIS platform box doesn't already |
| +// have stamped, call registry.SetFleetBox to catch it up. |
| +// |
| +// s must be an UNPREFIXED store handle (see fleetBindingKey) — this walks |
| +// the same absolute "fleets/" keys WriteFleetBinding writes, and is only |
| +// ever called on platform boxes, which never set CARLOS_STATUS_PREFIX in |
| +// the first place. |
| +// |
| +// Newest BoundAt wins when more than one fleet's binding names the same |
| +// host — a bare hostname collision across fleets should never happen in |
| +// practice, but nothing enforces it at the bucket layer, so ties are broken |
| +// deterministically rather than by map/listing order. A host with NO |
| +// binding object at all (a non-fleet route, or a fleet route this platform |
| +// box hasn't heard from yet) is left completely alone — this function only |
| +// ever calls SetFleetBox for a host it found a binding for. |
| +// |
| +// Corrupt objects and per-key read failures are logged and skipped, never |
| +// fatal — the loadBoxes precedent (internal/status/fleet.go:197-216): one |
| +// bad binding must not blank out routing for every other fleet host. A |
| +// missing route (the binding names a host this platform box doesn't carry) |
| +// is silently skipped too — it isn't this platform box's route to stamp. |
| +func ReconcileFleetBindings(ctx context.Context, s release.Store, reg *registry.Registry) error { |
| + fleetDirs, err := s.ListDirs(ctx, "fleets/") |
| + if err != nil { |
| + return err |
| + } |
| + best := make(map[string]FleetBinding) |
| + for _, dir := range fleetDirs { |
| + keys, err := s.List(ctx, dir+"bindings/") |
| + if err != nil { |
| + log.Printf("host: fleet bindings: list %s: %v", dir, err) |
| + continue |
| + } |
| + for _, key := range keys { |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("host: fleet bindings: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var b FleetBinding |
| + if err := json.Unmarshal(body, &b); err != nil { |
| + log.Printf("host: fleet bindings: skipping %s: corrupt binding: %v", key, err) |
| + continue |
| + } |
| + if cur, ok := best[b.Host]; !ok || b.BoundAt > cur.BoundAt { |
| + best[b.Host] = b |
| + } |
| + } |
| + } |
| + for host, b := range best { |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + log.Printf("host: fleet bindings: read route %s: %v", host, err) |
| + continue |
| + } |
| + if rt == nil || rt.FleetBox == b.Box { |
| + continue |
| + } |
| + if err := reg.SetFleetBox(host, b.Box); err != nil { |
| + log.Printf("host: fleet bindings: stamp %s -> %s: %v", host, b.Box, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// fleetBindingHook builds the activator's OnWake hook for a fleet box: on |
| +// every successful wake, publish a FleetBinding naming this box as the |
| +// current holder. activator.Service already invokes OnWake in its own |
| +// goroutine (see activator.go's wake), so this function runs off the wake |
| +// path entirely — a slow or failing bucket Put can never delay or fail a |
| +// request. Errors are logged only: a binding is eventually-consistent by |
| +// design (the design spec's failure sequence — box goes dark, heartbeat |
| +// ages, lease expires, the next request wakes a surviving box, which |
| +// restamps — is exactly the fallback that covers a dropped write here). |
| +func fleetBindingHook(store release.Store, fleet, box string) func(host string) { |
| + return func(host string) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + b := FleetBinding{Host: host, Fleet: fleet, Box: box, BoundAt: time.Now().Unix()} |
| + if err := WriteFleetBinding(ctx, store, b); err != nil { |
| + log.Printf("agent: fleet binding for %s: %v", host, err) |
| + } |
| + } |
| +} |
| + |
| +// publishResidentFleetBindings runs once at fleet-box agent startup: every |
| +// route already present in this box's local registry gets a binding |
| +// written immediately, without waiting for an activator wake. Two gaps this |
| +// closes that a wake-only publish would miss: |
| +// - an agent restart discards activator.Service's in-memory entries, but |
| +// an already-running exec child (or a unit-backed tenant) keeps serving |
| +// — its host would otherwise have no fresh binding until its NEXT idle |
| +// wake, which may be a long time coming, or never for an always-awake |
| +// sidecar host; |
| +// - a route that isn't activator-managed at all (Hibernate unset, or a |
| +// unit-backed Backing) never goes through activator.wake in the first |
| +// place, so it would never get a binding published any other way. |
| +// |
| +// Best-effort and non-fatal per host: a failed List aborts the whole pass |
| +// (logged; the next restart tries again), but one host's write failure does |
| +// not stop the rest of the pass from covering everyone else. |
| +func publishResidentFleetBindings(store release.Store, fleet, box string, reg *registry.Registry) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("agent: fleet bindings startup pass: list routes: %v", err) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
| + defer cancel() |
| + now := time.Now().Unix() |
| + for _, rt := range routes { |
| + b := FleetBinding{Host: rt.Host, Fleet: fleet, Box: box, BoundAt: now} |
| + if err := WriteFleetBinding(ctx, store, b); err != nil { |
| + 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| new file mode 100644 |
| index 000000000..69c6166e7 |
| --- /dev/null |
| +++ b/internal/host/fleetbind_test.go |
| @@ -0,0 +1,335 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + return reg |
| +} |
| + |
| +// --- WriteFleetBinding -------------------------------------------------------- |
| + |
| +func TestWriteFleetBindingRoundTrips(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + b := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1700000000} |
| + |
| + if err := WriteFleetBinding(ctx, s, b); err != nil { |
| + t.Fatalf("WriteFleetBinding: %v", err) |
| + } |
| + |
| + body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + var got FleetBinding |
| + if err := json.Unmarshal(body, &got); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + if got != b { |
| + t.Fatalf("round-tripped binding = %+v, want %+v", got, b) |
| + } |
| +} |
| + |
| +// A box republishes to the SAME key on every wake and once more at agent |
| +// startup — a bare create-only Put would fail every write after the first |
| +// with ErrCASConflict, which is exactly the bug the read-etag-then-Put dance |
| +// (mirroring status.WriteBox) exists to avoid. |
| +func TestWriteFleetBindingOverwritesExistingKey(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + first := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 100} |
| + second := FleetBinding{Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 200} |
| + |
| + if err := WriteFleetBinding(ctx, s, first); err != nil { |
| + t.Fatalf("first write: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, second); err != nil { |
| + t.Fatalf("second write (must replace, not conflict): %v", err) |
| + } |
| + |
| + body, _, err := s.Get(ctx, "fleets/pi/bindings/one.example.com.json") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + var got FleetBinding |
| + if err := json.Unmarshal(body, &got); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + if got.BoundAt != 200 { |
| + t.Fatalf("BoundAt = %d, want the second write's 200", got.BoundAt) |
| + } |
| +} |
| + |
| +// --- ReconcileFleetBindings --------------------------------------------------- |
| + |
| +func TestReconcileFleetBindingsStampsMatchingRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, FleetBinding{ |
| + Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000, |
| + }); err != nil { |
| + t.Fatalf("WriteFleetBinding: %v", err) |
| + } |
| + |
| + if err := ReconcileFleetBindings(ctx, s, reg); err != nil { |
| + t.Fatalf("ReconcileFleetBindings: %v", err) |
| + } |
| + |
| + rt, err := reg.Get("one.example.com") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if rt.FleetBox != "amber-gliding" { |
| + t.Fatalf("FleetBox = %q, want amber-gliding", rt.FleetBox) |
| + } |
| +} |
| + |
| +// A route with no published binding at all — a non-fleet route, or a fleet |
| +// route this platform box hasn't heard from yet — must be left completely |
| +// alone: ReconcileFleetBindings only ever calls SetFleetBox for a host it |
| +// found a binding for. |
| +func TestReconcileFleetBindingsLeavesNonFleetRoutesAlone(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "static.example.com", Kind: "static"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + |
| + if err := ReconcileFleetBindings(ctx, s, reg); err != nil { |
| + t.Fatalf("ReconcileFleetBindings: %v", err) |
| + } |
| + |
| + rt, err := reg.Get("static.example.com") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if rt.FleetBox != "" { |
| + t.Fatalf("FleetBox = %q, want empty (untouched)", rt.FleetBox) |
| + } |
| +} |
| + |
| +// Two different fleets both naming the same host (a bare hostname collision |
| +// the bucket layer itself never prevents) must resolve to the binding with |
| +// the greater BoundAt, regardless of listing/iteration order — writing the |
| +// OLDER one last (over a map, whose iteration order Go deliberately |
| +// randomizes) is what actually exercises the "compare, don't just take the |
| +// latest write" logic rather than an accidental last-one-wins. |
| +func TestReconcileFleetBindingsPrefersNewestBoundAt(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "shared.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, FleetBinding{ |
| + Host: "shared.example.com", Fleet: "pi", Box: "old-box", BoundAt: 500, |
| + }); err != nil { |
| + t.Fatalf("write pi binding: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, FleetBinding{ |
| + Host: "shared.example.com", Fleet: "eleven", Box: "new-box", BoundAt: 900, |
| + }); err != nil { |
| + t.Fatalf("write eleven binding: %v", err) |
| + } |
| + |
| + if err := ReconcileFleetBindings(ctx, s, reg); err != nil { |
| + t.Fatalf("ReconcileFleetBindings: %v", err) |
| + } |
| + |
| + rt, err := reg.Get("shared.example.com") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if rt.FleetBox != "new-box" { |
| + t.Fatalf("FleetBox = %q, want new-box (BoundAt 900 beats 500)", rt.FleetBox) |
| + } |
| +} |
| + |
| +// A corrupt binding object must be logged and skipped, never fatal — the |
| +// loadBoxes precedent (internal/status/fleet.go) — and must not stop the |
| +// pass from stamping every OTHER host's valid binding. |
| +func TestReconcileFleetBindingsSkipsCorruptJSON(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "good.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert good: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "bad.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert bad: %v", err) |
| + } |
| + if _, err := s.Put(ctx, "fleets/pi/bindings/bad.example.com.json", []byte("{ not json"), ""); err != nil { |
| + t.Fatalf("seed corrupt object: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, FleetBinding{ |
| + Host: "good.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000, |
| + }); err != nil { |
| + t.Fatalf("WriteFleetBinding: %v", err) |
| + } |
| + |
| + if err := ReconcileFleetBindings(ctx, s, reg); err != nil { |
| + t.Fatalf("ReconcileFleetBindings: %v", err) |
| + } |
| + |
| + good, err := reg.Get("good.example.com") |
| + if err != nil { |
| + t.Fatalf("Get good: %v", err) |
| + } |
| + if good.FleetBox != "amber-gliding" { |
| + t.Fatalf("good.FleetBox = %q, want amber-gliding despite the corrupt sibling", good.FleetBox) |
| + } |
| + bad, err := reg.Get("bad.example.com") |
| + if err != nil { |
| + t.Fatalf("Get bad: %v", err) |
| + } |
| + if bad.FleetBox != "" { |
| + t.Fatalf("bad.FleetBox = %q, want empty (corrupt binding never stamped)", bad.FleetBox) |
| + } |
| +} |
| + |
| +// Already-stamped, matching FleetBox must not trigger a needless SetFleetBox |
| +// call — proven indirectly by asserting the value survives a second |
| +// reconcile pass unchanged (a broken idempotence bug would show up as a |
| +// SetFleetBox call against a host with a matching value, which is harmless |
| +// here but would be loud with a fake registry; this pins the observable |
| +// behavior instead). |
| +func TestReconcileFleetBindingsIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if err := WriteFleetBinding(ctx, s, FleetBinding{ |
| + Host: "one.example.com", Fleet: "pi", Box: "amber-gliding", BoundAt: 1000, |
| + }); err != nil { |
| + t.Fatalf("WriteFleetBinding: %v", err) |
| + } |
| + for i := 0; i < 2; i++ { |
| + if err := ReconcileFleetBindings(ctx, s, reg); err != nil { |
| + t.Fatalf("ReconcileFleetBindings pass %d: %v", i, err) |
| + } |
| + } |
| + rt, err := reg.Get("one.example.com") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if rt.FleetBox != "amber-gliding" { |
| + t.Fatalf("FleetBox = %q, want amber-gliding", rt.FleetBox) |
| + } |
| +} |
| + |
| +// --- publishResidentFleetBindings (agent-startup pass) ------------------------ |
| + |
| +func TestPublishResidentFleetBindingsCoversEveryLocalRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: "one.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert one: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "two.example.com", Kind: "instance"}); err != nil { |
| + t.Fatalf("upsert two: %v", err) |
| + } |
| + |
| + publishResidentFleetBindings(s, "pi", "amber-gliding", reg) |
| + |
| + for _, host := range []string{"one.example.com", "two.example.com"} { |
| + body, _, err := s.Get(ctx, fleetBindingKey("pi", host)) |
| + if err != nil { |
| + t.Fatalf("Get %s: %v", host, err) |
| + } |
| + var b FleetBinding |
| + if err := json.Unmarshal(body, &b); err != nil { |
| + t.Fatalf("unmarshal %s: %v", host, err) |
| + } |
| + if b.Host != host || b.Fleet != "pi" || b.Box != "amber-gliding" || b.BoundAt == 0 { |
| + t.Fatalf("binding for %s = %+v, want host/fleet/box populated and a nonzero BoundAt", host, b) |
| + } |
| + } |
| +} |
| + |
| +// A registry with no routes at all must not error or panic — a startup pass |
| +// on a freshly-provisioned fleet box, before any host has been assigned. |
| +func TestPublishResidentFleetBindingsEmptyRegistryIsNoop(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + publishResidentFleetBindings(s, "pi", "amber-gliding", reg) |
| + keys, err := s.List(context.Background(), "fleets/") |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + if len(keys) != 0 { |
| + t.Fatalf("keys = %v, want none written for an empty registry", keys) |
| + } |
| +} |
| + |
| +// --- fleetBindingHook (fire-and-forget wake publish) -------------------------- |
| + |
| +// errPutStore fails every Put while otherwise behaving like a normal |
| +// DirStore (Get/List/ListDirs/Delete pass through) — enough to exercise |
| +// WriteFleetBinding's error path without a broken embedded nil Store. |
| +type errPutStore struct{ release.Store } |
| + |
| +func (errPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + return "", errors.New("boom: put refused") |
| +} |
| + |
| +func TestFleetBindingHookLogsErrorWithoutPanicking(t *testing.T) { |
| + var buf bytes.Buffer |
| + prev := log.Writer() |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(prev) |
| + |
| + store := errPutStore{Store: release.NewDirStore(t.TempDir())} |
| + hook := fleetBindingHook(store, "pi", "amber-gliding") |
| + |
| + hook("one.example.com") // 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()) |
| + } |
| +} |
| + |
| +func TestFleetBindingHookWritesBinding(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + hook := fleetBindingHook(s, "pi", "amber-gliding") |
| + |
| + hook("woken.example.com") |
| + |
| + body, _, err := s.Get(ctx, fleetBindingKey("pi", "woken.example.com")) |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + var b FleetBinding |
| + if err := json.Unmarshal(body, &b); err != nil { |
| + t.Fatalf("unmarshal: %v", err) |
| + } |
| + if b.Host != "woken.example.com" || b.Fleet != "pi" || b.Box != "amber-gliding" { |
| + t.Fatalf("binding = %+v, want host/fleet/box populated from the hook's closure", b) |
| + } |
| +} |