carlos / platform Public

host+edge: door-backed status prefix, app-log writes, logship cutover (spec F3/F5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a5c6cc354ae5e525592a800239c0b94d0b9a7de3 parent 0121890
7 files changed, +232 −29
  • docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md +2 −2
  • internal/edge/storedoor.go +55 −9
  • internal/edge/storedoor_test.go +23 −1
  • internal/host/agent.go +55 −5
  • internal/host/agent_test.go +72 −2
  • internal/host/logship.go +20 −8
  • internal/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")