host: fleet boxes heartbeat under their fleet prefix and never publish fleet.json
Task 5 of the customer-fleets plan. CARLOS_REGION's fleet/<name> placement already adopts via reconcile.go's existing string-equality Region filter (pinned with a new provision test, zero code change). CARLOS_FLEET marks an agent as a fleet box: shouldSkipPublisherElection short-circuits statusLoop before lease.Acquire is ever called, so a fleet box never contends for the publish lease and never runs BuildFleet/PublishFleet, while statusTick's unconditional WriteBox still self-reports every tick. CARLOS_STATUS_PREFIX previously only namespaced the lease key; it now also rebases every key the tick's store touches (prefixStore/newPrefixStore), so a fleet box's own snapshot actually lands at fleets/<name>/status/boxes/<label>.json instead of colliding with the shared status/boxes/ path platform boxes use (they share a bucket per the design's Trust section). Empty prefix is a pure passthrough - no behavior change for existing deployments. A new status contract test pins that BuildFleet's loadBoxes, which lists exactly "status/boxes/", never picks up a fleet-prefixed snapshot - owner-only visibility by construction, no allowlist to bypass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+370
−28
README.md+26 −13internal/host/agent.go+131 −15internal/host/host_test.go+123 −0internal/provision/reconcile_test.go+41 −0internal/status/contract_test.go+49 −0
diff --git a/README.md b/README.md| index d02050170..3ec23b5eb 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -335,19 +335,32 @@ no single reader owns the whole contract: |
| - `CARLOS_STATUS_BUCKET` / `CARLOS_STATUS_PREFIX` / `CARLOS_STATUS_REGION` |
| — read by `carlos agent`'s status loop: the bucket each box writes its |
| snapshot to and, when it wins the lease, publishes the merged feed to; |
| - prefix and region also feed the cross-box status-lease election. **The |
| - serving side (the edge's `kind=status` route and the console's operator |
| - view) must be pointed at this same bucket** — nothing enforces that |
| - agreement automatically, publishing and serving are separately |
| - configured. To make the common case foolproof, an unset |
| - `CARLOS_STATUS_BUCKET` defaults to `CARLOS_DEPLOYMENT_BUCKET` (an |
| - explicit `CARLOS_STATUS_BUCKET` still always wins), so a single-bucket |
| - deployment needs only one env var for both release adoption and status. |
| - Both unset disables the whole loop — inert until configured, the same |
| - convention litestream replication uses. **Honest limitation**: that |
| - default only ever resolves to an S3 bucket — a deployment adopting from |
| - `CARLOS_DEPLOYMENT_DIR` (no bucket at all) does not publish fleet status |
| - yet; give it its own `CARLOS_STATUS_BUCKET` if it wants the page. |
| + region also feeds the cross-box status-lease election. **The serving side |
| + (the edge's `kind=status` route and the console's operator view) must be |
| + pointed at this same bucket** — nothing enforces that agreement |
| + automatically, publishing and serving are separately configured. To make |
| + the common case foolproof, an unset `CARLOS_STATUS_BUCKET` defaults to |
| + `CARLOS_DEPLOYMENT_BUCKET` (an explicit `CARLOS_STATUS_BUCKET` still |
| + always wins), so a single-bucket deployment needs only one env var for |
| + both release adoption and status. Both unset disables the whole loop — |
| + inert until configured, the same convention litestream replication uses. |
| + **Honest limitation**: that default only ever resolves to an S3 bucket — |
| + a deployment adopting from `CARLOS_DEPLOYMENT_DIR` (no bucket at all) |
| + does not publish fleet status yet; give it its own `CARLOS_STATUS_BUCKET` |
| + if it wants the page. `CARLOS_STATUS_PREFIX` does double duty: it |
| + namespaces the status-lease key, AND (customer-fleets plan, Task 5) |
| + rebases every key the tick's store touches — a fleet box set to |
| + `CARLOS_STATUS_PREFIX=fleets/<name>/` writes its own snapshot at |
| + `fleets/<name>/status/boxes/<label>.json` instead of the shared |
| + `status/boxes/` path, so it never collides with (or leaks into) the |
| + platform's own `fleet.json`. Empty (every platform box today) is a pure |
| + passthrough. |
| +- `CARLOS_FLEET` — read by `carlos agent`'s status loop (customer-fleets |
| + plan, Task 5). Set (to the fleet's name) marks this agent as a fleet box: |
| + it still self-reports its own snapshot every tick, but never contends for |
| + the cross-box publish lease and never merges/publishes `status/fleet.json` |
| + — that feed belongs to the platform's own boxes only. Unset (every |
| + platform box today) is unaffected. |
| - `STATUS_BOX_REGION` — also read by `carlos agent`'s status loop: an |
| operator-set, coarse region word (e.g. `"EU · Ireland"`) carried onto |
| this box's snapshot verbatim; unset omits the field. Free text the |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 53743493c..2327766fe 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -27,10 +27,24 @@ |
| // the loop never starts, logging one line and returning — the same |
| // "inert until configured" convention adoptLoop uses for |
| // CARLOS_DEPLOYMENT_BUCKET/_DIR, and litestream's own env-gated |
| -// replication before that. CARLOS_STATUS_PREFIX and CARLOS_STATUS_REGION |
| -// feed lease.Acquire (the single-writer election for the publish half |
| -// of each tick); both may be empty (region falls back to the AWS |
| -// default chain). |
| +// replication before that. CARLOS_STATUS_REGION feeds lease.Acquire |
| +// (the single-writer election for the publish half of each tick); it |
| +// may be empty (falls back to the AWS default chain). CARLOS_STATUS_PREFIX |
| +// does double duty: it namespaces that same lease key (path.Join(prefix, |
| +// "status-lease")) AND, since Task 5 of the customer-fleets plan, |
| +// rebases every key statusTick's store touches (newPrefixStore) — a |
| +// fleet box set to CARLOS_STATUS_PREFIX=fleets/<name>/ writes its own |
| +// snapshot at fleets/<name>/status/boxes/<label>.json instead of the |
| +// shared status/boxes/ path every platform box also writes, so its |
| +// heartbeat never collides with (or leaks into) the platform's own |
| +// fleet.json. Empty (every platform box today) is a pure passthrough. |
| +// - CARLOS_FLEET — read by statusLoop (Task 5, customer-fleets plan). Set |
| +// (to the fleet's name — its value is otherwise unused here) marks this |
| +// agent as a fleet box: shouldSkipPublisherElection short-circuits |
| +// publish to false BEFORE lease.Acquire is ever called, so a fleet box |
| +// never contends for the cross-box publish lease and never runs |
| +// BuildFleet/PublishFleet — it still self-reports via statusTick's |
| +// unconditional WriteBox, just under its own CARLOS_STATUS_PREFIX. |
| // HONEST LIMIT: the CARLOS_DEPLOYMENT_BUCKET fallback only ever |
| // resolves to an S3 bucket. A deployment adopting from |
| // CARLOS_DEPLOYMENT_DIR (a plain directory — tests, or a single-box |
| @@ -947,6 +961,92 @@ func ResolveStatusBucket(getenv func(string) string) string { |
| return getenv("CARLOS_DEPLOYMENT_BUCKET") |
| } |
| +// shouldSkipPublisherElection reports whether this tick must never contend |
| +// for the cross-box publish lease at all — true exactly when CARLOS_FLEET is |
| +// set (Task 5, customer-fleets plan): a fleet box self-reports its own Box |
| +// snapshot every tick (statusTick's WriteBox call is unconditional) but must |
| +// never win — or even attempt — the election that would have it merge and |
| +// publish status/fleet.json, which is the platform's own boxes' shared feed. |
| +// Checked BEFORE lease.Acquire is ever called, not after: a fleet box never |
| +// makes the network call, not merely one that's discarded on return. |
| +func shouldSkipPublisherElection(getenv func(string) string) bool { |
| + return getenv("CARLOS_FLEET") != "" |
| +} |
| + |
| +// prefixStore rebases every key a caller works with onto a fixed base |
| +// (CARLOS_STATUS_PREFIX's actual enforcement point, Task 5 of the |
| +// customer-fleets plan) before it ever reaches the underlying store. A |
| +// fleet box's own snapshot is written through one of these with prefix |
| +// "fleets/<name>/", so status.WriteBox's hardcoded "status/boxes/<label>.json" |
| +// key lands at "fleets/<name>/status/boxes/<label>.json" — outside the exact |
| +// "status/boxes/" prefix status.BuildFleet's loadBoxes lists, and so |
| +// invisible to the platform's own fleet.json by construction (pinned by |
| +// internal/status's contract test), with no change needed to the status |
| +// package itself. List/ListDirs strip the base back off their results, so a |
| +// key List hands back still round-trips through this SAME wrapper's Get/Put |
| +// without doubling the prefix. |
| +type prefixStore struct { |
| + release.Store |
| + base string // always "" or ends in "/" |
| +} |
| + |
| +// newPrefixStore wraps s so every key is rebased under prefix. An empty |
| +// prefix returns s unchanged — every platform box today leaves |
| +// CARLOS_STATUS_PREFIX unset, and this must be a pure no-op for them. |
| +func newPrefixStore(s release.Store, prefix string) release.Store { |
| + if prefix == "" { |
| + return s |
| + } |
| + if !strings.HasSuffix(prefix, "/") { |
| + prefix += "/" |
| + } |
| + return prefixStore{Store: s, base: prefix} |
| +} |
| + |
| +func (p prefixStore) full(key string) string { return p.base + key } |
| + |
| +func (p prefixStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + return p.Store.Get(ctx, p.full(key)) |
| +} |
| + |
| +func (p prefixStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + return p.Store.Put(ctx, p.full(key), body, ifMatch) |
| +} |
| + |
| +func (p prefixStore) Delete(ctx context.Context, key string) error { |
| + return p.Store.Delete(ctx, p.full(key)) |
| +} |
| + |
| +// List rebases the query prefix the same way Get/Put rebase a key, then |
| +// strips its own base back off every result — the underlying store's keys |
| +// always start with p.base (that's what "queried under p.full(prefix)" |
| +// guarantees), so TrimPrefix here exactly undoes full() above, and the |
| +// returned keys round-trip through this wrapper's Get unchanged. |
| +func (p prefixStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := p.Store.List(ctx, p.full(prefix)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, len(keys)) |
| + for i, k := range keys { |
| + out[i] = strings.TrimPrefix(k, p.base) |
| + } |
| + return out, nil |
| +} |
| + |
| +// ListDirs mirrors List's rebase-then-strip, for the delimiter-aware listing. |
| +func (p prefixStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + dirs, err := p.Store.ListDirs(ctx, p.full(prefix)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, len(dirs)) |
| + for i, d := range dirs { |
| + out[i] = strings.TrimPrefix(d, p.base) |
| + } |
| + return out, nil |
| +} |
| + |
| // statusLoop runs the fleet status page's 5-minute tick, forever, once armed |
| // — see the package doc comment above for the full env contract. Missing |
| // CARLOS_STATUS_BUCKET disables the loop with one log line, the same |
| @@ -977,12 +1077,18 @@ func statusLoop(reg *registry.Registry) { |
| prefix := os.Getenv("CARLOS_STATUS_PREFIX") |
| region := os.Getenv("CARLOS_STATUS_REGION") |
| boxRegion := os.Getenv("STATUS_BOX_REGION") |
| + skipElection := shouldSkipPublisherElection(os.Getenv) |
| s, err := release.NewS3Store(ctx, bucket) |
| if err != nil { |
| log.Printf("agent: status loop disabled: %v", err) |
| return |
| } |
| + // store is what statusTick actually reads/writes through: identical to s |
| + // (a pure passthrough) unless CARLOS_STATUS_PREFIX is set, in which case |
| + // every key it touches is rebased under that prefix — see newPrefixStore |
| + // and the package doc comment's CARLOS_STATUS_PREFIX entry. |
| + store := newPrefixStore(s, prefix) |
| // The effective bucket is worth a log line on its own: it's easy to set |
| // CARLOS_DEPLOYMENT_BUCKET and CARLOS_STATUS_BUCKET to different values |
| // by accident (or on purpose) and end up with the agent writing status |
| @@ -1068,20 +1174,30 @@ func statusLoop(reg *registry.Registry) { |
| Tunnel: tunnelState(), |
| } |
| - // path.Join (an S3 key, not a filesystem path — matches |
| - // activator.go's Config.hibernatePrefix convention) rather than a |
| - // bare "+" concat: guards against a trailing-slash |
| - // CARLOS_STATUS_PREFIX producing a doubled "//status-lease" key. |
| - held, acqErr := lease.Acquire(ctx, bucket, path.Join(prefix, "status-lease"), region) |
| - publish := acqErr == nil |
| - if acqErr != nil { |
| - var heldErr *lease.HeldError |
| - if !errors.As(acqErr, &heldErr) { |
| - log.Printf("agent: status tick: lease acquire: %v", acqErr) |
| + // A fleet box (CARLOS_FLEET set) never gets here as a contender: it |
| + // self-reports only, never attempting the election at all — no |
| + // lease.Acquire call, no BuildFleet, no PublishFleet. See |
| + // shouldSkipPublisherElection and the package doc comment's |
| + // CARLOS_FLEET entry. |
| + var held *lease.Held |
| + publish := false |
| + if !skipElection { |
| + // path.Join (an S3 key, not a filesystem path — matches |
| + // activator.go's Config.hibernatePrefix convention) rather than a |
| + // bare "+" concat: guards against a trailing-slash |
| + // CARLOS_STATUS_PREFIX producing a doubled "//status-lease" key. |
| + var acqErr error |
| + held, acqErr = lease.Acquire(ctx, bucket, path.Join(prefix, "status-lease"), region) |
| + publish = acqErr == nil |
| + if acqErr != nil { |
| + var heldErr *lease.HeldError |
| + if !errors.As(acqErr, &heldErr) { |
| + log.Printf("agent: status tick: lease acquire: %v", acqErr) |
| + } |
| } |
| } |
| - if err := statusTick(ctx, s, reg, p, publish); err != nil { |
| + if err := statusTick(ctx, store, reg, p, publish); err != nil { |
| log.Printf("agent: status tick: %v", err) |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index fcca235b5..68e9343aa 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -304,6 +304,129 @@ func TestStatusTickWritesBoxAndPublishesOnlyWhenAsked(t *testing.T) { |
| } |
| } |
| +// ---- Task 5 (customer-fleets plan): publisher guard + fleet-prefixed store - |
| + |
| +// TestShouldSkipPublisherElection pins the CARLOS_FLEET contract: a fleet |
| +// box (CARLOS_FLEET set to anything non-empty) must skip the publisher |
| +// election, and a platform box (unset, the every-deployment-today case) |
| +// must not. statusLoop checks this BEFORE calling lease.Acquire — see the |
| +// call site — so this also documents that the decision needs no lease, no |
| +// bucket, no network at all: it's a pure function of one env var. |
| +func TestShouldSkipPublisherElection(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + cases := []struct { |
| + name string |
| + env map[string]string |
| + want bool |
| + }{ |
| + {"fleet box skips the election", map[string]string{"CARLOS_FLEET": "pi"}, true}, |
| + {"platform box still elects", map[string]string{}, false}, |
| + {"empty CARLOS_FLEET is not set", map[string]string{"CARLOS_FLEET": ""}, false}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + if got := shouldSkipPublisherElection(env(c.env)); got != c.want { |
| + t.Errorf("shouldSkipPublisherElection(%v) = %v, want %v", c.env, got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPrefixStoreRebasesKeys pins newPrefixStore's own contract in |
| +// isolation, before the fleet-heartbeat integration test below composes it |
| +// with statusTick: Put/Get/List/ListDirs all operate in "unprefixed" key |
| +// space from the caller's side, while the underlying store actually holds |
| +// everything under the fixed base — and a key List hands back must |
| +// round-trip through the SAME wrapper's Get without doubling the prefix. |
| +func TestPrefixStoreRebasesKeys(t *testing.T) { |
| + ctx := context.Background() |
| + under := release.NewDirStore(t.TempDir()) |
| + wrapped := newPrefixStore(under, "fleets/pi/") |
| + |
| + if _, err := wrapped.Put(ctx, "status/boxes/pi-box.json", []byte(`{"ok":true}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The underlying store must hold the REBASED key, not the caller's one. |
| + if _, _, err := under.Get(ctx, "fleets/pi/status/boxes/pi-box.json"); err != nil { |
| + t.Errorf("underlying store missing rebased key: %v", err) |
| + } |
| + if _, _, err := under.Get(ctx, "status/boxes/pi-box.json"); err == nil { |
| + t.Error("underlying store must not hold the unrebased key") |
| + } |
| + |
| + // List, queried in the caller's unprefixed space, must find it and hand |
| + // back a key that still round-trips through the SAME wrapper's Get. |
| + keys, err := wrapped.List(ctx, "status/boxes/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 1 || keys[0] != "status/boxes/pi-box.json" { |
| + t.Fatalf("List = %v, want exactly [status/boxes/pi-box.json]", keys) |
| + } |
| + if body, _, err := wrapped.Get(ctx, keys[0]); err != nil || string(body) != `{"ok":true}` { |
| + t.Errorf("Get(%q) via the wrapper = %q, %v", keys[0], body, err) |
| + } |
| + |
| + // An empty prefix must be a pure passthrough: the same store, unwrapped. |
| + if s := newPrefixStore(under, ""); s != release.Store(under) { |
| + t.Errorf("newPrefixStore with empty prefix = %v, want the underlying store unchanged", s) |
| + } |
| +} |
| + |
| +// TestFleetBoxHeartbeatLandsUnderFleetPrefixNeverPublishes is the end-to-end |
| +// pin for Task 5's whole contract, composing the pieces the two tests above |
| +// verify in isolation: a fleet box's tick (store wrapped in |
| +// CARLOS_STATUS_PREFIX="fleets/pi/", publish forced false the way |
| +// shouldSkipPublisherElection drives statusLoop) writes ITS OWN Box |
| +// snapshot at "fleets/pi/status/boxes/<label>.json" in the underlying |
| +// bucket — never at the shared "status/boxes/<label>.json" a platform box |
| +// uses, and never status/fleet.json either. |
| +func TestFleetBoxHeartbeatLandsUnderFleetPrefixNeverPublishes(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "static"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + under := release.NewDirStore(t.TempDir()) |
| + fleetStore := newPrefixStore(under, "fleets/pi/") |
| + ctx := context.Background() |
| + probes := status.Probes{MachineID: "pi-box"} |
| + |
| + // publish=false is what shouldSkipPublisherElection forces statusLoop |
| + // into for a fleet box — lease.Acquire is never even called. |
| + if err := statusTick(ctx, fleetStore, reg, probes, false); err != nil { |
| + t.Fatalf("statusTick: %v", err) |
| + } |
| + |
| + boxes, err := under.List(ctx, "fleets/pi/status/boxes/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 { |
| + t.Fatalf("fleets/pi/status/boxes/ = %v, want exactly one snapshot", boxes) |
| + } |
| + |
| + // The shared platform namespace must stay untouched: no box snapshot, |
| + // and definitely no fleet.json (which would only ever appear had |
| + // publish been true). |
| + sharedBoxes, err := under.List(ctx, "status/boxes/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(sharedBoxes) != 0 { |
| + t.Errorf("status/boxes/ = %v, want none — a fleet box must never write the shared namespace", sharedBoxes) |
| + } |
| + if _, _, err := under.Get(ctx, "status/fleet.json"); err == nil { |
| + t.Error("a fleet box must never publish status/fleet.json") |
| + } |
| +} |
| + |
| // TestWrapListeningProbeNullsUnitBackedSockets is fix round 1's regression |
| // test: listeningProbe's raw /proc/net/unix read is confidently WRONG for |
| // systemd-socket-activated (registry.BackingUnit) routes, since the |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 19f6aebba..5fe3bfb1d 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -167,6 +167,47 @@ func TestReconcileScope(t *testing.T) { |
| mustGet(t, reg, "shared.go.tito.io") |
| } |
| +// 2b. Fleet placement rides the same Region filter, string-equal, unchanged. |
| +// |
| +// TestReconcileFleetRegionMatchesByStringEquality pins the customer-fleets |
| +// plan's Task 5 Step 1: a record placed on a fleet (Region: "fleet/pi") is |
| +// adopted by a reconcile pass configured for that exact fleet region, and |
| +// left alone by a pass configured for a different (platform) region — |
| +// reconcile.go:125's `rec.Region != cfg.Region` is plain string equality, |
| +// so "fleet/pi" needs no new grammar to be recognized: it's simply another |
| +// opaque region string, exactly like "ie" or "us" already are. Exercises |
| +// the real Reconcile entry point (not a reimplementation of the filter) so |
| +// a future refactor of the scope check can't silently narrow it. |
| +func TestReconcileFleetRegionMatchesByStringEquality(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: sqid, Pool: "tito", Region: "fleet/pi", Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + |
| + // A pass configured for a platform region must not adopt a fleet-placed |
| + // record — "eu" != "fleet/pi", same as any other region mismatch. |
| + if err := Reconcile(ctx, s, reg, ReconcileConfig{Region: "eu", Pools: []string{"tito"}, DataDir: "/data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("minted for the wrong region (eu != fleet/pi): %+v %v", rt, err) |
| + } |
| + |
| + // The fleet's own box — configured with the exact "fleet/pi" region — |
| + // adopts it with zero code change: the same Region == cfg.Region check |
| + // every platform region already uses. |
| + if err := Reconcile(ctx, s, reg, ReconcileConfig{Region: "fleet/pi", Pools: []string{"tito"}, DataDir: "/data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := mustGet(t, reg, "acme.go.tito.io") |
| + if got.Region != "fleet/pi" { |
| + t.Errorf("adopted route Region = %q, want fleet/pi", got.Region) |
| + } |
| +} |
| + |
| // 3. Idempotent. |
| func TestReconcileIdempotent(t *testing.T) { |
| s, sqid := setup(t) |
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index 0ad706fa6..862f239c4 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -283,6 +283,55 @@ func TestFleetSchemaAllowlisted(t *testing.T) { |
| assertOnlyKeys(t, j, allowedFleetKeys) |
| } |
| +// TestBuildFleetIgnoresFleetPrefixedSnapshots pins the customer-fleets |
| +// plan's Task 5 Step 3 contract: BuildFleet's loadBoxes lists EXACTLY |
| +// "status/boxes/" (fleet.go:197) — a fleet box's heartbeat, written under |
| +// "fleets/<name>/status/boxes/<label>.json" per the fleet box env contract |
| +// (CARLOS_STATUS_PREFIX, internal/host's newPrefixStore), sits outside that |
| +// prefix entirely and so never surfaces on the platform's own public |
| +// fleet.json. This is owner-only visibility BY CONSTRUCTION — there is no |
| +// allowlist rule to bypass, loadBoxes simply never reads that path — the |
| +// same shape FleetBox never copying Box's "metrics" key already relies on. |
| +func TestBuildFleetIgnoresFleetPrefixedSnapshots(t *testing.T) { |
| + s := teststore(t) |
| + now := time.Now() |
| + |
| + // A fleet box's own snapshot, parked at the exact key its |
| + // CARLOS_STATUS_PREFIX=fleets/pi/ produces — same Box shape a platform |
| + // box's WriteBox would produce, just outside status/boxes/. |
| + fleetBox := boxFixture("pi-box", "plenty", now) |
| + body, err := json.Marshal(fleetBox) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(context.Background(), "fleets/pi/status/boxes/pi-box.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute) |
| + if err != nil { |
| + t.Fatalf("BuildFleet: %v", err) |
| + } |
| + if len(f.Boxes) != 0 { |
| + t.Errorf("Boxes = %+v, want none — a fleet-prefixed snapshot must never surface on the public fleet page", f.Boxes) |
| + } |
| + |
| + // Contrast case: a platform box's own snapshot, written through the |
| + // real WriteBox (the unprefixed key), MUST surface — proving the |
| + // exclusion above is specifically the fleets/ prefix, not a broken |
| + // loadBoxes that would hide everything. |
| + if err := status.WriteBox(context.Background(), s, boxFixture("platform-box", "plenty", now)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f2, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute) |
| + if err != nil { |
| + t.Fatalf("BuildFleet: %v", err) |
| + } |
| + if len(f2.Boxes) != 1 || f2.Boxes[0].Label != "platform-box" { |
| + t.Errorf("Boxes = %+v, want exactly platform-box (the fleet-prefixed snapshot still must not appear)", f2.Boxes) |
| + } |
| +} |
| + |
| // TestNoDigitsInBandSubtrees verifies that the band words (load, capacity) |
| // never carry numeric digits. The privacy commitment: never publish raw |
| // fractions or counts, only words. A digit in these fields would violate |