host+edge: door-backed status prefix, app-log writes, logship cutover (spec F3/F5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+232
−29
docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md+2 −2internal/edge/storedoor.go+55 −9internal/edge/storedoor_test.go+23 −1internal/host/agent.go+55 −5internal/host/agent_test.go+72 −2internal/host/logship.go+20 −8internal/host/metrics_loop.go+5 −2
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md| index ce95f9c00..b6bc091eb 100644 |
| --- a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md |
| +++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md |
| @@ -49,9 +49,9 @@ reconcile, config, status, metrics, bindings — runs unchanged. |
| |---|---|---| |
| | F1 | **Transport is the edge's reserved tunnel hostname**, new paths beside `/register`: `GET/PUT/DELETE /store/o/<key>`, `GET /store/list?prefix=`, `GET /store/dirs?prefix=`. Same headers as the tunnel dial: `Authorization: Bearer <token>` + `Carlos-Tunnel-Name: <fleet>/<label>`. | The reserved host is already TLS'd, already routed, already carries the only box-shaped credential the platform can verify. No new hostname, cert, or credential mint. | |
| | F2 | **Auth = the dynamic-auth table**, via a new `TunnelHub.VerifyDynamic(name, token) (fleet string, ok bool)` refactored from ServeHTTP's dynamic branch (constant-time hash compare, single-401 shape preserved; static `CARLOS_TUNNEL_CLIENTS` names never authenticate here — this door is for fleet boxes only). | One credential, one hash table, one revocation path: detach/rotate already converge to the edge in seconds and now revoke API access with the same motion. | |
| -| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>/**` (the account manifest is `accounts/<sqid>/account.json` — `domains.AccountManifestKey`, one path level deep — NOT `accounts/<sqid>.json`; a box that cannot read it sees zero accounts and `provision.Reconcile` retires every provisioned route on it), and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset, INCLUDING the delimiter-aware listing — `ListDirs("accounts/")` returns one child prefix per account on the platform, so it is filtered per tenant, not against the prefix it was listed under. WRITE/DELETE allow `fleets/<own-fleet>/**` only. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle). | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate and writes only its own corner. A compromised customer box can no longer read other tenants or touch platform releases. | |
| +| F3 | **ACL is account-scoped + own-prefix**, derived server-side from the authenticated fleet's registry row (`fleets.account` → sqid): READ allows `control/serial`, `bootstrap/carlos`, `accounts/<own-sqid>/**` (the account manifest is `accounts/<sqid>/account.json` — `domains.AccountManifestKey`, one path level deep — NOT `accounts/<sqid>.json`; a box that cannot read it sees zero accounts and `provision.Reconcile` retires every provisioned route on it), and `apps/<own-sqid>/**`; list of `accounts/` and `apps/` answers only the own-account subset, INCLUDING the delimiter-aware listing — `ListDirs("accounts/")` returns one child prefix per account on the platform, so it is filtered per tenant, not against the prefix it was listed under. WRITE/DELETE allow `fleets/<own-fleet>/**` and `apps/<own-sqid>/<one app segment>/logs/**` (the app-log chunks `logshipLoop` writes and prunes — `applog.Prefix`), and nothing else. Everything else: byte-identical 404 (never 403 — the keyspace must not be an oracle).<br><br>The logs grant is **segment-exact**, and the exactness is the security property: `apps/<sqid>/<app>/logs/**`, never `apps/<sqid>/**` and never a "the key contains `logs/`" test. A blanket own-account write would let a box pin its own `channels/*.json` (self-promoting any build it likes) or mint `instances/*.json` records (self-authorizing routes) — the exact writes this door exists to prevent.<br><br>**Caveat (accepted):** the `<box>` segment inside an app's log subtree is an unverifiable pseudonym (`status.Label` of a machine id the box chose), so one box of an account can delete a same-account sibling box's chunks. Blast radius is inside a single account's own logs — no other tenant, no release data — and pruning its own subtree is what logship must do hourly. Optional hardening if it ever matters: bind the box segment to the authenticated channel label, which needs a per-box identity the door does not carry today.<br><br>**Deliberately NOT granted:** provisioning record writes (`apps/<sqid>/<app>/instances/*.json`). A box's `provision.Reconcile` makes zero store writes — every `Put`/`Delete` on those keys is console- or CLI-side — so there is nothing to grant, and granting it would hand a box self-authorization over its own routes. | This is the issue's "account-scoped surface" literally: a fleet reads only its owning account's estate, and writes only its own corner plus the log chunks it produces about its own tenant's apps. A compromised customer box can no longer read other tenants or touch platform releases. | |
| | F4 | **CAS passes through**: Get returns the backing etag in `ETag`; Put requires `If-Match` (or `If-None-Match: *` for create), maps conflict to 412; the client maps 412 back to `release.ErrCASConflict`. Deletes are idempotent 204. | The lease-adjacent writers (heartbeat CAS, binding takeover guard) keep their exact semantics through the proxy. | |
| -| F5 | **Client = `internal/fleetapi.Store`**, implementing `release.Store` over the F1 wire with the box's existing `CARLOS_TUNNEL_URL`/`_NAME`/`_TOKEN` env. Selection: `openDeployStore` (and the status/metrics bucket resolution) returns the API store **when fleet identity resolves AND `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_STATUS_BUCKET` is unset**; a bucket still set wins (the issue's migration fallback — platform boxes and grandfathered fleet boxes keep direct S3, byte-for-byte unchanged). | Env-driven cutover, box by box, no flag day: drop the bucket var + AWS creds from a box's env and it is on the API; put them back and it is off. | |
| +| F5 | **Client = `internal/fleetapi.Store`**, implementing `release.Store` over the F1 wire with the box's existing `CARLOS_TUNNEL_URL`/`_NAME`/`_TOKEN` env. Selection: `openDeployStore` (and the status/metrics bucket resolution) returns the API store **when fleet identity resolves AND `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_STATUS_BUCKET` is unset**; a bucket still set wins (the issue's migration fallback — platform boxes and grandfathered fleet boxes keep direct S3, byte-for-byte unchanged). Log shipping arms off the same selection (so `carlos logs` keeps working on a bucketless box, with keys left UNPREFIXED — the console reads the platform layout). On the door branch only, an empty `CARLOS_STATUS_PREFIX` defaults to `fleets/<fleet>/`: status/metrics at the shared root are outside the door's write ACL and would 404 forever. | Env-driven cutover, box by box, no flag day: drop the bucket var + AWS creds from a box's env and it is on the API; put them back and it is off. | |
| | F6 | **retentionLoop does not arm on a fleet box** (fleet identity present → skip with one log line). | A customer box scheduling deletes of platform-bucket releases was wrong under direct S3 too; the ACL would refuse it anyway — skipping avoids an hourly error loop. Platform boxes keep retention. | |
| | F7 | **`bootstrap/carlos` is readable through the door** (F3 allowlist). | Gives customer boxes their first sanctioned self-update path (#73's §9 gap): customer tooling can fetch the platform binary with the fleet token; sha256 discipline stays the operator's, as with pinfra's script. | |
| | F8 | **Artifact reads stream** (no presigned-URL redirect in v1). | Presigning re-exposes the bucket identity this spec exists to hide; edge egress for one fleet's artifacts is small, and the edge already streams tunnel traffic. Revisit only if a real fleet's adoption traffic hurts. | |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index e9a8dd7ab..f96443f79 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -165,7 +165,7 @@ func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fl |
| w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| w.Write(body) |
| case http.MethodPut: |
| - if !aclWrite(fleet, key) { |
| + if !aclWrite(sqid, fleet, key) { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -204,7 +204,7 @@ func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fl |
| } |
| w.WriteHeader(http.StatusOK) |
| case http.MethodDelete: |
| - if !aclWrite(fleet, key) { |
| + if !aclWrite(sqid, fleet, key) { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -404,16 +404,62 @@ func aclDir(sqid, fleet, dir string) bool { |
| strings.HasPrefix(dir, "fleets/"+fleet+"/") |
| } |
| -// aclWrite is the write half of F3 — and DELETE's half too, which is why it |
| -// takes no sqid: a box may touch its own fleet corner and nothing else. In |
| -// particular it can never delete a release artifact, which is the tamper |
| -// surface the whole spec exists to close (F6: retention does not arm on a |
| -// fleet box either). |
| -func aclWrite(fleet, key string) bool { |
| +// aclWrite is the write half of F3 — and DELETE's half too. Two scopes, and |
| +// only two: |
| +// |
| +// fleets/<fleet>/** its own corner |
| +// apps/<sqid>/<app>/logs/** its own account's app logs (applog.Prefix) |
| +// |
| +// It can never write a release artifact, a channel pointer, or an instance |
| +// record, which is the tamper surface the whole spec exists to close (F6: |
| +// retention does not arm on a fleet box either). |
| +// |
| +// The logs scope is SEGMENT-EXACT on purpose, and the exactness is the |
| +// security property: the grant is apps/<sqid>/<ONE app segment>/logs/**, never |
| +// apps/<sqid>/** and never a "logs/ appears somewhere in the key" test. A |
| +// blanket own-account write would let a box pin its own channels/*.json (self |
| +// -promoting any build it likes) or mint instances/*.json records |
| +// (self-authorizing routes) — precisely the writes this door exists to |
| +// prevent. Log chunks are the one thing a box legitimately produces about its |
| +// own tenant's apps (applog.ChunkKey), and they are write-once objects under a |
| +// per-box subtree. |
| +// |
| +// HONEST LIMIT: the <box> segment inside that subtree is an unverifiable |
| +// pseudonym (status.Label of a machine id the box chose), so one box of an |
| +// account can in principle delete a same-account sibling box's chunks. The |
| +// blast radius is inside a single account's own logs — no other tenant, no |
| +// release data — and pruning its own subtree is exactly what logship must do |
| +// every hour; verifying the segment would need a per-box identity the door |
| +// does not have today. |
| +func aclWrite(sqid, fleet, key string) bool { |
| if fleet == "" { |
| return false |
| } |
| - return strings.HasPrefix(key, "fleets/"+fleet+"/") |
| + if strings.HasPrefix(key, "fleets/"+fleet+"/") { |
| + return true |
| + } |
| + return aclWriteAppLogs(sqid, key) |
| +} |
| + |
| +// aclWriteAppLogs implements aclWrite's second scope: exactly |
| +// apps/<sqid>/<app>/logs/<something>, with <app> a single non-empty segment |
| +// and at least one key component below logs/. Anything else — a shorter key, |
| +// a second path shape, "logs" as the app segment — is refused, so the grant |
| +// can never be widened by a key that merely contains the word. |
| +func aclWriteAppLogs(sqid, key string) bool { |
| + if sqid == "" { |
| + return false |
| + } |
| + rest, ok := strings.CutPrefix(key, "apps/"+sqid+"/") |
| + if !ok { |
| + return false |
| + } |
| + app, tail, ok := strings.Cut(rest, "/") |
| + if !ok || app == "" { |
| + return false |
| + } |
| + below, ok := strings.CutPrefix(tail, "logs/") |
| + return ok && below != "" |
| } |
| // aclListScope maps a requested listing prefix onto the prefix the door will |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index 65419d7c0..34f5abb05 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -63,7 +63,7 @@ func aclAllows(key string, op aclOp) bool { |
| if op == opRead { |
| return aclRead(testSqid, testFleet, k) |
| } |
| - return aclWrite(testFleet, k) |
| + return aclWrite(testSqid, testFleet, k) |
| } |
| func TestStoreDoorACLMatrix(t *testing.T) { |
| @@ -102,6 +102,28 @@ func TestStoreDoorACLMatrix(t *testing.T) { |
| {"apps/ab12/web/channels/stable.json", opWrite, false, "a box never promotes"}, |
| {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opDelete, false, "retention is not a box's job (F6)"}, |
| + // --- own account's app LOGS: the second (and only other) write scope --- |
| + // applog.ChunkKey's shape, which is what logshipLoop PUTs and what its |
| + // hourly prune DELETEs. The rows below the allows are the regression |
| + // pins that keep this from becoming a blanket apps/<sqid>/** grant. |
| + {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opWrite, true, "own app's log chunk"}, |
| + {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opDelete, true, "logship prunes its own subtree hourly"}, |
| + {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opRead, true, "reads already covered by the app estate"}, |
| + {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opWrite, false, "another account's app logs"}, |
| + {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opDelete, false, "nor delete them"}, |
| + {"apps/ab12x/web/logs/box-a/x.jsonl.gz", opWrite, false, "sqid prefix must not match a longer sqid"}, |
| + {"apps/ab12/web/instances/h.example.json", opWrite, false, "REGRESSION: the logs grant must not mint instance records"}, |
| + {"apps/ab12/web/config/production.json", opWrite, false, "REGRESSION: nor rewrite config"}, |
| + {"apps/ab12/web/restart-gen.json", opWrite, false, "REGRESSION: nor request its own restarts"}, |
| + {"apps/ab12/web/releases/v1.2.3/manifest.json", opWrite, false, "REGRESSION: nor publish a release"}, |
| + {"apps/ab12/logs/box-a/x.jsonl.gz", opWrite, false, "logs must sit under an app segment, not the account root"}, |
| + {"apps/ab12/web/logs", opWrite, false, "the logs prefix itself is not an object"}, |
| + {"apps/ab12/web/logs/", opWrite, false, "nor is the bare prefix with a slash"}, |
| + {"apps/ab12/web/channels/logs/stable.json", opWrite, false, "\"logs\" deeper in the key grants nothing"}, |
| + {"apps/ab12/weblogs/box-a/x.jsonl.gz", opWrite, false, "the segment is logs/, not a suffix of the app name"}, |
| + {"apps/ab12/web/logs/../../zz99/web/logs/x.jsonl.gz", opWrite, false, "traversal out of the logs subtree"}, |
| + {"apps/ab12/web/logs/../channels/stable.json", opWrite, false, "traversal out of logs into channels"}, |
| + |
| // --- other tenants --- |
| {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"}, |
| {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index d22e0bdcf..0418d37d4 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -40,7 +40,11 @@ |
| // instead of the shared paths every platform box also writes — so |
| // neither its status heartbeat nor its metrics/alerts ever collide with |
| // (or leak into) the platform's own fleet.json, metrics view, or alert |
| -// webhook. Empty (every platform box today) is a pure passthrough. A |
| +// webhook. Empty (every platform box today) is a pure passthrough — |
| +// EXCEPT on a door-backed fleet box, where empty defaults to |
| +// "fleets/<fleet>/" because the door admits no other write scope |
| +// (resolveStatusPrefix). Log shipping is deliberately NOT rebased: see |
| +// logship.go. A |
| // PUBLISHING box with a prefix set would relocate status/fleet.json |
| // itself under that prefix too — moot today since nothing sets a prefix |
| // on a publishing (non-fleet) box, and a fleet box never publishes at |
| @@ -186,11 +190,18 @@ func RunAgent(args []string) { |
| cgm := cgroup.Setup(log.Printf) |
| // Log shipping (`carlos logs`): one sink for every producer in this |
| - // process, armed only when a bucket exists to ship to — a nil sink |
| + // process, armed only when there is somewhere to ship to — a nil sink |
| // leaves every producer exactly as before (no tees, no pipes, no |
| // events). The box identity is the one metrics publishes under. |
| + // |
| + // The gate is openStatusStore, not a bucket test: a cut-over fleet box has |
| + // no bucket at all and ships through the store door, and gating on the |
| + // bucket alone would leave `carlos logs` silently empty for exactly the |
| + // boxes an operator can't SSH into. Constructing the store here to throw |
| + // it away is deliberate and cheap (neither branch makes a call), and it |
| + // asks precisely the question logshipLoop will ask again for itself. |
| var logSink *applog.Sink |
| - if resolveStatusBucket(os.Getenv) != "" { |
| + if _, _, err := openStatusStore(context.Background(), os.Getenv); err == nil { |
| logSink = applog.NewSink(status.Label(readMachineID()), time.Now) |
| } |
| @@ -1582,7 +1593,7 @@ func statusLoop(reg *registry.Registry) { |
| // with no bucket behind it (openStatusStore). Every other box resolves a |
| // real bucket here exactly as before. |
| bucket := resolveStatusBucket(os.Getenv) |
| - prefix := os.Getenv("CARLOS_STATUS_PREFIX") |
| + prefix := resolveStatusPrefix(os.Getenv) |
| region := os.Getenv("CARLOS_STATUS_REGION") |
| boxRegion := os.Getenv("STATUS_BOX_REGION") |
| skipElection := shouldSkipPublisherElection(os.Getenv) |
| @@ -1642,7 +1653,16 @@ func statusLoop(reg *registry.Registry) { |
| // nil, same as every other unmeasured probe in this loop. Resolved once |
| // here, not re-read every tick, mirroring adoptLoop's own one-time |
| // resolution. |
| - canaryStore, _ := openDeployStore(ctx) |
| + // |
| + // The error is logged rather than swallowed: "no deployment store" is a |
| + // valid box shape, but since the store can now also fail to build for a |
| + // REAL misconfiguration (a fleet box with no bucket and no tunnel env — |
| + // openFleetDoorStore's loud error), silence here would turn that into an |
| + // unexplained permanently-nil ExpiredCanaries probe. |
| + canaryStore, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: status loop: expired-canary probe unavailable: %v", err) |
| + } |
| canaryPub, _ := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| // status.SnapshotPeriod IS this cadence — the const is named for the tick |
| @@ -2015,6 +2035,36 @@ func openFleetDoorStore(getenv func(string) string, identity FleetIdentity) (rel |
| return fleetapi.New(base, name, token) |
| } |
| +// resolveStatusPrefix returns the prefix every key statusLoop and metricsLoop |
| +// touch is rebased under (release.NewPrefixStore at both call sites). |
| +// |
| +// An explicit CARLOS_STATUS_PREFIX always wins, unchanged, on every box. What |
| +// this adds is the DOOR-branch default: a fleet box publishing through the |
| +// store door with an EMPTY prefix would otherwise write status/boxes/... and |
| +// metrics/boxes/... at the shared platform root, and the door's write ACL |
| +// admits only fleets/<own fleet>/** — every heartbeat and every flush would |
| +// 404, forever, on a box that looks correctly configured. So on that branch |
| +// alone the prefix defaults to "fleets/<fleet>/", which is exactly the layout |
| +// the console's fleet page already reads. |
| +// |
| +// S3-path semantics are deliberately untouched: an empty prefix on a |
| +// bucket-backed box is the platform box's shared root, which is correct |
| +// there, and a bucket-backed fleet box keeps whatever the operator set (the |
| +// customer-fleets rollout sets CARLOS_STATUS_PREFIX=fleets/<name>/ by hand). |
| +func resolveStatusPrefix(getenv func(string) string) string { |
| + if p := getenv("CARLOS_STATUS_PREFIX"); p != "" { |
| + return p |
| + } |
| + if resolveStatusBucket(getenv) != "" { |
| + return "" |
| + } |
| + identity, err := resolveFleetIdentity(getenv) |
| + if err != nil { |
| + return "" |
| + } |
| + return "fleets/" + identity.Fleet + "/" |
| +} |
| + |
| // errNoStatusStore is openStatusStore's "this deployment simply hasn't |
| // configured one" sentinel — distinct from a real construction failure, so |
| // each loop can keep its own long-standing disabled-log wording for the |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index 33b06e9a9..e0c4c1c8b 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -1472,6 +1472,72 @@ func TestOpenStatusStoreSelection(t *testing.T) { |
| }) |
| } |
| +// TestResolveStatusPrefixDefaultsOnTheDoorBranch pins the fix for a fleet box |
| +// that would otherwise 404 forever: through the door, the write ACL admits |
| +// only fleets/<own fleet>/**, so a door-backed box with an EMPTY |
| +// CARLOS_STATUS_PREFIX must rebase itself rather than write at the shared |
| +// platform root. Bucket-backed boxes keep today's semantics exactly — an |
| +// empty prefix there IS the correct shared root. |
| +func TestResolveStatusPrefixDefaultsOnTheDoorBranch(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + env map[string]string |
| + want string |
| + }{ |
| + {"platform box, no prefix", map[string]string{"CARLOS_STATUS_BUCKET": "b"}, ""}, |
| + {"platform box, explicit prefix", map[string]string{"CARLOS_STATUS_BUCKET": "b", "CARLOS_STATUS_PREFIX": "x/"}, "x/"}, |
| + {"bucket-backed fleet box keeps its own setting", map[string]string{ |
| + "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box", |
| + "CARLOS_STATUS_BUCKET": "b", "CARLOS_STATUS_PREFIX": "fleets/pi/", |
| + }, "fleets/pi/"}, |
| + {"bucket-backed fleet box with no prefix is left alone", map[string]string{ |
| + "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box", "CARLOS_STATUS_BUCKET": "b", |
| + }, ""}, |
| + {"door-backed fleet box defaults", map[string]string{ |
| + "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box", |
| + "CARLOS_TUNNEL_URL": "https://t.example.com", "CARLOS_TUNNEL_TOKEN": "tok", |
| + }, "fleets/pi/"}, |
| + {"door-backed fleet box with an explicit prefix keeps it", map[string]string{ |
| + "CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "pi-box", |
| + "CARLOS_TUNNEL_URL": "https://t.example.com", "CARLOS_TUNNEL_TOKEN": "tok", |
| + "CARLOS_STATUS_PREFIX": "custom/", |
| + }, "custom/"}, |
| + {"no fleet, no bucket", map[string]string{}, ""}, |
| + } { |
| + if got := resolveStatusPrefix(envFunc(c.env)); got != c.want { |
| + t.Errorf("%s: resolveStatusPrefix = %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// TestDoorBackedStatusWriteLandsInTheFleetCorner is the behavior half: the two |
| +// lines statusLoop and metricsLoop actually run (openStatusStore + |
| +// NewPrefixStore over resolveStatusPrefix) must put a heartbeat at |
| +// fleets/<fleet>/status/boxes/..., the one place the door will accept it and |
| +// the exact layout the console's fleet page reads. |
| +func TestDoorBackedStatusWriteLandsInTheFleetCorner(t *testing.T) { |
| + ctx := context.Background() |
| + door := newDoorFake(t) |
| + env := envFunc(fleetDoorEnv(door.url)) // no bucket, no CARLOS_STATUS_PREFIX |
| + |
| + raw, _, err := openStatusStore(ctx, env) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + store := release.NewPrefixStore(raw, resolveStatusPrefix(env)) |
| + // The door fake 404s anything but the serial, so the Put errors — the |
| + // assertion is the KEY that went on the wire, not the response. |
| + _, _ = store.Put(ctx, "status/boxes/pi-box.json", []byte("{}"), "") |
| + |
| + hits := door.seen() |
| + if len(hits) != 1 { |
| + t.Fatalf("door saw %d requests, want 1: %+v", len(hits), hits) |
| + } |
| + if want := "/store/o/fleets/pi/status/boxes/pi-box.json"; hits[0].Path != want { |
| + t.Fatalf("door path = %q, want %q — an unrebased key is outside the door's write ACL and 404s forever", hits[0].Path, want) |
| + } |
| +} |
| + |
| // TestRetentionLoopIsAPlatformBoxJob pins spec F6. A customer's box pruning |
| // the PLATFORM's releases was wrong under direct S3 and is refused outright |
| // through the door (its write ACL scopes a fleet to fleets/<fleet>/**), so a |
| @@ -1489,7 +1555,7 @@ func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| - retentionLoop(nil) // must return; a fleet box never reaches the loop |
| + retentionLoop(testRegistry(t)) // must return; a fleet box never reaches the loop |
| if got := logs(); !strings.Contains(got, skipLine) { |
| t.Fatalf("log = %q, want the F6 skip line verbatim", got) |
| @@ -1524,7 +1590,11 @@ func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| log.SetFlags(0) |
| t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) }) |
| - go retentionLoop(nil) |
| + // A REAL registry, never nil: this goroutine outlives the test and |
| + // runs retentionPass a minute from now, which lists routes — nil |
| + // would panic the whole test binary long after the test that started |
| + // it has passed. |
| + go retentionLoop(testRegistry(t)) |
| deadline := time.Now().Add(5 * time.Second) |
| for { |
diff --git a/internal/host/logship.go b/internal/host/logship.go| index 6861c0a28..1f097be4d 100644 |
| --- a/internal/host/logship.go |
| +++ b/internal/host/logship.go |
| @@ -14,6 +14,7 @@ package host |
| import ( |
| "context" |
| + "errors" |
| "log" |
| "os" |
| "time" |
| @@ -46,17 +47,28 @@ func logshipLoop(sink *applog.Sink, box string) { |
| }() |
| ctx := context.Background() |
| - bucket := resolveStatusBucket(os.Getenv) |
| - if bucket == "" { |
| - log.Printf("agent: logship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| - return |
| - } |
| - s, err := release.NewS3Store(ctx, bucket) |
| + // Same store selection as statusLoop/metricsLoop (agent.go's |
| + // openStatusStore): a bucket wins, and a cut-over fleet box with no bucket |
| + // ships through the edge's store door instead of going silent. |
| + // |
| + // The keys stay UNPREFIXED — deliberately unlike status and metrics, which |
| + // rebase under CARLOS_STATUS_PREFIX. A log chunk is the app's own data |
| + // (applog.Prefix = release.AppPrefix + "logs/"), and the console reads it |
| + // straight from the platform layout (applog.ReadWindow over |
| + // apps/<sqid>/<app>/logs/**); rebasing under fleets/<name>/ would file a |
| + // fleet box's logs somewhere `carlos logs` never looks. The door's write |
| + // ACL admits exactly this shape for the box's own account (aclWrite's |
| + // segment-exact apps/<sqid>/<app>/logs/** scope). |
| + s, target, err := openStatusStore(ctx, os.Getenv) |
| if err != nil { |
| - log.Printf("agent: logship loop disabled: %v", err) |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: logship loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| + } else { |
| + log.Printf("agent: logship loop disabled: %v", err) |
| + } |
| return |
| } |
| - log.Printf("agent: logship loop armed (10s ticks, event fast-flush), publishing to bucket %s", bucket) |
| + log.Printf("agent: logship loop armed (10s ticks, event fast-flush), publishing to %s", target) |
| pending := make(map[applog.AppKey][]applog.Record) |
| pruned := "" // last hour a prune ran for, "" = not yet |
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go| index 9e0cb945a..4427e393e 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -761,8 +761,11 @@ func metricsLoop(execRoot string) { |
| // metricsview lists metrics/boxes/) and an alert would page the |
| // platform's own webhook for customer hardware. Empty prefix (every |
| // platform box today) is a pure passthrough — see the package doc |
| - // comment's CARLOS_STATUS_PREFIX entry. |
| - s := release.NewPrefixStore(raw, os.Getenv("CARLOS_STATUS_PREFIX")) |
| + // comment's CARLOS_STATUS_PREFIX entry. resolveStatusPrefix, not a bare |
| + // env read: on a door-backed fleet box an empty prefix would put every |
| + // flush outside the door's write ACL, so that branch defaults to |
| + // fleets/<fleet>/. |
| + s := release.NewPrefixStore(raw, resolveStatusPrefix(os.Getenv)) |
| box := metricsBoxLabel(os.Getenv, readMachineID()) |
| statePath := filepath.Join(dataDir(), "monitor", "alerts.json") |