Merge pull request #145 from carlosframework/worktree-fleet-api
Fleet API surface: the deployment bucket behind the edge (#73)
23 files changed,
+3718
−73
docs/superpowers/plans/2026-08-11-fleet-api-surface.md+97 −0docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md+115 −0infra/dev/region-pi.md+12 −0infra/eleven-fleet.md+160 −0infra/modules/README.md+9 −5infra/modules/region-remote-host/README.md+20 −3infra/modules/region-remote-host/main.tf+33 −13infra/modules/region-remote-host/variables.tf+2 −1internal/console/accountfleet_test.go+7 −0internal/console/templates/fleet_byo.html+19 −2internal/edge/edge.go+76 −0internal/edge/fleetapi_e2e_test.go+544 −0internal/edge/storedoor.go+505 −0internal/edge/storedoor_test.go+701 −0internal/edge/tunnel.go+38 −9internal/edge/tunnel_test.go+66 −0internal/fleetapi/fleetapi.go+254 −0internal/fleetapi/fleetapi_internal_test.go+39 −0internal/fleetapi/fleetapi_test.go+366 −0internal/host/agent.go+198 −22internal/host/agent_test.go+422 −0internal/host/logship.go+20 −8internal/host/metrics_loop.go+15 −10
diff --git a/docs/superpowers/plans/2026-08-11-fleet-api-surface.md b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md| new file mode 100644 |
| index 000000000..ff4d3a856 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-11-fleet-api-surface.md |
| @@ -0,0 +1,97 @@ |
| +# Fleet API Surface Implementation Plan (issue #73) |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Fleet boxes lose direct platform-bucket S3: a `release.Store` spoken over HTTPS through the edge's reserved hostname, fleet-token-authed, account-scoped server-side — per `docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md` (F1–F8). |
| + |
| +**Architecture:** New edge handler beside the tunnel `/register` reusing the dynamic-auth table; new `internal/fleetapi` client implementing `release.Store`; env-driven store selection on the box (bucket set wins = fallback). No loop changes. |
| + |
| +**Tech Stack:** Go stdlib HTTP; no new deps. |
| + |
| +## Global Constraints |
| + |
| +- Branch `worktree-fleet-api` from main 79f6fc2. Gate per commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all packages), `gofmt -l` clean on touched files, `GOOS=darwin go build ./...`. Env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox off only on "read-only file system". Known flake: internal/activator TestUnitTenantFailedHealthStopsUnit — re-run once. |
| +- Security invariants from the spec §Security are binding on every task: byte-identical 404s (no oracle), constant-time token compare, static names never authenticate the door, canonicalization before ACL, no token logging. |
| +- Edge test suite runs -race in CI: run `go test ./internal/edge/ -count=1 -race` in any task touching internal/edge. |
| +- Wire shape additive; platform boxes' S3 behavior byte-identical (regression-pinned in Task 4). |
| + |
| +--- |
| + |
| +### Task 1: `TunnelHub.VerifyDynamic` — auth extraction |
| + |
| +**Files:** Modify `internal/edge/tunnel.go` (ServeHTTP dynamic branch ~L129-138); Test `internal/edge/tunnel_test.go`. |
| + |
| +**Interfaces — Produces:** `func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool)` — ok only when `name` contains "/" AND is present in h.dynamicAuth AND the sha256-hex of token constant-time-matches; fleet = the segment before the first "/". ServeHTTP's dynamic branch calls it (behavior byte-identical: same single-401, viaDynamic = ok). |
| + |
| +Steps (TDD): failing tests — static name refused even with correct plaintext in h.clients; unknown dynamic name refused; wrong token refused; right token → (fleet, true); after SetDynamicAuth swap removing the name, refused. Then extract, re-run edge suite with -race, commit `edge: VerifyDynamic — the dynamic-auth check as a reusable gate`. |
| + |
| +### Task 2: the store door — edge handler + canonicalization + ACL |
| + |
| +**Files:** Create `internal/edge/storedoor.go`, `internal/edge/storedoor_test.go`. Modify `internal/edge/edge.go` (reserved-host routing: today it hands the reserved host to TunnelHub — route `/store/` paths to the new handler first, `/register` to the hub unchanged) and `internal/edge/tunnel.go` only if the hub needs a getter for the door. |
| + |
| +**Interfaces — Consumes:** Task 1's `VerifyDynamic`; the edge's existing deployment store handle (find how edge.go builds/holds its release store — reuse, do not open a second S3 client) and its registry handle (`e.reg.GetFleet(name)` for fleet→account sqid). **Produces:** |
| + |
| +```go |
| +// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4). |
| +type storeDoor struct { |
| + hub *TunnelHub // VerifyDynamic |
| + store release.Store // the deployment bucket, edge's own handle |
| + fleetAccount func(fleet string) (sqid string, ok bool) // registry lookup, cached per reload like FleetChannelAuth consumers |
| +} |
| +// Routes (reserved host only): |
| +// GET /store/o/<key> → Get (200 body + ETag; 404) |
| +// PUT /store/o/<key> → Put (If-Match or If-None-Match:*; 200 new ETag; 412) |
| +// DELETE /store/o/<key> → Delete (204 always for in-scope keys) |
| +// GET /store/list?prefix=<p> → List (200 JSON array of keys) |
| +// GET /store/dirs?prefix=<p> → ListDirs (200 JSON array) |
| +func canonicalKey(raw string) (string, bool) // refuses "..", "//", leading "/", empty, >1024 |
| +func aclRead(sqid, fleet, key string) bool // control/serial | bootstrap/carlos | accounts/<sqid>.json | apps/<sqid>/** |
| +func aclWrite(fleet, key string) bool // fleets/<fleet>/** only (write AND delete) |
| +func aclListScope(sqid, fleet, prefix string) (string, bool) // maps "apps/"→"apps/<sqid>/", "accounts/"→allowed-with-filter, "fleets/<fleet>/..."→itself; else false |
| +``` |
| + |
| +Every refusal — bad auth, bad key, out-of-scope, missing object — is `http.NotFound(w, r)` after auth ran; auth runs exactly once per request; the handler on any NON-reserved host is unreachable (assert in test via the edge's routing, mirroring how tunnel registration is host-gated). |
| + |
| +Steps (TDD): the ACL matrix table test FIRST (every inventory key class from the spec × op × own/other → allow/404 — write ~25 rows, exact keys like `apps/ab12/app/channels/stable.json`, `apps/OTHER/app/...`, `fleets/pi/bindings/h.json`, `fleets/other/...`, `control/serial`, `bootstrap/carlos`, `accounts/ab12.json`, `accounts/other.json`, `../etc`, `apps/ab12/../OTHER/x`); handler tests over httptest with a DirStore: CAS round-trip (Put no-precondition → 400; If-Match stale → 412; fresh → 200 new etag), Get missing → 404 byte-identical to out-of-scope 404, list scoping strips other accounts, static-name auth → 404s identical too. Then implement; `-race` suite; commit `edge: the store door — release.Store over HTTPS, account-scoped (spec F1-F4)`. |
| + |
| +### Task 3: `internal/fleetapi` — the client store |
| + |
| +**Files:** Create `internal/fleetapi/fleetapi.go`, `internal/fleetapi/fleetapi_test.go`. |
| + |
| +**Interfaces — Consumes:** Task 2's wire (test against the real storeDoor over httptest, not a hand-rolled fake). **Produces:** |
| + |
| +```go |
| +// New builds a release.Store speaking the edge store door. |
| +// base = CARLOS_TUNNEL_URL's origin, name/token = the box's channel identity. |
| +func New(base, name, token string) (release.Store, error) |
| +``` |
| + |
| +Semantics: Get returns (body, etag); Put sends If-Match (or If-None-Match:* when ifMatch==""... CHECK: read release.Store's Put contract for the empty-ifMatch meaning in DirStore/S3Store first and mirror it EXACTLY); 412 → `release.ErrCASConflict`; 404 on Get → the same not-found error S3Store returns (`os.ErrNotExist`-wrapped — check and mirror); Delete idempotent; List/ListDirs decode. Timeouts: 30s per call; artifact GETs must stream (return the body reader... CHECK Store.Get returns []byte — it does; keep []byte, buffered — fine for binaries the box already downloads whole today). No token in error strings. |
| + |
| +Steps: failing round-trip tests through a real storeDoor+DirStore; implement; commit `fleetapi: release.Store over the edge store door`. |
| + |
| +### Task 4: box-side selection + retention skip |
| + |
| +**Files:** Modify `internal/host/agent.go` (`openDeployStore` ~L1926; status-bucket resolution `resolveStatusBucket` consumers in statusLoop ~L1582 and metrics_loop.go ~L748; retentionLoop arming ~L276/1456); Test `internal/host/agent_test.go` (+ existing fixtures). |
| + |
| +**Interfaces — Consumes:** `fleetapi.New`, `resolveFleetIdentity` (fleetidentity.go). **Produces:** selection rule (spec F5): when `resolveFleetIdentity` succeeds AND the relevant bucket env (`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` for openDeployStore; `CARLOS_STATUS_BUCKET` else deployment for status/metrics) is UNSET → `fleetapi.New(os.Getenv("CARLOS_TUNNEL_URL"), identity string, token)`; bucket set → S3/dir exactly as today. retentionLoop: fleet identity present → log once `agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)` and return. |
| + |
| +Tests: table over env permutations pinning which store type each path builds (export a tiny seam if needed — prefer a package-level `deployStoreForTest` accessor pattern ONLY if an existing test seam convention exists; otherwise test via behavior: fake edge httptest server + env pointing at it, assert a serial read hits the door). Regression pin: bucket-set env yields S3-backed behavior with zero requests to the door. Retention skip test. Commit `host: fleet boxes speak the store door; retention stays a platform job (spec F5/F6)`. |
| + |
| +### Task 5: e2e — the fleet loop through the door |
| + |
| +**Files:** Modify/extend `internal/edge/fleet_e2e_test.go` (or a new `fleetapi_e2e_test.go` beside it — read the existing harness first and extend, don't duplicate). |
| + |
| +Re-run the existing two-fake-box fleet e2e with the boxes' deployment store swapped for `fleetapi.New` pointed at the test edge: adoption converges, serial poll works, heartbeat lands under `fleets/<name>/status/...` via the door, binding takeover works, and the ACL blocks box A's fleet from reading a second account's keys planted in the store. Commit `e2e: fleet boxes converge with no bucket credentials (spec proof)`. |
| + |
| +### Task 6: docs + module + BYO contract |
| + |
| +**Files:** Modify `internal/console/templates/fleet_byo.html` (env contract: the no-bucket form becomes primary — CARLOS_TUNNEL_URL/NAME/TOKEN + hibernate bucket creds only; deployment-bucket form demoted to a "legacy direct-S3" note), its test assertion in `internal/console/accountfleet_test.go` if strings pinned; `infra/dev/region-pi.md` (flip note); `infra/modules/region-remote-host/{variables.tf,main.tf,README.md}` (`deployment_bucket_arn` optional — default `""`, IAM statement emitted only when non-empty; README reframes the customer artifact); spec status line. |
| + |
| +Gate + commit `docs+module: fleet boxes without platform-bucket credentials`. |
| + |
| +--- |
| + |
| +## Self-review notes |
| + |
| +Spec coverage: F1→T2 routing; F2→T1; F3→T2 ACL; F4→T2+T3 CAS both ends; F5→T4; F6→T4; F7→T2 allowlist row; F8 is a non-feature (streaming = []byte Get, noted T3). Security §: every invariant appears as a named test in T1/T2. Inventory classes all appear in the T2 matrix. Types: `VerifyDynamic` (T1) consumed T2; `storeDoor` internal; `fleetapi.New` (T3) consumed T4/T5. Open checks flagged inline (Put empty-ifMatch contract, not-found error shape) — implementers resolve against DirStore/S3Store as instructed, never invent. |
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| new file mode 100644 |
| index 000000000..a56c06867 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md |
| @@ -0,0 +1,115 @@ |
| +# Fleet API Surface: the deployment bucket behind the edge (issue #73) |
| + |
| +**Date:** 2026-08-11 |
| +**Status:** All six implementation tasks built on branch `worktree-fleet-api` |
| +(edge store door, `internal/fleetapi` client, host env-driven store |
| +selection + F6 retention skip, this doc + BYO page + region-pi.md + |
| +region-remote-host module updates) — pending merge to `main` and the edge |
| +roll; designed autonomously in the unattended fleets session, Paul |
| +pre-approved the direction in issue #73 (2026-08-07: "don't paper over it |
| +with a bucket policy — abstract the bucket behind the carlos API"). |
| +**Depends on:** customer fleets v1+v2 (merged, main 00c3344); the tunnel |
| +dynamic-auth table (edge/tunnel.go). |
| +**Closes:** #73. **Unblocks:** untrusted-customer fleets, the Amadan |
| +cross-account fleet, the Eleven→Carloku flip without platform-bucket creds. |
| + |
| +## The problem (from the #73 inventory, verified at 00c3344) |
| + |
| +A fleet box holds AWS credentials with RW on the PLATFORM's deployment |
| +bucket. Every consumer flows through `release.Store` handles built in |
| +exactly three places on the box: `openDeployStore` (agent.go, 10 call |
| +sites), and `release.NewS3Store` in statusLoop + metricsLoop (status-bucket |
| +resolution). What those handles actually touch, complete inventory: |
| + |
| +- **Reads**: `control/serial` (every 2s); `apps/<sqid>/<app>/…` channel |
| + pointers, production flags, release manifests + artifacts, restart-gen, |
| + instance records, config bundles + environments + config-gen, trash |
| + markers; `accounts/` list + records; `bootstrap/carlos` has NO in-repo |
| + fetch path today (pinfra pushes over SSM — a customer box has no update |
| + path at all). |
| +- **Writes (CAS)**: heartbeats/metrics/alerts under the |
| + `fleets/<name>/…` prefix rebase; fleet bindings at |
| + `fleets/<name>/bindings/<host>.json` (unprefixed handle, same keyspace). |
| +- **Deletes**: metrics 48h prune under own prefix; `retentionLoop` would |
| + delete RELEASE artifacts platform-wide — armed on any box with the |
| + pubkey, which is exactly the tamper surface to close. |
| +- **Not on fleet boxes** (verified gates): status-lease election |
| + (`shouldSkipPublisherElection`), `ReconcileFleetBindings`, domainsLoop |
| + (no `PLATFORM_APPS_DOMAIN`). Instance leases + litestream live entirely |
| + in the customer's own hibernate bucket — untouched by this spec. |
| + |
| +## Design: `release.Store` over HTTPS, ACL'd server-side |
| + |
| +One new mechanism, not N endpoints: an edge-hosted **store door** that |
| +speaks the five `release.Store` operations over plain HTTPS, authenticated |
| +per-request by the credential the box already holds (its fleet token), with |
| +a server-side ACL derived from the fleet's identity. The box swaps its S3 |
| +store handle for an HTTP one; every loop — adoption, fast-converge, |
| +reconcile, config, status, metrics, bindings — runs unchanged. |
| + |
| +### Decisions |
| + |
| +| # | Decision | Why | |
| +|---|---|---| |
| +| 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>/**` 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. Wider than the sibling-box case, and accepted on the same terms: the logs grant is **account-wide**, so a compromised fleet box can forge or delete log chunks for *any* of its account's apps — including apps that run on the account's PLATFORM boxes and never touch the fleet at all — and there is no server-side quota on what it writes beyond the 32 MiB per-PUT cap; a door-side chunk shape/size guard is a filed follow-up, not part of v1.<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). 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. | |
| + |
| +### What this deletes (when a box flips) |
| + |
| +`CARLOS_DEPLOYMENT_BUCKET` and the platform-bucket half of |
| +`/etc/carlos/credentials` on fleet boxes; the cross-account bucket-policy |
| +requirement (Amadan); the "trusted customers only" caveat on fleets. The |
| +`region-remote-host` module's `deployment_bucket_arn` input becomes |
| +optional (omitted = fleet-API consumer; the grant is not emitted). |
| + |
| +## Security invariants (test-pinned) |
| + |
| +- The door answers ONLY on the reserved hostname; every other host 404s it. |
| +- Auth failures, unknown keys, and out-of-scope keys are byte-identical |
| + 404s after a single constant-time verify; no response distinguishes |
| + "wrong token" / "no such key" / "someone else's key". |
| +- Static tunnel names (no `/`) never authenticate; a detached box's token |
| + fails from the moment `SetDynamicAuth` swaps the table (same bound as |
| + tunnel revocation). |
| +- Key canonicalization refuses `..`, empty segments, leading `/`, and |
| + keys >1024 bytes BEFORE the ACL runs; the ACL is prefix-string exact. |
| +- The proxy never logs tokens, and never forwards client-supplied AWS |
| + headers; it holds the deployment store handle the edge already has. |
| + |
| +## Testing |
| + |
| +Unit: ACL matrix table (every §inventory key class × read/write/delete × |
| +own/other account → allow/404); VerifyDynamic refutes static names, wrong |
| +tokens, detached boxes; canonicalization rejects. Integration: fleetapi |
| +client against a real edge handler over `httptest` backed by a DirStore — |
| +Get/Put CAS round-trip incl. 412→ErrCASConflict, List/ListDirs prefix |
| +strip, artifact streaming; then the FULL existing fleet e2e (two fake |
| +boxes) re-run with the boxes' deploy store swapped to the API client — |
| +adoption, serial poll, reconcile, heartbeat, binding takeover all through |
| +the door. Fallback: bucket-set env still builds S3 stores (platform boxes |
| +regression-pinned). |
| + |
| +## Rollout |
| + |
| +1. This branch: edge door + client + env switch + F6 + docs (BYO page env |
| + contract gains the no-bucket form; region-pi.md notes the flip). |
| +2. Platform binary roll (edge gains the door; no behavior change for any |
| + existing box). |
| +3. Pi fleet flips first (drop bucket env, keep hibernate creds), then |
| + Amadan joins API-native (no cross-account policy ever), then Eleven per |
| + its runbook. |
| +4. `region-remote-host` module: `deployment_bucket_arn` optional, README |
| + reframed (customer artifact = hibernate-bucket-only IAM). |
| + |
| +## Out of scope |
| + |
| +Presigned redirects (F8); per-app (finer than account) ACL; device tokens |
| +for non-fleet boxes; moving platform boxes off S3; hibernate-bucket |
| +abstraction (the customer owns that bucket by design); revoking the pi |
| +fleet's existing IAM user (operator step after its flip). |
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md| index d0f48c86f..7a03a9f54 100644 |
| --- a/infra/dev/region-pi.md |
| +++ b/infra/dev/region-pi.md |
| @@ -41,6 +41,18 @@ Follow infra/remote-host.md with these dev-platform values: |
| shows UNREACHABLE in the fleet view and `carlos status -live`; instances |
| resume when it re-dials. This is the approved demo posture. |
| +**Flipping to the fleet API (issue #73, branch `worktree-fleet-api`, not yet |
| +merged):** once that branch is on `main` and rolled to the dev edge, drop |
| +`CARLOS_DEPLOYMENT_BUCKET` from the standard agent env on this box, and drop |
| +the platform-bucket half of `region-remote-host`'s IAM grant |
| +(`deployment_bucket_arn` unset on the `pi-fleet` tofu root — the module |
| +defaults it to `""` and simply omits that statement). Keep |
| +`AWS_SHARED_CREDENTIALS_FILE`/`CARLOS_HIBERNATE_BUCKET` exactly as above — |
| +the Pi still owns its data bucket directly. `CARLOS_STATUS_PREFIX` becomes |
| +optional at that point too (an unset value defaults to `fleets/<name>/` on |
| +the door branch); keep the explicit line above until this doc is updated |
| +post-merge, or drop it once verified. |
| + |
| Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter; |
| re-running an already-seeded batch is safe but not free, since the limiter |
| counts attempts, not successes, so only re-run a batch that actually |
diff --git a/infra/eleven-fleet.md b/infra/eleven-fleet.md| new file mode 100644 |
| index 000000000..e6dc9a414 |
| --- /dev/null |
| +++ b/infra/eleven-fleet.md |
| @@ -0,0 +1,160 @@ |
| +# Eleven → Carloku fleet migration runbook |
| + |
| +Eleven (elevenmessenger.com, Tito's flagship CARLOS app) moves from Tito's |
| +own self-hosted CARLOS install (`console.platform.tito.io`, on |
| +`carlos-tito-ie`) onto Carloku's console (`console.carloku.com`) as a |
| +**customer fleet**: Eleven's account on Carloku owns fleet `eleven`, the two |
| +production boxes (already in Tito's own AWS account, `titogo`) attach as |
| +fleet boxes, and every Eleven app is placed `fleet/eleven` — opted out of |
| +Carloku's shared fleet by construction. Eleven's data never leaves Eleven's |
| +own bucket. The boxes' network posture does not change: outbound-only dial. |
| + |
| +Executed by Paul, one box at a time, with verification and an abort path at |
| +every step. Scope is the **fleet attach + placement** flip only — Eleven's |
| +account and app records on Carloku are assumed to already exist (see |
| +Preconditions). Not a data migration: there is no data to migrate. |
| + |
| +## Preconditions (must be true before Step 1) |
| + |
| +- [ ] **#73 (fleet API surface) merged and deployed to Carloku's flagship |
| + edge.** *Built on branch `worktree-fleet-api`, pending merge + edge |
| + roll* — the design |
| + (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`), the |
| + store door (`/store/o/<key>` etc.), the `internal/fleetapi` client, and |
| + the env-driven store selection in `openDeployStore` are all built and |
| + tested on that branch, but nothing has merged to `main` or shipped to |
| + Carloku's edge yet. Without a deployed edge a fleet box still needs RW |
| + on Carloku's platform deployment bucket to run — unacceptable for |
| + boxes in Tito's own AWS account. |
| +- [ ] **`region-remote-host`'s `deployment_bucket_arn` is optional.** Also |
| + built on branch `worktree-fleet-api` (`infra/modules/region-remote-host/ |
| + variables.tf` now defaults it to `""`, IAM statement emitted only when |
| + set) but not yet merged. Eleven's tofu root must be able to omit the |
| + grant, which requires that branch's module on `main`. |
| +- [ ] **Eleven's account exists on Carloku**, passkey-reachable, apps already |
| + created under it (`carlos accounts create -name eleven -owner |
| + <address>`, then the normal `apps create` flow) — currently unplaced, |
| + since the fleet doesn't exist yet. |
| +- [ ] **Eleven-owned tofu root**, `region-remote-host`/`paulca/pi-fleet` |
| + pattern, applied in `titogo` against Eleven's existing data bucket — |
| + `deployment_bucket_arn` **omitted** (#73: no platform-bucket creds |
| + needed at all, only the box's own bucket). |
| +- [ ] `carlos` agent binary on both boxes built from a post-#73 `main` |
| + (stale-checkout mismatch is a recorded trap — build fresh). |
| + |
| +## Ceremony |
| + |
| +Signed in to Carloku (`carlos auth login -console https://console.carloku.com`) |
| +as a member of Eleven's account, or `-account eleven` as ops. |
| + |
| +**1. Create the fleet** |
| + |
| + carlos fleets create -bucket <eleven's data bucket> -account eleven eleven |
| + |
| +Mints the record only; the bucket is taken on trust that it exists (it |
| +does). The fleets namespace is flat and global — a 409 "that name is taken" |
| +doesn't necessarily mean *this* attempt took it. |
| + |
| +*Verify:* `carlos fleets ls -account eleven` shows fleet `eleven`, correct |
| +bucket, no boxes. |
| +*Abort:* no `fleets delete` verb exists (`create|add-box|rotate-token| |
| +tag-box|detach-box|ls` is the full set). A fleet with the wrong bucket has |
| +no clean undo short of an operator-side registry fix — get the bucket name |
| +right first. |
| + |
| +**2. Add box 1** |
| + |
| + carlos fleets add-box -account eleven eleven <box-1-label> |
| + |
| +Token prints **exactly once** — capture it now (`rotate-token` mints a new |
| +one; the plaintext is never re-shown). |
| + |
| +*Verify:* `carlos fleets ls -account eleven` shows the box, status `active`. |
| +*Abort:* if added in error and never dialed, `carlos fleets detach-box |
| +-account eleven eleven <box-1-label>` disables the row (no delete verb, but |
| +detach suffices). |
| + |
| +**3. Flip box 1's env** |
| + |
| +Stage a new `/etc/carlos/host.env` (keep the old file as |
| +`host.env.pre-carloku` — the entire rollback for this step). Post-#73 |
| +contract — no `CARLOS_DEPLOYMENT_BUCKET`, no platform-bucket half of |
| +`/etc/carlos/credentials`, hibernate-bucket creds only: |
| + |
| + AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials |
| + CARLOS_HIBERNATE_BUCKET=<eleven's data bucket> |
| + CARLOS_FLEET=eleven |
| + CARLOS_FLEET_BOX=<box-1-label> |
| + CARLOS_REGION=fleet/eleven |
| + CARLOS_STATUS_PREFIX=fleets/eleven/ |
| + CARLOS_RELEASE_PUBKEY=<Carloku's release public key> |
| + CARLOS_TUNNEL_URL=https://<Carloku's reserved tunnel hostname> |
| + CARLOS_TUNNEL_NAME=eleven/<box-1-label> |
| + CARLOS_TUNNEL_TOKEN=<from Step 2 — shown once> |
| + |
| +`CARLOS_RELEASE_PUBKEY` must be **Carloku's** release public key, not |
| +tito's — carrying the old `host.env`'s value forward silently ships the |
| +wrong key, and adoption then fails signature verification (the box will |
| +not adopt any release, with no other symptom). `CARLOS_FLEET_BOX` defaults |
| +from `CARLOS_TUNNEL_NAME`'s label half, but write it explicitly. |
| +`/etc/carlos/credentials` now holds ONLY the |
| +hibernate-bucket key from the Eleven-owned tofu root — any platform-bucket |
| +key pair the file carried for tito's own console is removed here, not just |
| +unused. Restart the agent unit to pick up the new env. |
| + |
| +*Verify:* |
| +- `journalctl -u carlos-edge` shows "tunnel connected". |
| +- Carloku fleet page (`/a/<eleven-sqid>/fleets/eleven`) shows box 1 with a |
| + fresh heartbeat, tunnel connected. |
| +- `carlos fleets ls -account eleven` still shows the box `active` (env flip |
| + changes which console it dials, not registry status). |
| + |
| +**Do not proceed to box 2 until box 1 verifies clean** — the whole point of |
| +flipping one box at a time is that box 2 keeps serving Eleven's traffic |
| +under its prior config while box 1 is proven. |
| + |
| +*Abort:* restore `host.env.pre-carloku`, restart the unit — the box re-dials |
| +whatever it dialed before, nothing on Carloku needs touching. For a |
| +suspected-compromised token rather than a bad env, `carlos fleets |
| +detach-box -account eleven eleven <box-1-label>` is the hard cut — closes |
| +the channel immediately; `rotate-token` only refuses the *next* dial-in. |
| + |
| +**4. Repeat Steps 2-3 for box 2**, only once box 1 is verified clean. |
| + |
| +## App placement |
| + |
| +For each Eleven app (already created under the account, per Preconditions): |
| + |
| + carlos apps place -app <name> -target fleet/eleven -account eleven |
| + |
| +`-target` is required and tri-state (`""` clears placement — never omit it; |
| +an omitted `-target` is a usage error, not a no-op). Placement re-targets |
| +*serving* only, converging within a pass — it does not copy state (v1's |
| +"nothing migrates," restated as D6 in the fleets-console spec). That caveat |
| +is moot here: Eleven's instance state was never anywhere but Eleven's own |
| +bucket, so there's nothing to leave behind. Only the control plane moves. |
| + |
| +*Verify per app:* `curl -sI https://<app-host>` (`--resolve` at Carloku's |
| +edge IP ahead of any DNS cutover — never trust a cached answer) shows |
| +`X-Carlos-Version` matching what Eleven expects. |
| + |
| +*Abort:* `-target ""` un-places, but on Carloku that lands the app on the |
| +**shared** fleet, which has no access to Eleven's bucket — not a rollback to |
| +a working state. If a box misbehaves, fix or detach it and leave placement |
| +at `fleet/eleven`: the dark-fleet 503 (age shown, nothing silently wrong) is |
| +the intended failure mode here, not un-placing. |
| + |
| +## Post-flip |
| + |
| +- [ ] **Revoke the old platform-bucket IAM grant.** Whatever principal gave |
| + Eleven's boxes RW on tito's own platform deployment bucket (from |
| + tito's own `platform-infrastructure` tofu) is no longer needed once |
| + both boxes verify clean on Carloku — revoke it; don't delete the |
| + principal blind without confirming nothing else references it. |
| +- [ ] **DNS for elevenmessenger.com — a Paul decision.** Point the host |
| + directly at Carloku's edge (clean cutover, `X-Carlos-Version` is |
| + immediately authoritative, but TTL/propagation lag is live downtime |
| + risk for a flagship app) versus leaving DNS on tito's own edge proxying |
| + or CNAMEing through to Carloku (slower, an extra hop, but a fallback |
| + that doesn't depend on DNS propagation). Both are compatible with |
| + everything above; neither is assumed here. |
diff --git a/infra/modules/README.md b/infra/modules/README.md| index 5687b9b81..857b58d23 100644 |
| --- a/infra/modules/README.md |
| +++ b/infra/modules/README.md |
| @@ -82,11 +82,15 @@ wants one. |
| What an off-cloud CARLOS host (customer fleets and tito's hq) |
| needs from AWS when there is no EC2 instance profile to lean on: a region |
| -data bucket plus a scoped IAM user + access key, limited to exactly the |
| -deployment bucket and this region's data bucket — the same |
| -wrong-region-fails-at-IAM residency property `region-host` gives cloud |
| -hosts. Consumed by customer fleets (paulca/pi-fleet is the live example) and |
| -the platform's tito hq; the module is reusable as-is for both. |
| +data bucket plus a scoped IAM user + access key, limited to this region's |
| +data bucket always, plus the deployment bucket only when |
| +`deployment_bucket_arn` is set — the same wrong-region-fails-at-IAM |
| +residency property `region-host` gives cloud hosts. Fleet-API consumers |
| +(issue #73) omit `deployment_bucket_arn` entirely: the box reaches the |
| +platform's deployment bucket over the console's edge with its tunnel |
| +token, not a direct IAM grant. Consumed by customer fleets (paulca/pi-fleet |
| +is the live example) and the platform's tito hq; the module is reusable |
| +as-is for both. |
| | Variable | Description | |
| |---|---| |
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md| index e68f31059..aceb8ff16 100644 |
| --- a/infra/modules/region-remote-host/README.md |
| +++ b/infra/modules/region-remote-host/README.md |
| @@ -2,14 +2,31 @@ |
| Provisions what an off-cloud CARLOS host needs when there is no EC2 instance |
| profile to lean on: a region data bucket plus an IAM user carrying a scoped |
| -inline policy and access key, limited to exactly the deployment bucket and |
| -this region's data bucket (wrong-region-fails-at-IAM, the same residency |
| -property `region-host` gives cloud hosts). |
| +inline policy and access key, limited to this region's data bucket and |
| +nothing else (wrong-region-fails-at-IAM, the same residency property |
| +`region-host` gives cloud hosts). |
| Consumed by customer fleets (paulca/pi-fleet is the live example) and |
| the platform's tito hq box. The module is reusable as-is for both — they |
| differ only in account and variable values, not shape. |
| +**`deployment_bucket_arn` is optional (default `""`).** The fleet API |
| +surface (issue #73) means a fleet box no longer needs any IAM grant on the |
| +platform's deployment bucket at all: it reads releases and status, and |
| +writes heartbeats, through the console's edge (`/store/o/...`) using the |
| +same tunnel token it already dials with — the door authenticates and |
| +scopes the request, not a bucket-level AWS credential. That is the shape |
| +every new fleet-API consumer's tofu root should apply with: leave |
| +`deployment_bucket_arn` unset, and the module emits no deployment-bucket |
| +IAM statement at all. |
| + |
| +The variable still exists for **grandfathered direct-S3 boxes** — ones |
| +still running the pre-#73 agent, or deliberately kept off the fleet API — |
| +which need RW straight on the deployment bucket exactly as before; set |
| +`deployment_bucket_arn` explicitly for those and the module emits the |
| +extra scoped statement (`aws_iam_user_policy.host_deployment_bucket`) on |
| +top of the always-present own-data-bucket grant. |
| + |
| The S3 action list in the inline policy is not independently chosen: it is |
| copied verbatim from `infra/modules/region-host/main.tf`'s |
| `aws_iam_role_policy.host_buckets` (the authority for what this principal |
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf| index 90b74d912..ce7d0ecf5 100644 |
| --- a/infra/modules/region-remote-host/main.tf |
| +++ b/infra/modules/region-remote-host/main.tf |
| @@ -3,9 +3,11 @@ |
| # IAM principal, since there is no EC2 instance profile to lean on off-cloud. |
| # |
| # Residency is enforced the same way as region-host: the principal reaches |
| -# the deployment bucket plus ITS OWN region's data bucket and nothing else. |
| -# A wrong-region host fails loudly at the IAM layer, same property as every |
| -# cloud host. |
| +# ITS OWN region's data bucket and nothing else. A wrong-region host fails |
| +# loudly at the IAM layer, same property as every cloud host. The deployment |
| +# bucket is grandfathered-optional (var.deployment_bucket_arn, default "") |
| +# — a fleet-API box needs no platform-bucket grant at all; see the |
| +# aws_iam_user_policy.host_deployment_bucket comment below. |
| # |
| # The data bucket follows the deployment-buckets convention for this class |
| # of bucket (infra/modules/README.md, infra/modules/deployment-buckets/ |
| @@ -58,8 +60,8 @@ resource "aws_iam_access_key" "host" { |
| user = aws_iam_user.host.name |
| } |
| -# Scope: the deployment bucket + THIS region's data bucket and nothing |
| -# else — wrong-region-fails-at-IAM, same property as every cloud host. |
| +# Scope: THIS region's data bucket, always — wrong-region-fails-at-IAM, same |
| +# property as every cloud host. |
| # |
| # The action list is not independently chosen: region-host's |
| # aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is |
| @@ -74,28 +76,46 @@ resource "aws_iam_user_policy" "host" { |
| Version = "2012-10-17" |
| Statement = [ |
| { |
| - Sid = "DeploymentBucketObjects" |
| + Sid = "OwnRegionDataObjects" |
| Effect = "Allow" |
| Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = ["${var.deployment_bucket_arn}/*"] |
| + Resource = ["${aws_s3_bucket.data.arn}/*"] |
| }, |
| { |
| - Sid = "DeploymentBucketList" |
| + Sid = "OwnRegionDataList" |
| Effect = "Allow" |
| Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [var.deployment_bucket_arn] |
| + Resource = [aws_s3_bucket.data.arn] |
| }, |
| + ] |
| + }) |
| +} |
| + |
| +# Deployment-bucket grant: GRANDFATHERED, direct-S3 boxes only. A fleet-API |
| +# box (issue #73) never needs this — it reaches the platform's deployment |
| +# bucket through the console's edge, over the same tunnel token it dials |
| +# with, so var.deployment_bucket_arn is left "" and this whole statement |
| +# (and the IAM policy resource carrying it) is omitted. count, not a dynamic |
| +# block, matches the same optional-statement pattern used by |
| +# aws_iam_role_policy.host_route53 in the region-host module. |
| +resource "aws_iam_user_policy" "host_deployment_bucket" { |
| + count = var.deployment_bucket_arn != "" ? 1 : 0 |
| + name = "carlos-remote-host-${var.region_code}-deployment-bucket" |
| + user = aws_iam_user.host.name |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| { |
| - Sid = "OwnRegionDataObjects" |
| + Sid = "DeploymentBucketObjects" |
| Effect = "Allow" |
| Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = ["${aws_s3_bucket.data.arn}/*"] |
| + Resource = ["${var.deployment_bucket_arn}/*"] |
| }, |
| { |
| - Sid = "OwnRegionDataList" |
| + Sid = "DeploymentBucketList" |
| Effect = "Allow" |
| Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [aws_s3_bucket.data.arn] |
| + Resource = [var.deployment_bucket_arn] |
| }, |
| ] |
| }) |
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf| index 8b1240278..2096ca518 100644 |
| --- a/infra/modules/region-remote-host/variables.tf |
| +++ b/infra/modules/region-remote-host/variables.tf |
| @@ -9,6 +9,7 @@ variable "data_bucket_name" { |
| } |
| variable "deployment_bucket_arn" { |
| - description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to." |
| + description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to. Optional (default \"\"): a fleet-API box (fleet API surface, issue #73) reaches the platform's deployment bucket over the console's edge with its tunnel token, not a direct IAM grant — leave this unset and no deployment-bucket statement is emitted. Only a grandfathered direct-S3 box needs it set." |
| type = string |
| + default = "" |
| } |
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go| index 1cd589a81..800664bb0 100644 |
| --- a/internal/console/accountfleet_test.go |
| +++ b/internal/console/accountfleet_test.go |
| @@ -359,6 +359,13 @@ func TestFleetBYOPageMemberReadable(t *testing.T) { |
| t.Fatalf("BYO page missing %q in:\n%s", want, body) |
| } |
| } |
| + // The primary env contract is bucketless: no platform-bucket var or |
| + // credential belongs there (the box reaches releases/heartbeats through |
| + // the console's edge with its tunnel token). CARLOS_DEPLOYMENT_BUCKET |
| + // still appears, but only inside the demoted legacy-direct-S3 note. |
| + if !strings.Contains(body, "Legacy direct-S3") { |
| + t.Fatalf("BYO page missing the legacy direct-S3 note in:\n%s", body) |
| + } |
| } |
| // TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost |
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html| index 5d88b38c2..ddd0339e1 100644 |
| --- a/internal/console/templates/fleet_byo.html |
| +++ b/internal/console/templates/fleet_byo.html |
| @@ -31,13 +31,30 @@ CARLOS_HIBERNATE_BUCKET=<your-bucket> |
| CARLOS_FLEET=<name> |
| CARLOS_FLEET_BOX=<label> |
| CARLOS_REGION=fleet/<name> |
| -CARLOS_STATUS_PREFIX=fleets/<name>/ |
| +CARLOS_RELEASE_PUBKEY=<this deployment's release public key> |
| CARLOS_TUNNEL_URL=<this deployment's tunnel address> |
| CARLOS_TUNNEL_TOKEN=<from carlos fleets add-box <name> <label> — shown once> |
| CARLOS_TUNNEL_NAME=<name>/<label></code></pre> |
| +<p class="small">Release pointers are Ed25519-signed; without <code>CARLOS_RELEASE_PUBKEY</code> the |
| +box has no key to verify against and refuses to adopt anything.</p> |
| <p class="small"><code>CARLOS_FLEET_BOX</code> is optional and defaults from |
| <code>CARLOS_TUNNEL_NAME</code>'s label half. <code>AWS_SHARED_CREDENTIALS_FILE</code> points at |
| -an access key for the bucket above, written root-owned mode 600 alongside the env file.</p> |
| +an access key for the bucket above — <strong>your own bucket only</strong> — written root-owned |
| +mode 600 alongside the env file.</p> |
| +<p class="small">No <code>CARLOS_DEPLOYMENT_BUCKET</code>, and no AWS credentials for the |
| +platform's own bucket, belong in this file at all: the box reads its app releases and writes its |
| +heartbeats through the console's edge, over HTTPS, using the same tunnel token it already dials |
| +in with — not a bucket-level AWS grant.</p> |
| +<p class="small">Optional override: <code>CARLOS_STATUS_PREFIX=fleets/<name>/</code> — the box |
| +defaults to this on its own; set it explicitly only if you're pointing status/heartbeats |
| +somewhere non-standard.</p> |
| + |
| +<h3>Legacy direct-S3 (grandfathered)</h3> |
| +<p class="small">A box still running a pre-fleet-API agent build needs |
| +<code>CARLOS_DEPLOYMENT_BUCKET</code> set to the platform's own deployment bucket, plus an AWS |
| +credential granting it RW there, in addition to the hibernate-bucket vars above. New boxes should |
| +not be configured this way — it is kept working for boxes that haven't upgraded yet, not as a |
| +choice.</p> |
| <h2>Hardening</h2> |
| <ul> |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index dd84325ee..219e8697e 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -108,6 +108,22 @@ type Edge struct { |
| // to its socket/addr, or goes unserved if it has neither — the same |
| // nil-means-disabled convention as act/cache/staticStore. |
| tunnels *TunnelHub |
| + // door, when non-nil, is the fleet store door (storedoor.go): the five |
| + // release.Store operations served over HTTPS under /store/ on the SAME |
| + // reserved hostname the hub answers on, so a fleet box needs no AWS |
| + // credentials on the deployment bucket. It exists only when BOTH halves |
| + // it needs are configured — the hub (its only credential source) and the |
| + // deployment store (EnableStatic's handle, reused, never a second |
| + // client) — so armStoreDoor is called from both EnableXxx, whichever |
| + // lands second. nil = /store/ falls through to the hub's own 404, the |
| + // same nil-means-disabled convention as act/cache/staticStore. |
| + door *storeDoor |
| + // fleetAccounts is the reload-time fleet→account-sqid snapshot the door's |
| + // ACL is derived from, refreshed on the SAME pass that feeds the hub's |
| + // dynamic auth table (Reload, below) so a box's credentials and its |
| + // tenancy can never come from two different reads of the registry. |
| + // Atomic: written by Reload while requests are being served. |
| + fleetAccounts atomic.Pointer[map[string]string] |
| // cache, when non-nil, answers cacheable GET/HEAD requests without ever |
| // invoking the hibernation activator or the backend — the mitigation |
| // this whole feature exists for. nil disables caching entirely. |
| @@ -163,6 +179,7 @@ func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir |
| e.staticPub = pub |
| e.staticCacheDir = cacheDir |
| e.staticRecheck = staticRecheck |
| + e.armStoreDoor() |
| } |
| // EnableCache wires a response cache into the edge. Called once at startup |
| @@ -178,6 +195,39 @@ func (e *Edge) EnableCache(cache *Cache) { |
| // Reload afterwards, tunnel routes stay skipped from the last table build. |
| func (e *Edge) EnableTunnels(hub *TunnelHub) { |
| e.tunnels = hub |
| + e.armStoreDoor() |
| +} |
| + |
| +// armStoreDoor builds (or clears) the fleet store door from whatever is |
| +// configured now. Called from both EnableStatic and EnableTunnels because the |
| +// door needs one thing from each and the two are wired in either order at |
| +// startup (enableStaticFromEnv runs first today, but a caller composing an |
| +// Edge by hand — every test does — must not have to know that). A deployment |
| +// with no release pubkey configured therefore has no store handle and so no |
| +// door: /store/ then reaches the hub, which 404s it, byte-identically. |
| +func (e *Edge) armStoreDoor() { |
| + if e.tunnels == nil || e.staticStore == nil { |
| + e.door = nil |
| + return |
| + } |
| + e.door = &storeDoor{hub: e.tunnels, store: e.staticStore, fleetAccount: e.fleetAccount} |
| +} |
| + |
| +// fleetAccount resolves an authenticated fleet name to its owning account's |
| +// sqid from the last reload's snapshot. A fleet the snapshot does not carry |
| +// (created since the last reload, or deleted) has no tenancy, so the door |
| +// answers it nothing — the same convergence bound as its channel credentials, |
| +// which come from the same pass. |
| +func (e *Edge) fleetAccount(fleet string) (string, bool) { |
| + m := e.fleetAccounts.Load() |
| + if m == nil { |
| + return "", false |
| + } |
| + sqid, ok := (*m)[fleet] |
| + if !ok || sqid == "" { |
| + return "", false |
| + } |
| + return sqid, true |
| } |
| // SetStaticRecheck tunes how long a static route trusts a resolved channel |
| @@ -384,6 +434,22 @@ func (e *Edge) Reload() error { |
| return err |
| } |
| e.tunnels.SetDynamicAuth(auth) |
| + // The store door's tenancy map, from the SAME pass: a box's |
| + // credentials and the account its reads are scoped to must never come |
| + // from two different reads of the registry. ListFleets goes over a |
| + // fresh connection (like FleetChannelAuth above), so a fleet the |
| + // console created moments ago is visible here rather than stuck |
| + // behind a stale WAL snapshot on the long-lived handle. Same |
| + // all-or-nothing error contract as everything else in Reload. |
| + fleets, err := e.reg.ListFleets() |
| + if err != nil { |
| + return err |
| + } |
| + accounts := make(map[string]string, len(fleets)) |
| + for _, f := range fleets { |
| + accounts[f.Name] = f.Account |
| + } |
| + e.fleetAccounts.Store(&accounts) |
| } |
| e.upstreams.Store(&m) |
| // Reconcile the activator with the new route set: a host un-flagged (or |
| @@ -463,6 +529,16 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // hub ends its handshake in a hijack, so it must be handed the REAL |
| // ResponseWriter, never a recordingResponseWriter wrapping it. |
| if e.tunnels != nil && host == e.tunnels.Host() { |
| + // The fleet store door (storedoor.go) shares this hostname, under |
| + // /store/ — the same host gate as registration, which is the whole |
| + // reason it is reachable at all: it is not a registry route, so no |
| + // other host can ever reach it. /register and everything else stay |
| + // the hub's. Ahead of the hub rather than behind it because the hub |
| + // 404s any path that is not /register. |
| + if e.door != nil && strings.HasPrefix(r.URL.Path, storeDoorPrefix) { |
| + e.door.ServeHTTP(w, r) |
| + return |
| + } |
| e.tunnels.ServeHTTP(w, r) |
| return |
| } |
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go| new file mode 100644 |
| index 000000000..c32c34101 |
| --- /dev/null |
| +++ b/internal/edge/fleetapi_e2e_test.go |
| @@ -0,0 +1,544 @@ |
| +// Task 5 of the fleet-api plan: the spec's central promise proved end to end |
| +// — a fleet box holding NO bucket credentials converges entirely through the |
| +// store door (spec 2026-08-11-fleet-api-surface-design.md, F1-F8; issue #73). |
| +// |
| +// The box's whole view of the platform's deployment bucket is a |
| +// fleetapi.New(...) client pointed at a REAL edge: real TunnelHub auth |
| +// (registry-backed, the same table detach/rotate converge), the real |
| +// fleet→account snapshot Reload builds, the real ACL, a DirStore behind it. |
| +// No box-side code path in this file ever touches that DirStore — every |
| +// platform-side use of it below is the OTHER role (the console/CLI writing |
| +// the bucket, or the assertion reading back what a box wrote through the |
| +// door). |
| +// |
| +// It sits beside fleet_e2e_test.go rather than inside it because the two |
| +// prove different things about the same fleet and want different worlds: |
| +// TestTwoBoxFleetEndToEnd drives the ROUTING failure sequence (channels, |
| +// leases, takeover, dark fleet) and its edge deliberately has no static |
| +// store — no door at all. This one drives the STORE loop (adopt, serial, |
| +// provision, heartbeat, bindings, logs) and needs EnableStatic wired. What |
| +// they do share is shared: e2eHashToken, startFleetBox, namedFleetHandler, |
| +// waitChannel, e2eGet and waitBody all come from that file, so the edge/hub |
| +// arrangement has exactly one source of truth per concern. |
| +// |
| +// Hermetic and fast: loopback httptest edge, real h2 reverse channels via |
| +// the production tunnelclient.Loop, real ed25519 release signing. |
| +package edge_test |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/fleetapi" |
| + "github.com/carlosframework/platform/internal/host" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +const ( |
| + fapiFleet = "orchard" |
| + fapiBoxA = "box-a" |
| + fapiBoxB = "box-b" |
| + fapiTokenA = "orchard-token-a" |
| + fapiTokenB = "orchard-token-b" |
| + |
| + fapiApp = "shop" |
| + fapiDomain = "orchard.example" |
| + fapiHost = "acme." + fapiDomain |
| + fapiRegion = "ie" |
| + fapiChannel = "canary/e2e" // canary/* has no bake window: the pass applies at once |
| + fapiVersion = "v1" |
| + |
| + // The two account identities. Both are real sqids (domains.Sqid of an id), |
| + // never literals: provision.knownAccounts refuses any account manifest |
| + // whose path sqid, body sqid and Sqid(body.ID) do not all agree, so a |
| + // hardcoded string would quietly stop being an account the moment the |
| + // frozen encoding changed — and this test's whole provisioning leg would |
| + // then pass for the wrong reason (nothing known, nothing minted). |
| + fapiAcctID int64 = 7 |
| + fapiOtherID int64 = 8 |
| + fapiOtherApp = "intruder" |
| +) |
| + |
| +// fapiPlatform is the artifact key a box of THIS machine's shape adopts — |
| +// release's own adoptPlatform, which is unexported. |
| +var fapiPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| + |
| +// fleetDoorWorld is one platform + one fleet: the edge and its bucket on one |
| +// side, two fleet boxes whose only bucket access is the door on the other. |
| +type fleetDoorWorld struct { |
| + reg *registry.Registry // the PLATFORM's registry: fleet rows, box credentials, fleet-placed routes |
| + store *release.DirStore // the platform's deployment bucket — edge-side and console-side handle |
| + srvURL string |
| + hub *edge.TunnelHub |
| + priv ed25519.PrivateKey |
| + pub ed25519.PublicKey |
| + sqid string // the fleet's owning account |
| + other string // a second tenant on the same bucket, which the fleet must never see |
| + boxA release.Store |
| + boxB release.Store |
| +} |
| + |
| +// newFleetDoorWorld stands the world up and returns it with both boxes' |
| +// channels connected — the same credential that dials the tunnel is the one |
| +// their store clients authenticate with, which is F1/F2 stated as a fixture. |
| +func newFleetDoorWorld(t *testing.T) *fleetDoorWorld { |
| + t.Helper() |
| + w := &fleetDoorWorld{ |
| + sqid: domains.Sqid(fapiAcctID), |
| + other: domains.Sqid(fapiOtherID), |
| + } |
| + |
| + // Not closed at cleanup, for the reason fleet_e2e_test.go gives: Edge.Watch |
| + // may still be mid-poll when the test returns. |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + w.reg = reg |
| + if err := reg.UpsertFleet(registry.Fleet{Name: fapiFleet, Account: w.sqid, Bucket: "orchard-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + for label, tok := range map[string]string{fapiBoxA: fapiTokenA, fapiBoxB: fapiTokenB} { |
| + if err := reg.AddFleetBox(registry.FleetBox{ |
| + Fleet: fapiFleet, Label: label, TokenHash: e2eHashToken(tok), Status: "active", |
| + }); err != nil { |
| + t.Fatalf("AddFleetBox(%s): %v", label, err) |
| + } |
| + } |
| + |
| + w.store = release.NewDirStore(t.TempDir()) |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + if w.priv, err = release.ParseSigningKey(seedB64, "test"); err != nil { |
| + t.Fatalf("ParseSigningKey: %v", err) |
| + } |
| + if w.pub, err = release.ParsePublicKey(pubB64, "test"); err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("edge.New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + w.srvURL = srv.URL |
| + u, err := url.Parse(srv.URL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // EnableStatic is what hands the door its store handle — the edge's OWN, |
| + // never a second client, and the door is only reachable on the hub's |
| + // reserved hostname (here the listener's own, as in production |
| + // CARLOS_TUNNEL_HOST). |
| + w.hub = edge.NewTunnelHub(u.Hostname(), nil) |
| + e.EnableStatic(w.store, w.pub, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableTunnels(w.hub) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + stopWatch := make(chan struct{}) |
| + t.Cleanup(func() { close(stopWatch) }) |
| + go e.Watch(stopWatch) |
| + |
| + stopA := startFleetBox(t, srv.URL, fapiFleet+"/"+fapiBoxA, fapiTokenA, namedFleetHandler(fapiBoxA)) |
| + t.Cleanup(stopA) |
| + stopB := startFleetBox(t, srv.URL, fapiFleet+"/"+fapiBoxB, fapiTokenB, namedFleetHandler(fapiBoxB)) |
| + t.Cleanup(stopB) |
| + waitChannel(t, w.hub, fapiFleet+"/"+fapiBoxA, "connected", e2eDialBound) |
| + waitChannel(t, w.hub, fapiFleet+"/"+fapiBoxB, "connected", e2eDialBound) |
| + |
| + // The boxes' deployment stores. srv.URL is the door's base exactly as |
| + // CARLOS_TUNNEL_URL is in production, and the name is the box's own |
| + // channel identity — no AWS config, no bucket name, no credentials. |
| + w.boxA = fapiStore(t, srv.URL, fapiBoxA, fapiTokenA) |
| + w.boxB = fapiStore(t, srv.URL, fapiBoxB, fapiTokenB) |
| + return w |
| +} |
| + |
| +func fapiStore(t *testing.T, base, label, token string) release.Store { |
| + t.Helper() |
| + s, err := fleetapi.New(base, fapiFleet+"/"+label, token) |
| + if err != nil { |
| + t.Fatalf("fleetapi.New(%s): %v", label, err) |
| + } |
| + return s |
| +} |
| + |
| +// put is a platform-side (console/CLI-shaped) write straight to the bucket: |
| +// create-only, the same shape every real writer of these keys uses. |
| +func (w *fleetDoorWorld) put(t *testing.T, key, body string) { |
| + t.Helper() |
| + if _, err := w.store.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatalf("seed %s: %v", key, err) |
| + } |
| +} |
| + |
| +// accountManifest is domains' account record at the key provision reads — |
| +// accounts/<sqid>/account.json, one level deep (never accounts/<sqid>.json). |
| +func fapiAccountManifest(id int64, sqid, name string) string { |
| + b, _ := json.Marshal(map[string]any{ |
| + "id": id, "sqid": sqid, "name": name, "created_at": "2026-08-11T09:00:00Z", |
| + }) |
| + return string(b) |
| +} |
| + |
| +// TestFleetBoxConvergesWithoutBucketCredentials is Task 5's acceptance |
| +// evidence. The legs run in sequence against ONE world, each depending on |
| +// what the last left behind (a record becomes a route, the route adopts a |
| +// release, the box heartbeats about it, its binding lands in routing) — a |
| +// failure means the loop stopped there and nothing after it is meaningful. |
| +func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| + ctx := context.Background() |
| + w := newFleetDoorWorld(t) |
| + |
| + // A second tenant on the same bucket, planted BEFORE anything else so |
| + // every listing below runs against a store that really does hold another |
| + // account's keys — the negative leg (f) is only meaningful if the data it |
| + // says is invisible is genuinely there. |
| + w.put(t, "accounts/"+w.other+"/account.json", fapiAccountManifest(fapiOtherID, w.other, "Intruder")) |
| + otherChannelKey := "apps/" + w.other + "/" + fapiOtherApp + "/channels/stable.json" |
| + w.put(t, otherChannelKey, `{"app":"intruder","version":"v9"}`) |
| + otherLogKey := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9) |
| + w.put(t, otherLogKey, "not yours") |
| + w.put(t, "fleets/other/status/boxes/box-a.json", `{"label":"box-a"}`) |
| + |
| + // --- (a) the 2s convergence poll: control/serial, read-only -------------- |
| + // The one shared platform key a box polls every 2 seconds. It must read, |
| + // and it must never be writable — a box that could bump the serial could |
| + // make every other box in the deployment run a full pass set on demand. |
| + w.put(t, release.SerialKey, "41") |
| + serial, _, err := w.boxA.Get(ctx, release.SerialKey) |
| + if err != nil || string(serial) != "41" { |
| + t.Fatalf("(a) serial through the door = %q, %v; want \"41\"", serial, err) |
| + } |
| + _, etag, err := w.store.Get(ctx, release.SerialKey) |
| + if err != nil { |
| + t.Fatalf("(a) read serial etag: %v", err) |
| + } |
| + if _, err := w.store.Put(ctx, release.SerialKey, []byte("42"), etag); err != nil { |
| + t.Fatalf("(a) console bump: %v", err) |
| + } |
| + if serial, _, err = w.boxA.Get(ctx, release.SerialKey); err != nil || string(serial) != "42" { |
| + t.Fatalf("(a) serial after the console bump = %q, %v; want \"42\" — the poll would never see a deploy", serial, err) |
| + } |
| + if _, err := w.boxA.Put(ctx, release.SerialKey, []byte("999"), ""); err == nil { |
| + t.Fatal("(a) a fleet box bumped control/serial through the door") |
| + } |
| + t.Log("(a) control/serial reads through the door and refuses the box's write") |
| + |
| + // --- (b) provisioning: an instance record becomes a local route ---------- |
| + // The account-scoped LISTING path end to end: knownAccounts lists |
| + // accounts/ (a shared prefix, filtered per tenant), the record scan lists |
| + // apps/ (scope-mapped to this account's subtree), and the pass mints. |
| + w.put(t, "accounts/"+w.sqid+"/account.json", fapiAccountManifest(fapiAcctID, w.sqid, "Orchard")) |
| + if err := provision.PutManifest(ctx, w.store, w.sqid, fapiApp, provision.Manifest{ |
| + Schema: 1, App: fapiApp, Domain: fapiDomain, Regions: []string{fapiRegion}, Seed: []string{"serve"}, |
| + }); err != nil { |
| + t.Fatalf("(b) PutManifest: %v", err) |
| + } |
| + if err := provision.CreateRecord(ctx, w.store, provision.Record{ |
| + Schema: 1, Host: fapiHost, Slug: "acme", App: fapiApp, Account: w.sqid, |
| + Region: fapiRegion, Channel: fapiChannel, CreatedAt: time.Now(), |
| + }); err != nil { |
| + t.Fatalf("(b) CreateRecord: %v", err) |
| + } |
| + // The other tenant's record, in THIS box's region and pool: the only thing |
| + // keeping it off this box is the door's account scope. |
| + if err := provision.CreateRecord(ctx, w.store, provision.Record{ |
| + Schema: 1, Host: "evil." + fapiDomain, Slug: "evil", App: fapiOtherApp, Account: w.other, |
| + Region: fapiRegion, Channel: "stable", CreatedAt: time.Now(), |
| + }); err != nil { |
| + t.Fatalf("(b) CreateRecord (other tenant): %v", err) |
| + } |
| + |
| + // The box's OWN registry — a fleet box has one, separate from the |
| + // platform's, and this pass is what puts routes in it. |
| + boxReg, err := registry.Open(filepath.Join(t.TempDir(), "box-registry.db")) |
| + if err != nil { |
| + t.Fatalf("(b) open box registry: %v", err) |
| + } |
| + dataDir := t.TempDir() |
| + if err := provision.Reconcile(ctx, w.boxA, boxReg, provision.ReconcileConfig{ |
| + Region: fapiRegion, DataDir: dataDir, |
| + }); err != nil { |
| + t.Fatalf("(b) provision.Reconcile through the door: %v", err) |
| + } |
| + minted, err := boxReg.Get(fapiHost) |
| + if err != nil || minted == nil { |
| + t.Fatalf("(b) route %s = %+v, %v — the record never became a route", fapiHost, minted, err) |
| + } |
| + if minted.App != fapiApp || minted.Account != w.sqid || minted.Channel != fapiChannel || |
| + !minted.Provisioned || minted.Seed != `["serve"]` { |
| + t.Fatalf("(b) minted route = %+v, want the record's app/account/channel, Provisioned and the manifest seed", minted) |
| + } |
| + if rt, err := boxReg.Get("evil." + fapiDomain); err != nil || rt != nil { |
| + t.Fatalf("(b) the other tenant's record minted a route on this box: %+v, %v", rt, err) |
| + } |
| + t.Log("(b) provision.Reconcile minted the fleet's own instance route and could not see the other tenant's") |
| + |
| + // --- (c) release adoption: pointer, artifact, staging -------------------- |
| + // The whole adoption pass over the door: the signed channel pointer is |
| + // fetched and verified, the manifest read, the artifact BODY streamed |
| + // through the edge (F8 — no presigned redirect), and the version staged |
| + // and stamped on the route. |
| + artifact := make([]byte, 64<<10) |
| + for i := range artifact { |
| + artifact[i] = byte(i * 7) |
| + } |
| + if err := release.Ship(ctx, w.store, w.sqid, release.Manifest{ |
| + App: fapiApp, Version: fapiVersion, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{fapiPlatform: {}}, |
| + }, map[string][]byte{fapiPlatform: artifact}); err != nil { |
| + t.Fatalf("(c) Ship: %v", err) |
| + } |
| + if err := release.Promote(ctx, w.store, w.priv, w.sqid, fapiApp, fapiChannel, fapiVersion); err != nil { |
| + t.Fatalf("(c) Promote: %v", err) |
| + } |
| + optDir := t.TempDir() |
| + var restarted []string |
| + restart := func(h string) error { restarted = append(restarted, h); return nil } |
| + if err := release.Pass(ctx, boxReg, w.boxA, w.pub, optDir, restart); err != nil { |
| + t.Fatalf("(c) release.Pass through the door: %v", err) |
| + } |
| + if v, err := release.LiveVersion(optDir, fapiHost); v != fapiVersion { |
| + t.Fatalf("(c) live version = %q (%v), want %q — adoption did not converge through the door", v, err, fapiVersion) |
| + } |
| + staged, err := os.ReadFile(release.VersionPath(optDir, w.sqid, fapiVersion, fapiApp)) |
| + if err != nil || !slices.Equal(staged, artifact) { |
| + t.Fatalf("(c) staged artifact = %d bytes (%v), want the shipped %d — a truncated body would pass a shorter check", len(staged), err, len(artifact)) |
| + } |
| + adopted, err := boxReg.Get(fapiHost) |
| + if err != nil || adopted == nil || adopted.Version != fapiVersion { |
| + t.Fatalf("(c) route version = %+v, %v; want %q stamped", adopted, err, fapiVersion) |
| + } |
| + t.Logf("(c) adoption converged: pointer verified, %d artifact bytes streamed and staged (restarted %v)", len(artifact), restarted) |
| + |
| + // --- (d) the heartbeat, under the Task-4 default prefix ------------------ |
| + // A door-backed box gets CARLOS_STATUS_PREFIX defaulted to |
| + // fleets/<fleet>/ (spec F5) precisely because the shared status/ root is |
| + // outside the door's write ACL. The two lines statusLoop runs, with that |
| + // rebase and the agent's label override: |
| + prefixed := release.NewPrefixStore(w.boxA, "fleets/"+fapiFleet+"/") |
| + routes, err := boxReg.List() |
| + if err != nil { |
| + t.Fatalf("(d) list box routes: %v", err) |
| + } |
| + box := status.CollectBox(routes, status.Probes{MachineID: "orchard-a", Region: "IE"}, time.Now()) |
| + box.Label = fapiBoxA // statusTick's labelOverride: the fleet label, not a machine-id hash |
| + if err := status.WriteBox(ctx, prefixed, box); err != nil { |
| + t.Fatalf("(d) status.WriteBox through the door: %v", err) |
| + } |
| + heartbeatKey := "fleets/" + fapiFleet + "/status/boxes/" + fapiBoxA + ".json" |
| + raw, _, err := w.store.Get(ctx, heartbeatKey) |
| + if err != nil { |
| + t.Fatalf("(d) heartbeat did not land at %s: %v", heartbeatKey, err) |
| + } |
| + var landed status.Box |
| + if err := json.Unmarshal(raw, &landed); err != nil { |
| + t.Fatalf("(d) heartbeat body: %v", err) |
| + } |
| + if landed.Label != fapiBoxA { |
| + t.Fatalf("(d) heartbeat label = %q, want %q", landed.Label, fapiBoxA) |
| + } |
| + // The SECOND write is the one that exercises CAS through the door: |
| + // WriteBox reads its own object's etag first (the read half of the fleet |
| + // corner, which only exists so this replace can happen) and Puts with |
| + // If-Match. A door that dropped etags would fail here, not above. |
| + if err := status.WriteBox(ctx, prefixed, box); err != nil { |
| + t.Fatalf("(d) heartbeat replace (read-etag-then-Put) through the door: %v", err) |
| + } |
| + // And a box may not heartbeat into a fleet that is not its own. |
| + if _, err := w.boxA.Put(ctx, "fleets/other/status/boxes/box-a.json", []byte("{}"), ""); err == nil { |
| + t.Fatal("(d) box-a wrote into another fleet's corner") |
| + } |
| + t.Logf("(d) heartbeat landed at %s and replaced itself under CAS", heartbeatKey) |
| + |
| + // --- (e) a fleet binding takeover, box → bucket → routing ---------------- |
| + // The platform's route for the app, placed on the fleet and currently |
| + // bound to box-b. |
| + if err := w.reg.Upsert(registry.Route{ |
| + Host: fapiHost, Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget(fapiFleet), FleetBox: fapiBoxB, |
| + }); err != nil { |
| + t.Fatalf("(e) upsert fleet-placed route: %v", err) |
| + } |
| + // box-b's agent-startup resident pass (takeover=false), through ITS door |
| + // client — an observation, with no lease behind it. |
| + if err := host.WriteFleetBinding(ctx, w.boxB, host.FleetBinding{ |
| + Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxB, BoundAt: time.Now().UnixMilli(), |
| + }, false); err != nil { |
| + t.Fatalf("(e) box-b resident binding: %v", err) |
| + } |
| + // box-a wakes the app and wins the lease (modeled in fleet_e2e_test.go; |
| + // what matters here is the WRITE): a takeover claims the binding. |
| + if err := host.WriteFleetBinding(ctx, w.boxA, host.FleetBinding{ |
| + Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxA, BoundAt: time.Now().UnixMilli(), |
| + }, true); err != nil { |
| + t.Fatalf("(e) box-a takeover binding through the door: %v", err) |
| + } |
| + // The platform reads it back from its own bucket handle — the object IS |
| + // the message — and restamps routing. |
| + if err := host.ReconcileFleetBindings(ctx, w.store, w.reg); err != nil { |
| + t.Fatalf("(e) ReconcileFleetBindings: %v", err) |
| + } |
| + rt, err := w.reg.Get(fapiHost) |
| + if err != nil || rt == nil || rt.FleetBox != fapiBoxA { |
| + t.Fatalf("(e) Route.FleetBox = %+v, %v; want %q — the takeover never made it back into routing", rt, err, fapiBoxA) |
| + } |
| + // And the guard survives the round trip: box-b's next resident pass reads |
| + // the existing binding through the door and declines to steal it back. |
| + if err := host.WriteFleetBinding(ctx, w.boxB, host.FleetBinding{ |
| + Host: fapiHost, Fleet: fapiFleet, Box: fapiBoxB, BoundAt: time.Now().UnixMilli(), |
| + }, false); err != nil { |
| + t.Fatalf("(e) box-b second resident pass: %v", err) |
| + } |
| + if err := host.ReconcileFleetBindings(ctx, w.store, w.reg); err != nil { |
| + t.Fatalf("(e) ReconcileFleetBindings (after box-b's pass): %v", err) |
| + } |
| + if rt, err = w.reg.Get(fapiHost); err != nil || rt == nil || rt.FleetBox != fapiBoxA { |
| + t.Fatalf("(e) Route.FleetBox = %+v, %v; want %q — a non-takeover write stole the binding through the door", rt, err, fapiBoxA) |
| + } |
| + // End to end, from outside: the edge's 2s poll picks the restamp up and |
| + // the app is served by box-a's own channel. Record → route → release → |
| + // binding → traffic, with the box holding no bucket credential anywhere |
| + // in that chain. |
| + waitBody(t, w.srvURL, fapiHost, fapiBoxA, e2eConvergeBound) |
| + t.Log("(e) the takeover written through the door reached routing, and the bound box serves") |
| + |
| + // --- (f) the other tenant is invisible ---------------------------------- |
| + // Listings first: the easiest place to leak a neighbour, and the one |
| + // provision.Reconcile above depends on being right. |
| + appKeys, err := w.boxA.List(ctx, "apps/") |
| + if err != nil { |
| + t.Fatalf("(f) list apps/: %v", err) |
| + } |
| + for _, k := range appKeys { |
| + if !strings.HasPrefix(k, "apps/"+w.sqid+"/") { |
| + t.Fatalf("(f) list apps/ returned %q — outside this fleet's account", k) |
| + } |
| + } |
| + if len(appKeys) == 0 { |
| + t.Fatal("(f) list apps/ came back empty — the scope map is refusing the fleet's OWN estate") |
| + } |
| + acctKeys, err := w.boxA.List(ctx, "accounts/") |
| + if err != nil { |
| + t.Fatalf("(f) list accounts/: %v", err) |
| + } |
| + if !slices.Equal(acctKeys, []string{"accounts/" + w.sqid + "/account.json"}) { |
| + t.Fatalf("(f) list accounts/ = %v, want only this fleet's own account record", acctKeys) |
| + } |
| + acctDirs, err := w.boxA.ListDirs(ctx, "accounts/") |
| + if err != nil { |
| + t.Fatalf("(f) listdirs accounts/: %v", err) |
| + } |
| + if !slices.Equal(acctDirs, []string{"accounts/" + w.sqid + "/"}) { |
| + t.Fatalf("(f) listdirs accounts/ = %v, want only this fleet's own directory (one child per account exists)", acctDirs) |
| + } |
| + fleetDirs, err := w.boxA.ListDirs(ctx, "fleets/") |
| + if err != nil { |
| + t.Fatalf("(f) listdirs fleets/: %v", err) |
| + } |
| + for _, d := range fleetDirs { |
| + if !strings.HasPrefix(d, "fleets/"+fapiFleet+"/") { |
| + t.Fatalf("(f) listdirs fleets/ returned %q — another fleet's corner", d) |
| + } |
| + } |
| + // Then direct gets: every one of these keys exists in the bucket, and |
| + // every one of them is os.ErrNotExist to this box. |
| + for _, key := range []string{ |
| + "accounts/" + w.other + "/account.json", |
| + otherChannelKey, |
| + otherLogKey, |
| + "apps/" + w.other + "/" + fapiOtherApp + "/instances/evil." + fapiDomain + ".json", |
| + "fleets/other/status/boxes/box-a.json", |
| + } { |
| + if _, _, err := w.store.Get(ctx, key); err != nil { |
| + t.Fatalf("(f) fixture %s is not actually in the bucket: %v", key, err) |
| + } |
| + if _, _, err := w.boxA.Get(ctx, key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("(f) GET %s through the door = %v, want os.ErrNotExist (byte-identical 404)", key, err) |
| + } |
| + } |
| + // Writes and deletes into the neighbour are refused too — including the |
| + // delete of a log chunk, which is the one write verb the account-wide logs |
| + // grant makes tempting. |
| + if _, err := w.boxA.Put(ctx, otherChannelKey, []byte(`{"version":"evil"}`), ""); err == nil { |
| + t.Fatal("(f) box-a promoted a release in another account") |
| + } |
| + if _, err := w.boxA.Put(ctx, otherLogKey, []byte("forged"), ""); err == nil { |
| + t.Fatal("(f) box-a forged a log chunk in another account") |
| + } |
| + if err := w.boxA.Delete(ctx, otherLogKey); err == nil { |
| + t.Fatal("(f) box-a deleted another account's log chunk") |
| + } |
| + if _, _, err := w.store.Get(ctx, otherChannelKey); err != nil { |
| + t.Fatalf("(f) the refused writes changed the neighbour's object anyway: %v", err) |
| + } |
| + t.Log("(f) the second tenant is invisible to this fleet: listings, gets, writes and deletes all refuse") |
| + |
| + // --- (g) log chunks: ship and prune through the door --------------------- |
| + // The Task-4 grant, exercised as logshipLoop uses it: chunks are written |
| + // UNPREFIXED under the app's own subtree (applog.Prefix — where `carlos |
| + // logs` reads them), listed back, and pruned by the same box. |
| + hour := applog.HourOf(time.Now()) |
| + chunk, err := applog.EncodeChunk([]applog.Record{{ |
| + TS: time.Now(), Box: fapiBoxA, Host: fapiHost, Stream: "stdout", Msg: "listening", Seq: 1, |
| + }}) |
| + if err != nil { |
| + t.Fatalf("(g) EncodeChunk: %v", err) |
| + } |
| + chunkKey := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk)) |
| + if _, err := w.boxA.Put(ctx, chunkKey, chunk, ""); err != nil { |
| + t.Fatalf("(g) ship a log chunk through the door: %v", err) |
| + } |
| + landedChunk, _, err := w.store.Get(ctx, chunkKey) |
| + if err != nil { |
| + t.Fatalf("(g) chunk did not land at %s: %v", chunkKey, err) |
| + } |
| + recs, err := applog.DecodeChunk(landedChunk) |
| + if err != nil || len(recs) != 1 || recs[0].Msg != "listening" { |
| + t.Fatalf("(g) chunk round trip = %+v, %v", recs, err) |
| + } |
| + listed, err := w.boxA.List(ctx, applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA)) |
| + if err != nil || !slices.Contains(listed, chunkKey) { |
| + t.Fatalf("(g) list own log subtree = %v, %v; want %s", listed, err, chunkKey) |
| + } |
| + if err := w.boxA.Delete(ctx, chunkKey); err != nil { |
| + t.Fatalf("(g) prune own chunk through the door: %v", err) |
| + } |
| + if _, _, err := w.store.Get(ctx, chunkKey); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("(g) chunk survived its prune: %v", err) |
| + } |
| + // The grant stays segment-exact: the same account, the same app, one |
| + // segment over, is still refused. |
| + for _, key := range []string{ |
| + release.ChannelKey(w.sqid, fapiApp, fapiChannel), |
| + provision.RecordKey(w.sqid, fapiApp, fapiHost), |
| + "apps/" + w.sqid + "/" + fapiApp + "/config/production.json", |
| + } { |
| + if _, err := w.boxA.Put(ctx, key, []byte("{}"), ""); err == nil { |
| + t.Fatalf("(g) the logs grant let the box write %s", key) |
| + } |
| + } |
| + t.Log("(g) log chunks ship, list and prune through the door; neighbouring keys stay refused") |
| +} |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| new file mode 100644 |
| index 000000000..f96443f79 |
| --- /dev/null |
| +++ b/internal/edge/storedoor.go |
| @@ -0,0 +1,505 @@ |
| +// The store door: release.Store spoken over HTTPS on the edge's reserved |
| +// tunnel hostname, so a fleet box needs no AWS credentials on the platform's |
| +// deployment bucket at all (spec 2026-08-11-fleet-api-surface-design.md, |
| +// F1-F4; issue #73). |
| +// |
| +// One mechanism, not N endpoints: the five Store operations, authenticated |
| +// per request by the credential the box already holds (its fleet tunnel |
| +// token, verified through TunnelHub.VerifyDynamic — the same hash table |
| +// detach/rotate already converge in seconds), and ACL'd server-side from the |
| +// fleet's registry row. The box's own loops — adoption, fast-converge, |
| +// config, status, metrics, bindings — run unchanged against the client half |
| +// (Task 3). |
| +// |
| +// Everything a caller is not entitled to is a byte-identical 404: wrong |
| +// token, static (non-fleet) name, another tenant's key, a key that does not |
| +// exist, a key shaped like a traversal. The keyspace must never be an |
| +// oracle, so no response distinguishes them, and no refusal ever costs more |
| +// than one constant-time verify. |
| +package edge |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + // storeDoorPrefix is what edge.go routes to this handler on the reserved |
| + // tunnel hostname; /register (the tunnel handshake) stays with the hub. |
| + storeDoorPrefix = "/store/" |
| + storeObjectPrefix = "/store/o/" |
| + storeListPath = "/store/list" |
| + storeDirsPath = "/store/dirs" |
| + |
| + // maxStoreKeyBytes is the pre-ACL key ceiling (spec: keys >1024 bytes are |
| + // refused before the ACL runs). S3's own limit. |
| + maxStoreKeyBytes = 1024 |
| + // maxStoreBodyBytes bounds one PUT. Everything a box may write lives |
| + // under fleets/<own>/ — heartbeats, metrics, alerts, bindings, shipped |
| + // logs — none of which approach this; the ceiling exists so a |
| + // compromised box cannot make the edge buffer arbitrary memory. |
| + maxStoreBodyBytes = 32 << 20 |
| +) |
| + |
| +// storeDoor serves release.Store over HTTPS for fleet boxes (spec F1-F4). |
| +// |
| +// It holds the deployment-store handle the edge ALREADY has (EnableStatic's, |
| +// shared with the static and status serving classes) — never a second S3 |
| +// client, and never anything the request can influence: no client-supplied |
| +// AWS header is read, and no bucket identity is ever exposed (F8: artifact |
| +// bytes stream through the edge rather than being redirected to a presigned |
| +// URL). |
| +type storeDoor struct { |
| + hub *TunnelHub // VerifyDynamic: the one auth path, called once per request |
| + store release.Store // the deployment bucket, edge's own handle |
| + // fleetAccount maps an authenticated fleet name to its owning account's |
| + // sqid — the whole basis of the read ACL. Backed by the edge's |
| + // reload-time snapshot (Edge.fleetAccount), refreshed on the same pass |
| + // that feeds the hub's FleetChannelAuth table, so a fleet's identity and |
| + // its credentials can never come from two different reads of the |
| + // registry. |
| + fleetAccount func(fleet string) (sqid string, ok bool) |
| +} |
| + |
| +// ServeHTTP is the whole door. The order is load-bearing: |
| +// |
| +// 1. auth — exactly once, before anything else, for every path and method; |
| +// 2. fleet → account (the ACL's identity); |
| +// 3. key canonicalization (traversal, empty segments, oversize); |
| +// 4. the ACL; |
| +// 5. only then the request's own well-formedness (a PUT's precondition) and |
| +// the store. |
| +// |
| +// Steps 1-4 all answer http.NotFound, so a caller cannot learn from a |
| +// response which of them refused it — in particular a 400 for a missing |
| +// precondition is only ever reachable on a key this fleet may actually |
| +// write, never as a probe for whether such a key exists. |
| +// |
| +// The CAS mapping onto release.Store's contract (store.go), exactly: |
| +// |
| +// If-None-Match: * → Put(ifMatch: "") create-only |
| +// If-Match: <etag> → Put(ifMatch: <etag>) replace-only |
| +// neither → 400 (there is no unconditional Put in the contract) |
| +// ErrCASConflict → 412 |
| +// Get not-found → 404 (os.ErrNotExist-wrapped, per the contract) |
| +// Delete → 204, always, for an in-scope key (idempotent) |
| +// |
| +// Etags are opaque strings on this wire, passed through in both directions |
| +// exactly as the backing store produced them (DirStore's bare sha256 hex, |
| +// S3's quoted tag): the door never parses or rewrites one, so the client's |
| +// If-Match always carries the same bytes its Get was handed. |
| +func (d *storeDoor) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + // Auth, once. Same two headers as the tunnel dial (F1), same table (F2): |
| + // only fleet channels ("<fleet>/<label>") can pass — VerifyDynamic |
| + // refuses slash-less static names outright, so a CARLOS_TUNNEL_CLIENTS |
| + // credential can never open this door. The token is never logged, never |
| + // echoed, and never compared in the clear. |
| + name := r.Header.Get("Carlos-Tunnel-Name") |
| + token, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") |
| + if name == "" || !hasBearer { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + fleet, ok := d.hub.VerifyDynamic(name, token) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + sqid, ok := d.fleetAccount(fleet) |
| + if !ok || sqid == "" { |
| + // Authenticated, but the fleet's registry row is not in the edge's |
| + // current snapshot (a fleet deleted between reloads, or a hub whose |
| + // auth table was set by hand). No identity, no ACL, no answer. |
| + http.NotFound(w, r) |
| + return |
| + } |
| + switch { |
| + case strings.HasPrefix(r.URL.Path, storeObjectPrefix): |
| + d.serveObject(w, r, sqid, fleet, strings.TrimPrefix(r.URL.Path, storeObjectPrefix)) |
| + case r.URL.Path == storeListPath: |
| + d.serveListing(w, r, sqid, fleet, false) |
| + case r.URL.Path == storeDirsPath: |
| + d.serveListing(w, r, sqid, fleet, true) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +// serveObject is Get/Put/Delete on one key. rawKey is the path remainder, |
| +// already percent-decoded by net/http — which is exactly what must be |
| +// canonicalized: an encoded "%2e%2e" arrives here as a real ".." segment. |
| +func (d *storeDoor) serveObject(w http.ResponseWriter, r *http.Request, sqid, fleet, rawKey string) { |
| + key, ok := canonicalKey(rawKey) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + ctx := r.Context() |
| + switch r.Method { |
| + case http.MethodGet: |
| + if !aclRead(sqid, fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body, etag, err := d.store.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) // byte-identical to every refusal above |
| + return |
| + } |
| + d.storeFailed(w, r, "get", key, err) |
| + return |
| + } |
| + if etag != "" { |
| + w.Header().Set("ETag", etag) |
| + } |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| + w.Write(body) |
| + case http.MethodPut: |
| + if !aclWrite(sqid, fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + // Precondition parsing is AFTER the ACL on purpose: a 400 here must |
| + // never be reachable for a key the caller may not write, or the |
| + // difference between 400 and 404 would map the keyspace. |
| + ifMatch, ok := putPrecondition(r) |
| + if !ok { |
| + http.Error(w, "want If-Match: <etag> or If-None-Match: *", http.StatusBadRequest) |
| + return |
| + } |
| + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxStoreBodyBytes)) |
| + if err != nil { |
| + var tooLarge *http.MaxBytesError |
| + if errors.As(err, &tooLarge) { |
| + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + http.Error(w, "read body", http.StatusBadRequest) |
| + return |
| + } |
| + etag, err := d.store.Put(ctx, key, body, ifMatch) |
| + if err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // The client maps this back to release.ErrCASConflict, so a |
| + // heartbeat or binding takeover keeps its exact semantics |
| + // through the proxy (F4). |
| + http.Error(w, "precondition failed", http.StatusPreconditionFailed) |
| + return |
| + } |
| + d.storeFailed(w, r, "put", key, err) |
| + return |
| + } |
| + if etag != "" { |
| + w.Header().Set("ETag", etag) |
| + } |
| + w.WriteHeader(http.StatusOK) |
| + case http.MethodDelete: |
| + if !aclWrite(sqid, fleet, key) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err := d.store.Delete(ctx, key); err != nil { |
| + d.storeFailed(w, r, "delete", key, err) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) // idempotent, per the Store contract |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +// serveListing is List (dirs=false) and ListDirs (dirs=true). The requested |
| +// prefix is scope-MAPPED rather than merely checked (aclListScope): a caller |
| +// asking for "apps/" gets its own account's subtree, which is what lets an |
| +// unmodified release.Store caller work through the door. Every returned entry |
| +// is then re-checked per tenant — aclRead for objects, aclDir for child |
| +// prefixes — so the answer is in scope even if a future mapping rule is ever |
| +// wrong, and so the one scope that IS shared (accounts/) can only ever answer |
| +// with this caller's own record. |
| +func (d *storeDoor) serveListing(w http.ResponseWriter, r *http.Request, sqid, fleet string, dirs bool) { |
| + if r.Method != http.MethodGet { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + prefix, ok := canonicalPrefix(r.URL.Query().Get("prefix")) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + scope, ok := aclListScope(sqid, fleet, prefix) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + out := []string{} // never null on the wire: an empty listing is [] |
| + if dirs { |
| + got, err := d.store.ListDirs(r.Context(), scope) |
| + if err != nil { |
| + d.storeFailed(w, r, "listdirs", scope, err) |
| + return |
| + } |
| + for _, dir := range got { |
| + // Judged per TENANT, never "is it under the prefix we listed": |
| + // under the shared accounts/ scope that test is a tautology and |
| + // would enumerate every account on the platform (aclDir). |
| + if aclDir(sqid, fleet, dir) { |
| + out = append(out, dir) |
| + } |
| + } |
| + } else { |
| + got, err := d.store.List(r.Context(), scope) |
| + if err != nil { |
| + d.storeFailed(w, r, "list", scope, err) |
| + return |
| + } |
| + for _, key := range got { |
| + if aclRead(sqid, fleet, key) { |
| + out = append(out, key) |
| + } |
| + } |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| +} |
| + |
| +// storeFailed answers a genuine backing-store failure. It is deliberately NOT |
| +// folded into the 404 shape: a bucket outage that looked like "the object is |
| +// gone" would make a box act on absence (a missing channel pointer, an empty |
| +// listing) instead of retrying. It is no keyspace oracle either — it is only |
| +// reachable once the key has already passed canonicalization and the ACL, so |
| +// the caller learns nothing it was not already entitled to. The log line |
| +// carries the key and the error; never the token, never the auth header. |
| +func (d *storeDoor) storeFailed(w http.ResponseWriter, r *http.Request, op, key string, err error) { |
| + log.Printf("edge: store door: %s %s: %v", op, key, err) |
| + http.Error(w, "store error", http.StatusBadGateway) |
| +} |
| + |
| +// putPrecondition maps the request's conditional headers onto release.Store's |
| +// ifMatch argument. The Store contract has no unconditional Put — "" is |
| +// create-only (If-None-Match: *) and non-empty is replace-only — so a PUT |
| +// carrying neither header is a client bug, refused rather than silently |
| +// turned into a create. Both headers together is equally ill-defined. |
| +// If-None-Match is honoured only as the literal "*": an etag list there would |
| +// mean something this wire does not implement. |
| +func putPrecondition(r *http.Request) (ifMatch string, ok bool) { |
| + im := strings.TrimSpace(r.Header.Get("If-Match")) |
| + inm := strings.TrimSpace(r.Header.Get("If-None-Match")) |
| + switch { |
| + case im != "" && inm != "": |
| + return "", false |
| + case inm == "*": |
| + return "", true |
| + case inm != "": |
| + return "", false |
| + case im != "": |
| + return im, true |
| + default: |
| + return "", false |
| + } |
| +} |
| + |
| +// canonicalKey is the pre-ACL gate: it refuses, rather than cleans, anything |
| +// that could re-point a prefix — "..", "." and empty segments (which covers a |
| +// leading "/", a trailing "/" and any "//"), an empty key, control bytes, and |
| +// anything over maxStoreKeyBytes. Refusing rather than normalizing is the |
| +// point: the ACL below is prefix-string exact, and a key that had to be |
| +// rewritten to be safe is a key whose author meant something else. |
| +// |
| +// It runs BEFORE the ACL, so "apps/<own>/../<other>/x" never reaches a prefix |
| +// comparison that would have approved its first segment. |
| +func canonicalKey(raw string) (string, bool) { |
| + if raw == "" || len(raw) > maxStoreKeyBytes { |
| + return "", false |
| + } |
| + if strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") { |
| + return "", false |
| + } |
| + for i := 0; i < len(raw); i++ { |
| + if raw[i] < 0x20 || raw[i] == 0x7f { |
| + return "", false |
| + } |
| + } |
| + for _, seg := range strings.Split(raw, "/") { |
| + if seg == "" || seg == "." || seg == ".." { |
| + return "", false |
| + } |
| + } |
| + return raw, true |
| +} |
| + |
| +// canonicalPrefix is canonicalKey for a LISTING prefix: same refusals, except |
| +// that a prefix may end in "/" (and usually does — "apps/", "fleets/pi/"). |
| +// The trailing slash is preserved, because aclListScope's mapping rules are |
| +// exact string matches and "apps" must not be allowed to stand in for a |
| +// prefix that would also match "appsomething/". |
| +func canonicalPrefix(raw string) (string, bool) { |
| + if _, ok := canonicalKey(strings.TrimSuffix(raw, "/")); !ok { |
| + return "", false |
| + } |
| + return raw, true |
| +} |
| + |
| +// aclRead is the read half of F3, prefix-string exact against the |
| +// authenticated fleet's own identity: |
| +// |
| +// control/serial the convergence poll |
| +// bootstrap/carlos the sanctioned self-update path (F7) |
| +// accounts/<sqid>/** its owning account's record — the manifest lives at |
| +// accounts/<sqid>/account.json (domains.AccountManifestKey), |
| +// NOT accounts/<sqid>.json: a box that cannot read it |
| +// sees an empty account set, and provision.Reconcile's |
| +// removal phase then retires every provisioned route on |
| +// the box (knownAccounts -> ParseRecordKey) |
| +// apps/<sqid>/** its owning account's whole app estate |
| +// fleets/<fleet>/** its own corner |
| +// |
| +// The last line is not in F3's enumeration and is not a widening of it: the |
| +// fleet's own prefix is the only place it may WRITE, and every write on this |
| +// wire is a CAS (read the etag, then Put with If-Match). A write scope you |
| +// cannot read is a write scope you cannot use — the binding takeover guard |
| +// and the heartbeat both read their own object first. It grants access to |
| +// nothing beyond what aclWrite already grants. |
| +func aclRead(sqid, fleet, key string) bool { |
| + if sqid == "" || fleet == "" { |
| + return false |
| + } |
| + switch key { |
| + case "control/serial", "bootstrap/carlos": |
| + return true |
| + } |
| + return strings.HasPrefix(key, "accounts/"+sqid+"/") || |
| + strings.HasPrefix(key, "apps/"+sqid+"/") || |
| + strings.HasPrefix(key, "fleets/"+fleet+"/") |
| +} |
| + |
| +// aclDir is aclRead for a LISTING's child prefixes (ListDirs), which are |
| +// directories rather than objects and so cannot be judged by it: a returned |
| +// prefix is in scope exactly when it lies inside one of this tenant's own |
| +// subtrees, or IS its own account directory. |
| +// |
| +// It exists because "the prefix we listed under" is not a sufficient filter |
| +// for the one shared scope: accounts/ must be listable (it is how a box |
| +// discovers its own account record) but ListDirs("accounts/") returns |
| +// accounts/<sqid>/ for EVERY tenant on the platform, and a HasPrefix check |
| +// against the scope we just used is a tautology that would hand all of them |
| +// back. |
| +func aclDir(sqid, fleet, dir string) bool { |
| + if sqid == "" || fleet == "" { |
| + return false |
| + } |
| + if dir == "accounts/"+sqid+"/" { |
| + return true |
| + } |
| + return strings.HasPrefix(dir, "apps/"+sqid+"/") || |
| + strings.HasPrefix(dir, "fleets/"+fleet+"/") |
| +} |
| + |
| +// 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 |
| + } |
| + 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 |
| +// actually list, or refuses. Mapping rather than refusing is what keeps |
| +// unmodified release.Store callers working: "apps/" is what platform code |
| +// asks for, and a fleet box asking it gets its own account's subtree back |
| +// (spec F3: "list of accounts/ and apps/ answers only the own-account |
| +// subset"). A bare "apps"/"fleets"/"accounts" (no trailing slash) maps the |
| +// same way; anything narrower must already be inside the caller's own |
| +// subtree, and a partial sqid or fleet name ("apps/ab1", "fleets/pix/") is |
| +// refused rather than widened, since S3 prefix matching would otherwise reach |
| +// a sibling tenant. |
| +// |
| +// accounts/ is the one scope that maps to a SHARED prefix — it has to be |
| +// listable, since that is how a box finds its own account manifest |
| +// (provision.knownAccounts lists accounts/ and parses |
| +// accounts/<sqid>/account.json). The caller may see only its own record |
| +// there, which serveListing enforces by re-checking every returned entry |
| +// (aclRead for objects, aclDir for the child prefixes ListDirs returns — |
| +// there is one per account on the platform). |
| +func aclListScope(sqid, fleet, prefix string) (string, bool) { |
| + if sqid == "" || fleet == "" || prefix == "" { |
| + return "", false |
| + } |
| + ownApps := "apps/" + sqid + "/" |
| + ownFleet := "fleets/" + fleet + "/" |
| + switch { |
| + case prefix == "apps" || prefix == "apps/" || prefix == "apps/"+sqid: |
| + return ownApps, true |
| + case strings.HasPrefix(prefix, ownApps): |
| + return prefix, true |
| + case strings.HasPrefix(prefix, "apps/"): |
| + return "", false |
| + case prefix == "accounts" || prefix == "accounts/": |
| + return "accounts/", true |
| + case prefix == "fleets" || prefix == "fleets/" || prefix == "fleets/"+fleet: |
| + return ownFleet, true |
| + case strings.HasPrefix(prefix, ownFleet): |
| + return prefix, true |
| + default: |
| + return "", false |
| + } |
| +} |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| new file mode 100644 |
| index 000000000..a2eb8011d |
| --- /dev/null |
| +++ b/internal/edge/storedoor_test.go |
| @@ -0,0 +1,701 @@ |
| +package edge |
| + |
| +// Task 2: the store door — the ACL is the whole security story, so it is |
| +// tested as a matrix BEFORE any HTTP shape exists: every key class from the |
| +// spec's #73 inventory × operation × own/other tenancy, with the exact keys a |
| +// real box uses. A row that flips here is a tenancy breach, not a test nit. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The two identities every row is judged against: fleet "pi" owned by account |
| +// sqid "ab12". "OTHER"/"zz99" is any other tenant. |
| +const ( |
| + testSqid = "ab12" |
| + testFleet = "pi" |
| +) |
| + |
| +type aclOp int |
| + |
| +const ( |
| + opRead aclOp = iota |
| + opWrite |
| + opDelete |
| +) |
| + |
| +func (o aclOp) String() string { |
| + switch o { |
| + case opRead: |
| + return "read" |
| + case opWrite: |
| + return "write" |
| + default: |
| + return "delete" |
| + } |
| +} |
| + |
| +// aclAllows is the door's decision for one (key, op), canonicalization |
| +// included — exactly the order the handler runs it in: canonicalKey first, so |
| +// a traversal key is refused before any ACL string ever sees it. |
| +func aclAllows(key string, op aclOp) bool { |
| + k, ok := canonicalKey(key) |
| + if !ok { |
| + return false |
| + } |
| + if op == opRead { |
| + return aclRead(testSqid, testFleet, k) |
| + } |
| + return aclWrite(testSqid, testFleet, k) |
| +} |
| + |
| +func TestStoreDoorACLMatrix(t *testing.T) { |
| + rows := []struct { |
| + key string |
| + op aclOp |
| + want bool |
| + why string |
| + }{ |
| + // --- the shared platform reads the spec allowlists (F3, F7) --- |
| + {"control/serial", opRead, true, "the 2s convergence poll"}, |
| + {"control/serial", opWrite, false, "a box must never bump the platform serial"}, |
| + {"control/serial", opDelete, false, "nor delete it"}, |
| + {"bootstrap/carlos", opRead, true, "F7: the sanctioned self-update path"}, |
| + {"bootstrap/carlos", opWrite, false, "a box must never publish the platform binary"}, |
| + {"control/serial2", opRead, false, "the allowlist is exact, not a prefix"}, |
| + {"control/", opRead, false, "no listing-shaped key stands in for the allowlist"}, |
| + {"bootstrap/carlos.sig", opRead, false, "exact key only"}, |
| + |
| + // --- own account estate --- |
| + // The account manifest is accounts/<sqid>/account.json |
| + // (domains.AccountManifestKey), NOT accounts/<sqid>.json: a box that |
| + // cannot read it sees zero accounts, and provision.Reconcile then |
| + // retires every provisioned route on the box. |
| + {"accounts/ab12/account.json", opRead, true, "own account manifest"}, |
| + {"accounts/zz99/account.json", opRead, false, "another tenant's account manifest"}, |
| + {"accounts/ab12/account.json", opWrite, false, "reads only, even of its own account"}, |
| + {"accounts/ab12.json", opRead, false, "the manifest is a directory deeper; this key is not the account"}, |
| + {"accounts/ab12x/account.json", opRead, false, "sqid prefix must not match a longer sqid"}, |
| + {"accounts/ab12", opRead, false, "the account prefix itself is not an object"}, |
| + {"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"}, |
| + {"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"}, |
| + {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"}, |
| + {"apps/ab12/web/config/production.json", opRead, true, "own config bundle"}, |
| + {"apps/ab12/web/restart-gen.json", opRead, true, "own restart generation"}, |
| + {"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"}, |
| + {"apps/ab1/web/channels/stable.json", opRead, false, "nor a shorter one"}, |
| + {"apps/ab12", opRead, false, "the account prefix itself is not an object"}, |
| + |
| + // --- own fleet corner: the only writable place there is --- |
| + {"fleets/pi/bindings/app.example.json", opRead, true, "the binding takeover guard reads before it CASes"}, |
| + {"fleets/pi/bindings/app.example.json", opWrite, true, "fleet bindings are the box's own message"}, |
| + {"fleets/pi/status/boxes/box-a.json", opWrite, true, "own heartbeat"}, |
| + {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opWrite, true, "own metrics"}, |
| + {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opDelete, true, "own 48h metrics prune"}, |
| + {"fleets/other/status/boxes/box-a.json", opRead, false, "another fleet's corner"}, |
| + {"fleets/other/status/boxes/box-a.json", opWrite, false, "and it cannot write there"}, |
| + {"fleets/other/status/boxes/box-a.json", opDelete, false, "nor delete there"}, |
| + {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"}, |
| + {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"}, |
| + |
| + // --- shapes canonicalKey must kill before the ACL is consulted --- |
| + {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"}, |
| + {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"}, |
| + {"../etc/passwd", opRead, false, "traversal below the keyspace"}, |
| + {"/control/serial", opRead, false, "leading slash"}, |
| + {"apps//ab12/web/channels/stable.json", opRead, false, "empty segment"}, |
| + {"apps/ab12/./web/channels/stable.json", opRead, false, "dot segment"}, |
| + {"", opRead, false, "empty key"}, |
| + } |
| + for _, row := range rows { |
| + got := aclAllows(row.key, row.op) |
| + if got != row.want { |
| + t.Errorf("acl %s %q = %v, want %v (%s)", row.op, row.key, got, row.want, row.why) |
| + } |
| + } |
| +} |
| + |
| +// TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is |
| +// prefix-string exact, which is only safe because nothing that could re-point |
| +// a prefix ever reaches it. |
| +func TestCanonicalKeyRefusesShapes(t *testing.T) { |
| + bad := []string{ |
| + "", |
| + "/apps/ab12/x", |
| + "apps//ab12/x", |
| + "apps/ab12/../zz99/x", |
| + "..", |
| + "../x", |
| + "apps/ab12/x/..", |
| + "apps/./ab12/x", |
| + "apps/ab12/x/", |
| + "apps/ab12/\x00x", |
| + "apps/ab12/x\ny", |
| + strings.Repeat("a", 1025), |
| + } |
| + for _, k := range bad { |
| + if got, ok := canonicalKey(k); ok { |
| + t.Errorf("canonicalKey(%q) = (%q, true), want refused", k, got) |
| + } |
| + } |
| + good := []string{ |
| + "control/serial", |
| + "bootstrap/carlos", |
| + "accounts/ab12.json", |
| + "apps/ab12/web/releases/v1.2.3/app.tar.gz", |
| + "fleets/pi/bindings/app.example.json", |
| + strings.Repeat("a", 1024), |
| + } |
| + for _, k := range good { |
| + got, ok := canonicalKey(k) |
| + if !ok || got != k { |
| + t.Errorf("canonicalKey(%q) = (%q, %v), want (%q, true)", k, got, ok, k) |
| + } |
| + } |
| +} |
| + |
| +// TestACLListScope: a listing is scope-MAPPED, never scope-checked — asking |
| +// for "apps/" gets you your own account's subtree, not a refusal, because |
| +// that is what makes an unmodified release.Store caller work through the door. |
| +func TestACLListScope(t *testing.T) { |
| + cases := []struct { |
| + prefix string |
| + want string |
| + ok bool |
| + }{ |
| + {"apps/", "apps/ab12/", true}, |
| + {"apps", "apps/ab12/", true}, |
| + {"apps/ab12", "apps/ab12/", true}, |
| + {"apps/ab12/", "apps/ab12/", true}, |
| + {"apps/ab12/web/releases/", "apps/ab12/web/releases/", true}, |
| + {"apps/zz99/", "", false}, |
| + {"apps/ab1", "", false}, |
| + {"apps/ab12x/", "", false}, |
| + {"accounts/", "accounts/", true}, |
| + {"accounts", "accounts/", true}, |
| + {"accounts/zz99.json", "", false}, |
| + // DELIBERATE, and the trap worth naming: aclDir is STRICTER than |
| + // aclRead here. aclRead admits accounts/<own-sqid>/** as an object |
| + // read, but the listing scope map admits only the bare, SHARED |
| + // accounts/ prefix — a caller narrowing its listing to its own |
| + // account directory is refused. That is not an oversight to "fix": |
| + // every real caller of this prefix lists accounts/ whole |
| + // (provision.knownAccounts, domains.Reconcile, console.materialize) |
| + // and takes the per-tenant filter serveListing applies, so a |
| + // per-tenant mapping row here would widen the scope map for no |
| + // caller. If one ever appears, adding the row is the deliberate act |
| + // this pin makes visible. |
| + {"accounts/" + testSqid + "/", "", false}, |
| + {"accounts/" + testSqid, "", false}, |
| + {"fleets/", "fleets/pi/", true}, |
| + {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true}, |
| + {"fleets/other/", "", false}, |
| + {"control/", "", false}, |
| + {"bootstrap/", "", false}, |
| + {"", "", false}, |
| + {"status/", "", false}, |
| + } |
| + for _, c := range cases { |
| + got, ok := aclListScope(testSqid, testFleet, c.prefix) |
| + if got != c.want || ok != c.ok { |
| + t.Errorf("aclListScope(%q) = (%q, %v), want (%q, %v)", c.prefix, got, ok, c.want, c.ok) |
| + } |
| + } |
| +} |
| + |
| +// TestACLDirIsPerTenant pins the ListDirs predicate on its own. A child |
| +// prefix cannot be judged by aclRead (it is a directory, not an object), and |
| +// judging it by "is it under the prefix we listed" would enumerate every |
| +// account on the platform the moment accounts/ is listed. |
| +func TestACLDirIsPerTenant(t *testing.T) { |
| + cases := map[string]bool{ |
| + "accounts/" + testSqid + "/": true, |
| + "accounts/zz99/": false, |
| + "accounts/" + testSqid + "x/": false, |
| + "accounts/": false, |
| + "apps/" + testSqid + "/web/": true, |
| + "apps/zz99/web/": false, |
| + "apps/" + testSqid + "x/web/": false, |
| + "fleets/" + testFleet + "/status/": true, |
| + "fleets/other/status/": false, |
| + "fleets/" + testFleet + "x/y/": false, |
| + "control/": false, |
| + } |
| + for dir, want := range cases { |
| + if got := aclDir(testSqid, testFleet, dir); got != want { |
| + t.Errorf("aclDir(%q) = %v, want %v", dir, got, want) |
| + } |
| + } |
| +} |
| + |
| +// ---- handler tests ------------------------------------------------------ |
| + |
| +// The credentials every handler test runs with: a fleet channel |
| +// "<fleet>/<label>" whose token hash sits in the hub's dynamic table (the |
| +// registry-backed source), alongside a STATIC client which must never open |
| +// this door. |
| +const ( |
| + testChannel = testFleet + "/box-a" |
| + testToken = "box-a-bearer-token" |
| + staticName = "hq" |
| + staticToken = "static-client-token" |
| + ownKey = "apps/" + testSqid + "/web/channels/stable.json" |
| + ownFleetKey = "fleets/" + testFleet + "/status/boxes/box-a.json" |
| + otherAccountKey = "apps/zz99/web/channels/stable.json" |
| +) |
| + |
| +// newTestDoor builds a door over a DirStore with the identities above — the |
| +// same wiring edge.go does in production (hub + the edge's own store handle + |
| +// the reload-time fleet→account snapshot). |
| +func newTestDoor(t *testing.T) (*storeDoor, *release.DirStore) { |
| + t.Helper() |
| + hub := NewTunnelHub("tunnel.test", map[string]string{staticName: staticToken}) |
| + hub.SetDynamicAuth(map[string]string{testChannel: sha256Hex(testToken)}) |
| + store := release.NewDirStore(t.TempDir()) |
| + return &storeDoor{hub: hub, store: store, fleetAccount: func(fleet string) (string, bool) { |
| + if fleet == testFleet { |
| + return testSqid, true |
| + } |
| + return "", false |
| + }}, store |
| +} |
| + |
| +// doorReq builds a request with the given credentials; an empty token sends |
| +// no Authorization header at all, an empty name no Carlos-Tunnel-Name. |
| +func doorReq(method, target, name, token, body string) *http.Request { |
| + var r *http.Request |
| + if body == "" { |
| + r = httptest.NewRequest(method, target, nil) |
| + } else { |
| + r = httptest.NewRequest(method, target, strings.NewReader(body)) |
| + } |
| + if name != "" { |
| + r.Header.Set("Carlos-Tunnel-Name", name) |
| + } |
| + if token != "" { |
| + r.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + return r |
| +} |
| + |
| +// doorDo runs one correctly authenticated request through the door. |
| +func doorDo(d *storeDoor, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder { |
| + r := doorReq(method, target, testChannel, testToken, body) |
| + for k, v := range hdr { |
| + r.Header.Set(k, v) |
| + } |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +func seedObject(t *testing.T, s *release.DirStore, key, body string) string { |
| + t.Helper() |
| + etag, err := s.Put(context.Background(), key, []byte(body), "") |
| + if err != nil { |
| + t.Fatalf("seed %s: %v", key, err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestStoreDoorGetServesOwnEstate(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + etag := seedObject(t, store, ownKey, `{"version":"v1"}`) |
| + seedObject(t, store, "control/serial", "42") |
| + seedObject(t, store, "bootstrap/carlos", "ELF...") |
| + |
| + for _, key := range []string{ownKey, "control/serial", "bootstrap/carlos"} { |
| + w := doorDo(d, "GET", "/store/o/"+key, "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", key, w.Code) |
| + } |
| + } |
| + w := doorDo(d, "GET", "/store/o/"+ownKey, "", nil) |
| + if got := w.Body.String(); got != `{"version":"v1"}` { |
| + t.Errorf("body = %q, want the stored object", got) |
| + } |
| + if got := w.Header().Get("ETag"); got != etag { |
| + t.Errorf("ETag = %q, want the backing store's %q (etags are opaque, passed through)", got, etag) |
| + } |
| +} |
| + |
| +// TestStoreDoorCASRoundTrip pins F4 on the wire: there is no unconditional |
| +// Put, a lost race is 412, and a won race hands back the new etag. |
| +func TestStoreDoorCASRoundTrip(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + path := "/store/o/" + ownFleetKey |
| + |
| + if w := doorDo(d, "PUT", path, "first", nil); w.Code != http.StatusBadRequest { |
| + t.Fatalf("PUT with no precondition = %d, want 400 (the Store contract has no blind write)", w.Code) |
| + } |
| + if _, _, err := store.Get(context.Background(), ownFleetKey); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("a refused PUT still created the object") |
| + } |
| + if w := doorDo(d, "PUT", path, "x", map[string]string{"If-Match": "abc", "If-None-Match": "*"}); w.Code != http.StatusBadRequest { |
| + t.Errorf("PUT with both preconditions = %d, want 400", w.Code) |
| + } |
| + |
| + w := doorDo(d, "PUT", path, "first", map[string]string{"If-None-Match": "*"}) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("create-only PUT = %d, want 200", w.Code) |
| + } |
| + e1 := w.Header().Get("ETag") |
| + if e1 == "" { |
| + t.Fatal("create-only PUT returned no ETag") |
| + } |
| + if w := doorDo(d, "PUT", path, "again", map[string]string{"If-None-Match": "*"}); w.Code != http.StatusPreconditionFailed { |
| + t.Errorf("create-only PUT over an existing object = %d, want 412", w.Code) |
| + } |
| + if w := doorDo(d, "PUT", path, "stale", map[string]string{"If-Match": "0000"}); w.Code != http.StatusPreconditionFailed { |
| + t.Errorf("replace-only PUT with a stale etag = %d, want 412", w.Code) |
| + } |
| + w2 := doorDo(d, "PUT", path, "second", map[string]string{"If-Match": e1}) |
| + if w2.Code != http.StatusOK { |
| + t.Fatalf("replace-only PUT with the fresh etag = %d, want 200", w2.Code) |
| + } |
| + if e2 := w2.Header().Get("ETag"); e2 == "" || e2 == e1 { |
| + t.Errorf("ETag after replace = %q, want a new one (was %q)", e2, e1) |
| + } |
| + body, _, err := store.Get(context.Background(), ownFleetKey) |
| + if err != nil || string(body) != "second" { |
| + t.Fatalf("stored body = %q, %v; want \"second\"", body, err) |
| + } |
| +} |
| + |
| +func TestStoreDoorDeleteIsScopedAndIdempotent(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, ownFleetKey, "{}") |
| + seedObject(t, store, ownKey, "{}") |
| + |
| + for i := 0; i < 2; i++ { |
| + if w := doorDo(d, "DELETE", "/store/o/"+ownFleetKey, "", nil); w.Code != http.StatusNoContent { |
| + t.Fatalf("DELETE own fleet key (attempt %d) = %d, want 204", i+1, w.Code) |
| + } |
| + } |
| + if w := doorDo(d, "DELETE", "/store/o/"+ownKey, "", nil); w.Code != http.StatusNotFound { |
| + t.Fatalf("DELETE a release object = %d, want 404 (retention is not a box's job)", w.Code) |
| + } |
| + if _, _, err := store.Get(context.Background(), ownKey); err != nil { |
| + t.Fatalf("the refused DELETE removed the object anyway: %v", err) |
| + } |
| +} |
| + |
| +// TestStoreDoorRefusalsAreByteIdentical is the spec's central security |
| +// invariant: bad auth, a static (non-fleet) name, another tenant's key, a |
| +// missing in-scope key and a malformed key must be INDISTINGUISHABLE — same |
| +// status, same headers, same bytes — or the door becomes a keyspace oracle. |
| +func TestStoreDoorRefusalsAreByteIdentical(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, otherAccountKey, "someone else's") |
| + seedObject(t, store, "fleets/other/status/boxes/box-a.json", "another fleet's") |
| + // Seeded so the account-manifest row below refuses a key that really |
| + // EXISTS: the refusal must not be distinguishable from the missing-key |
| + // one, which is only proved against an object that is actually there. |
| + seedObject(t, store, "accounts/zz99/account.json", "another tenant's account") |
| + |
| + req := func(name, token, method, target string) *http.Request { |
| + return doorReq(method, target, name, token, "") |
| + } |
| + cases := map[string]*http.Request{ |
| + "wrong token": req(testChannel, "not-the-token", "GET", "/store/o/"+ownKey), |
| + "no auth at all": req("", "", "GET", "/store/o/"+ownKey), |
| + "static name + token": req(staticName, staticToken, "GET", "/store/o/control/serial"), |
| + "unknown fleet": req("nosuch/box", testToken, "GET", "/store/o/control/serial"), |
| + "other account's key": req(testChannel, testToken, "GET", "/store/o/"+otherAccountKey), |
| + "other fleet's key": req(testChannel, testToken, "GET", "/store/o/fleets/other/status/boxes/box-a.json"), |
| + "missing in-scope key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/web/nope.json"), |
| + "traversal key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/../zz99/x"), |
| + "empty key": req(testChannel, testToken, "GET", "/store/o/"), |
| + "write out of scope": req(testChannel, testToken, "DELETE", "/store/o/"+otherAccountKey), |
| + "list out of scope": req(testChannel, testToken, "GET", "/store/list?prefix=apps/zz99/"), |
| + "unknown path": req(testChannel, testToken, "GET", "/store/nope"), |
| + "wrong method": req(testChannel, testToken, "POST", "/store/o/"+ownKey), |
| + // The two classes Task 2 deferred. The account manifest is the key a |
| + // tenant-enumeration probe would reach for FIRST (accounts/<sqid>/ |
| + // account.json is the one object whose presence answers "does this |
| + // account exist"), and /store/dirs is the whole second listing route |
| + // — its refusals were never pinned to the same shape as /store/list's. |
| + "other account's manifest": req(testChannel, testToken, "GET", "/store/o/accounts/zz99/account.json"), |
| + "dirs out of scope": req(testChannel, testToken, "GET", "/store/dirs?prefix=accounts/zz99/"), |
| + "dirs of another fleet": req(testChannel, testToken, "GET", "/store/dirs?prefix=fleets/other/"), |
| + "dirs unauthenticated": req(testChannel, "not-the-token", "GET", "/store/dirs?prefix=fleets/"), |
| + } |
| + type shape struct { |
| + code int |
| + header string |
| + body string |
| + } |
| + var want *shape |
| + var wantName string |
| + for name, r := range cases { |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()} |
| + if got.code != http.StatusNotFound { |
| + t.Errorf("%s: status = %d, want 404", name, got.code) |
| + } |
| + if strings.Contains(got.body, testToken) || strings.Contains(got.header, testToken) { |
| + t.Errorf("%s: response echoes the bearer token", name) |
| + } |
| + if want == nil { |
| + g := got |
| + want, wantName = &g, name |
| + continue |
| + } |
| + if got != *want { |
| + t.Errorf("%q answers %+v but %q answers %+v — the two refusals are distinguishable", name, got, wantName, *want) |
| + } |
| + } |
| +} |
| + |
| +// TestStoreDoorListScopeStripsOtherTenants: a listing is the easiest place to |
| +// leak a neighbour, so every listing route is checked against a store that |
| +// holds another account's and another fleet's objects. |
| +func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) { |
| + d, store := newTestDoor(t) |
| + seedObject(t, store, ownKey, "{}") |
| + seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}") |
| + seedObject(t, store, otherAccountKey, "{}") |
| + seedObject(t, store, "accounts/"+testSqid+"/account.json", "{}") |
| + seedObject(t, store, "accounts/zz99/account.json", "{}") |
| + seedObject(t, store, ownFleetKey, "{}") |
| + seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}") |
| + |
| + list := func(target string) []string { |
| + t.Helper() |
| + w := doorDo(d, "GET", target, "", nil) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET %s = %d, want 200", target, w.Code) |
| + } |
| + var out []string |
| + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { |
| + t.Fatalf("GET %s: decode %q: %v", target, w.Body.String(), err) |
| + } |
| + return out |
| + } |
| + for _, c := range []struct { |
| + target string |
| + want []string |
| + }{ |
| + {"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| + {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + "/account.json"}}, |
| + {"/store/list?prefix=fleets/", []string{ownFleetKey}}, |
| + {"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}}, |
| + {"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}}, |
| + {"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}}, |
| + // The regression that matters most on the dirs route: accounts/ is a |
| + // SHARED prefix, and ListDirs returns one child per account on the |
| + // platform. Filtering by "is it under the prefix we listed" is a |
| + // tautology there — the answer must be this tenant's own directory |
| + // and nothing else, with zz99 seeded above to prove it. |
| + {"/store/dirs?prefix=accounts/", []string{"accounts/" + testSqid + "/"}}, |
| + } { |
| + got := list(c.target) |
| + if !slices.Equal(got, c.want) { |
| + t.Errorf("%s = %v, want %v", c.target, got, c.want) |
| + } |
| + } |
| + // An empty listing is [] on the wire, never null: the client decodes into |
| + // a []string either way, but null would invite a nil-vs-empty distinction |
| + // the Store contract does not have. |
| + w := doorDo(d, "GET", "/store/list?prefix=apps/"+testSqid+"/nosuchapp/", "", nil) |
| + if body := strings.TrimSpace(w.Body.String()); body != "[]" { |
| + t.Errorf("empty listing body = %q, want []", body) |
| + } |
| +} |
| + |
| +// doorOpCounter records how many store operations a request performed — |
| +// enough to prove a refused request touches the bucket zero times. |
| +type doorOpCounter struct { |
| + release.Store |
| + ops int |
| +} |
| + |
| +func (c *doorOpCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.ops++ |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *doorOpCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + c.ops++ |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (c *doorOpCounter) List(ctx context.Context, prefix string) ([]string, error) { |
| + c.ops++ |
| + return c.Store.List(ctx, prefix) |
| +} |
| + |
| +func (c *doorOpCounter) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + c.ops++ |
| + return c.Store.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (c *doorOpCounter) Delete(ctx context.Context, key string) error { |
| + c.ops++ |
| + return c.Store.Delete(ctx, key) |
| +} |
| + |
| +// TestStoreDoorAuthRunsExactlyOncePerRequest pins the invariant two ways: the |
| +// handler contains exactly ONE call to VerifyDynamic (no path can skip it, |
| +// none can pay for it twice — and the auth logic itself is never |
| +// re-implemented here), and an unauthenticated request of any shape reaches |
| +// the store zero times. |
| +func TestStoreDoorAuthRunsExactlyOncePerRequest(t *testing.T) { |
| + fset := token.NewFileSet() |
| + f, err := parser.ParseFile(fset, "storedoor.go", nil, 0) |
| + if err != nil { |
| + t.Fatalf("parse storedoor.go: %v", err) |
| + } |
| + calls := 0 |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "VerifyDynamic" { |
| + calls++ |
| + } |
| + return true |
| + }) |
| + if calls != 1 { |
| + t.Errorf("storedoor.go calls VerifyDynamic %d times, want exactly 1 (one auth, one place)", calls) |
| + } |
| + |
| + d, _ := newTestDoor(t) |
| + counting := &doorOpCounter{Store: d.store} |
| + d.store = counting |
| + for _, r := range []*http.Request{ |
| + doorReq("GET", "/store/o/"+ownKey, testChannel, "wrong", ""), |
| + doorReq("PUT", "/store/o/"+ownFleetKey, testChannel, "wrong", "x"), |
| + doorReq("DELETE", "/store/o/"+ownFleetKey, testChannel, "wrong", ""), |
| + doorReq("GET", "/store/list?prefix=apps/", testChannel, "wrong", ""), |
| + doorReq("GET", "/store/dirs?prefix=apps/", testChannel, "wrong", ""), |
| + doorReq("GET", "/store/o/"+ownKey, staticName, staticToken, ""), |
| + doorReq("GET", "/store/o/"+ownKey, "", "", ""), |
| + } { |
| + w := httptest.NewRecorder() |
| + d.ServeHTTP(w, r) |
| + if w.Code != http.StatusNotFound { |
| + t.Errorf("%s %s unauthenticated = %d, want 404", r.Method, r.URL, w.Code) |
| + } |
| + } |
| + if counting.ops != 0 { |
| + t.Errorf("unauthenticated requests performed %d store operation(s), want 0", counting.ops) |
| + } |
| +} |
| + |
| +// TestStoreDoorOnlyOnReservedHost drives the door through the EDGE, not |
| +// directly: it must be unreachable on every host except the reserved tunnel |
| +// hostname, exactly like tunnel registration — and it must not have swallowed |
| +// /register on the way in. |
| +func TestStoreDoorOnlyOnReservedHost(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: testFleet, Label: "box-a", TokenHash: sha256Hex(testToken), Status: "active"}); err != nil { |
| + t.Fatalf("AddFleetBox: %v", err) |
| + } |
| + backend, _ := hostEchoBackend(t, "the app, not the door") |
| + if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert route: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + store := release.NewDirStore(t.TempDir()) |
| + seedObject(t, store, "control/serial", "77") |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableTunnels(NewTunnelHub("tunnel.test", nil)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + get := func(host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", srv.URL+path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = host |
| + req.Header.Set("Carlos-Tunnel-Name", testChannel) |
| + req.Header.Set("Authorization", "Bearer "+testToken) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s (host %s): %v", path, host, err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + return resp.StatusCode, string(b) |
| + } |
| + if code, body := get("tunnel.test", "/store/o/control/serial"); code != http.StatusOK || body != "77" { |
| + t.Fatalf("on the reserved host: %d %q, want 200 \"77\"", code, body) |
| + } |
| + if code, body := get("app.example", "/store/o/control/serial"); code != http.StatusOK || body != "the app, not the door" { |
| + t.Fatalf("on a routed host: %d %q — the door answered on a host it must not serve", code, body) |
| + } |
| + if code, _ := get("nosuch.example", "/store/o/control/serial"); code == http.StatusOK { |
| + t.Fatal("the door answered on an unknown host") |
| + } |
| + // /register stays the hub's on the same hostname: no upgrade headers, so |
| + // a 400 (the hub's wire-contract refusal), never the door's 404. |
| + if code, _ := get("tunnel.test", "/register"); code != http.StatusBadRequest { |
| + t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code) |
| + } |
| +} |
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go| index d40999a60..7f619d995 100644 |
| --- a/internal/edge/tunnel.go |
| +++ b/internal/edge/tunnel.go |
| @@ -154,15 +154,8 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // that fact is known. |
| viaDynamic := false |
| if !authOK && name != "" && hasBearer { |
| - h.mu.Lock() |
| - wantHash, dynOK := h.dynamicAuth[name] |
| - h.mu.Unlock() |
| - if dynOK && wantHash != "" { |
| - sum := sha256.Sum256([]byte(token)) |
| - gotHash := hex.EncodeToString(sum[:]) |
| - authOK = subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) == 1 |
| - viaDynamic = authOK |
| - } |
| + _, viaDynamic = h.VerifyDynamic(name, token) |
| + authOK = viaDynamic |
| } |
| if !authOK { |
| http.Error(w, "unauthorized", http.StatusUnauthorized) |
| @@ -253,6 +246,42 @@ func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dy |
| } |
| } |
| +// VerifyDynamic reports whether (name, token) authenticates as a dynamic |
| +// (registry-backed fleet) channel, and which fleet owns it. It is the single |
| +// answer to that question: ServeHTTP's dynamic branch is one caller, and |
| +// surfaces with no ResponseWriter to 401 with are the others. |
| +// |
| +// The "/" requirement is a gate, not a parse convenience: static channel names |
| +// are ValidTunnelName-checked and never contain "/", so refusing slash-less |
| +// names here means a static client's plaintext token can never be admitted by |
| +// this path, whatever h.clients (or a hand-built dynamicAuth) holds. An empty |
| +// fleet segment ("/box-a") is refused for the same reason: it would otherwise |
| +// authenticate into fleet "", a name no real fleet has, and every ownership |
| +// check downstream compares against the fleet this returns. |
| +// |
| +// The presented token is never compared in the clear — only its sha256 hex is, |
| +// against the stored hash — so a leaked dump of this table yields no usable |
| +// bearer token, and the compare is constant-time so a wrong token leaks no |
| +// prefix information by timing. |
| +func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool) { |
| + fleet, _, hasSlash := strings.Cut(name, "/") |
| + if !hasSlash || fleet == "" { |
| + return "", false |
| + } |
| + h.mu.Lock() |
| + wantHash, known := h.dynamicAuth[name] |
| + h.mu.Unlock() |
| + if !known || wantHash == "" { |
| + return "", false |
| + } |
| + sum := sha256.Sum256([]byte(token)) |
| + gotHash := hex.EncodeToString(sum[:]) |
| + if subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) != 1 { |
| + return "", false |
| + } |
| + return fleet, true |
| +} |
| + |
| // SetDynamicAuth replaces the entire registry-backed credential set — |
| // channel "<fleet>/<label>" -> sha256 hex of its bearer token — with auth. |
| // The whole-map REPLACE (not an incremental merge) is what makes a NEW dial |
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go| index 3f7b278c7..4ba3c095a 100644 |
| --- a/internal/edge/tunnel_test.go |
| +++ b/internal/edge/tunnel_test.go |
| @@ -197,6 +197,72 @@ func TestStaticClientStillAuthenticatesAlongsideDynamicSet(t *testing.T) { |
| } |
| } |
| +// TestVerifyDynamic covers the extracted gate directly, without a handshake: |
| +// VerifyDynamic is the one place that answers "does (name, token) authenticate |
| +// as a fleet channel, and whose?" — ServeHTTP's dynamic branch is now just a |
| +// caller, and later fleet-API surfaces (which have no ResponseWriter to 401 |
| +// with) need the same answer. |
| +func TestVerifyDynamic(t *testing.T) { |
| + // The static client is configured on the SAME hub on purpose: a static |
| + // name must never authenticate through this gate, even presenting the |
| + // correct plaintext token h.clients holds for it. ServeHTTP's static |
| + // compare is the only path that may admit it. |
| + hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"}) |
| + hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")}) |
| + for _, tc := range []struct { |
| + desc, name, token string |
| + wantFleet string |
| + wantOK bool |
| + }{ |
| + {desc: "static name with its own correct plaintext token", name: "hq", token: "sekrit"}, |
| + {desc: "unknown dynamic name", name: "pi/box-z", token: "tok"}, |
| + {desc: "right name, wrong token", name: "pi/box-a", token: "wrong"}, |
| + {desc: "right name and token", name: "pi/box-a", token: "tok", wantFleet: "pi", wantOK: true}, |
| + } { |
| + fleet, ok := hub.VerifyDynamic(tc.name, tc.token) |
| + if ok != tc.wantOK || fleet != tc.wantFleet { |
| + t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (%q, %v)", |
| + tc.desc, tc.name, tc.token, fleet, ok, tc.wantFleet, tc.wantOK) |
| + } |
| + } |
| + // A SetDynamicAuth swap that drops the name must refuse it the instant it |
| + // returns — the same whole-map-replace property detach relies on. |
| + hub.SetDynamicAuth(map[string]string{"pi/box-b": sha256Hex("tok")}) |
| + if fleet, ok := hub.VerifyDynamic("pi/box-a", "tok"); ok || fleet != "" { |
| + t.Errorf("after a SetDynamicAuth swap dropping the name: got (%q, %v), want (\"\", false)", fleet, ok) |
| + } |
| +} |
| + |
| +// TestVerifyDynamicRefusesSlashlessNameInDynamicAuth is the one case only the |
| +// "/" gate can refuse, and so the only test that actually pins it: the name is |
| +// PRESENT in dynamicAuth under a hash that matches the presented token, so |
| +// every other check in VerifyDynamic passes. Delete the gate and this test |
| +// fails; the table above would not, because "hq" simply misses that map. |
| +// |
| +// It makes the doc comment's claim real: no slash-less name authenticates |
| +// here, whatever dynamicAuth holds. Production never writes such a key |
| +// (FleetChannelAuth emits "<fleet>/<label>" only) — the point is that a future |
| +// writer that did could not silently turn a static channel name, or a name |
| +// with no fleet at all, into a fleet-authenticated one. |
| +func TestVerifyDynamicRefusesSlashlessNameInDynamicAuth(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"}) |
| + hub.SetDynamicAuth(map[string]string{ |
| + "hq": sha256Hex("sekrit"), // a static channel name, hash-correct |
| + "/box-a": sha256Hex("tok"), // empty fleet segment, hash-correct |
| + "noslash": sha256Hex("tok"), |
| + }) |
| + for _, tc := range []struct{ desc, name, token string }{ |
| + {"static name, hash-correct, present in dynamicAuth", "hq", "sekrit"}, |
| + {"empty fleet segment", "/box-a", "tok"}, |
| + {"bare name with no separator", "noslash", "tok"}, |
| + } { |
| + if fleet, ok := hub.VerifyDynamic(tc.name, tc.token); ok || fleet != "" { |
| + t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (\"\", false)", |
| + tc.desc, tc.name, tc.token, fleet, ok) |
| + } |
| + } |
| +} |
| + |
| // TestCloseChannelClosesAndRemovesLiveConn: CloseChannel is the detach/rotate |
| // primitive — force-close a live conn and drop it from the map. No real h2 |
| // handshake needed: CloseChannel only ever touches .raw. |
diff --git a/internal/fleetapi/fleetapi.go b/internal/fleetapi/fleetapi.go| new file mode 100644 |
| index 000000000..73f1e0402 |
| --- /dev/null |
| +++ b/internal/fleetapi/fleetapi.go |
| @@ -0,0 +1,254 @@ |
| +// Package fleetapi is the client half of the fleet store door (spec |
| +// 2026-08-11-fleet-api-surface-design.md, F5; Task 2's server is |
| +// internal/edge/storedoor.go). It implements release.Store over the five |
| +// HTTPS operations the door serves on the edge's reserved tunnel hostname — |
| +// GET/PUT/DELETE /store/o/<key>, GET /store/list, GET /store/dirs — using the |
| +// same two headers a tunnel dial already carries: "Authorization: Bearer |
| +// <token>" and "Carlos-Tunnel-Name: <fleet>/<label>". A box that builds one |
| +// of these needs no AWS credentials on the platform's deployment bucket at |
| +// all: every loop that already speaks release.Store — adoption, fast |
| +// convergence, config, status, metrics, bindings — runs unchanged against it. |
| +package fleetapi |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// callTimeout bounds connect, TLS handshake, and the wait for the response |
| +// headers to start arriving — the "is the edge even alive and answering" |
| +// phase of a call. It deliberately does NOT bound the rest of the exchange |
| +// (reading/writing the body): Get is the artifact-download path (release |
| +// adoption fetches whole app.tar.gz bodies through this store), and this |
| +// spec's own target box class — home broadband, a Pi's uplink — can take |
| +// well over 30s to pull a large artifact while still making steady |
| +// progress. Putting this on http.Client.Timeout instead (a whole-exchange |
| +// wall clock) would fail every such transfer deterministically, on every |
| +// adoption pass, forever; S3Store has no equivalent cap, so this store must |
| +// not invent a stricter one. A wedged edge that never starts answering still |
| +// fails fast — that's what ResponseHeaderTimeout below is for. |
| +const callTimeout = 30 * time.Second |
| + |
| +// store is the client half of the door: release.Store spoken over base's |
| +// /store/ routes, authenticated by name/token on every request. |
| +type store struct { |
| + base string // origin, no trailing slash — e.g. "https://tunnel.example.com" |
| + name string // Carlos-Tunnel-Name: "<fleet>/<label>" |
| + token string // bearer token; NEVER placed in an error string (see errorf) |
| + client *http.Client |
| +} |
| + |
| +// New builds a release.Store speaking the edge's store door at base |
| +// (CARLOS_TUNNEL_URL's origin) as the fleet channel identity name/token (the |
| +// box's CARLOS_TUNNEL_NAME/_TOKEN). All three are required — there is no |
| +// meaningful partial identity to fall back to, and a caller that got one of |
| +// them empty has a config bug worth failing on at construction rather than |
| +// on the first call. |
| +func New(base, name, token string) (release.Store, error) { |
| + if base == "" { |
| + return nil, errors.New("fleetapi: base is required") |
| + } |
| + if name == "" { |
| + return nil, errors.New("fleetapi: name is required") |
| + } |
| + if token == "" { |
| + return nil, errors.New("fleetapi: token is required") |
| + } |
| + u, err := url.Parse(base) |
| + if err != nil || u.Scheme == "" || u.Host == "" { |
| + return nil, fmt.Errorf("fleetapi: invalid base %q: want an absolute URL", base) |
| + } |
| + return &store{ |
| + base: strings.TrimSuffix(base, "/"), |
| + name: name, |
| + token: token, |
| + // No Client.Timeout (see callTimeout's comment): only the connect/TLS/ |
| + // response-header phase is bounded, so a slow-but-progressing artifact |
| + // transfer can still finish. |
| + client: &http.Client{Transport: &http.Transport{ |
| + Proxy: http.ProxyFromEnvironment, |
| + DialContext: (&net.Dialer{ |
| + Timeout: callTimeout, |
| + KeepAlive: 30 * time.Second, |
| + }).DialContext, |
| + TLSHandshakeTimeout: callTimeout, |
| + ResponseHeaderTimeout: callTimeout, |
| + ExpectContinueTimeout: 1 * time.Second, |
| + }}, |
| + }, nil |
| +} |
| + |
| +// errorf builds a client-side error. Every call site here formats around |
| +// the key/prefix and the response shape only — never s.token — so a leaked |
| +// error string (a log line, a status page) can never hand back the bearer |
| +// credential this store authenticates with. |
| +func errorf(format string, args ...any) error { |
| + return fmt.Errorf("fleetapi: "+format, args...) |
| +} |
| + |
| +// objectURL builds the /store/o/<key> URL, percent-encoding key the same way |
| +// any other path segment is encoded — the door decodes it back with |
| +// net/http's ordinary request parsing (storedoor.go's canonicalKey comment: |
| +// "already percent-decoded by net/http"), so this is the inverse of that, |
| +// not a second canonicalization. |
| +func (s *store) objectURL(key string) string { |
| + u := url.URL{Path: "/store/o/" + key} |
| + return s.base + u.EscapedPath() |
| +} |
| + |
| +func (s *store) listURL(path, prefix string) string { |
| + return s.base + path + "?" + url.Values{"prefix": {prefix}}.Encode() |
| +} |
| + |
| +// newRequest attaches the two headers every door route requires (F1): the |
| +// same bearer token and channel name a tunnel dial sends. |
| +func (s *store) newRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) { |
| + req, err := http.NewRequestWithContext(ctx, method, rawURL, body) |
| + if err != nil { |
| + return nil, errorf("build request: %w", err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+s.token) |
| + req.Header.Set("Carlos-Tunnel-Name", s.name) |
| + return req, nil |
| +} |
| + |
| +// Get fetches key and its etag. A 404 — which the door hands back |
| +// byte-identically whether the key never existed, belongs to another |
| +// tenant, or the caller's credential is bad (spec: no response distinguishes |
| +// them) — is mapped uniformly onto release.Store's os.ErrNotExist contract: |
| +// this client cannot tell those apart either, by design, and must not |
| +// pretend otherwise. |
| +func (s *store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return nil, "", errorf("get %s: %w", key, err) |
| + } |
| + defer resp.Body.Close() |
| + body, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + return nil, "", errorf("get %s: read body: %w", key, err) |
| + } |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + return body, resp.Header.Get("ETag"), nil |
| + case http.StatusNotFound: |
| + return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist) |
| + default: |
| + return nil, "", errorf("get %s: unexpected status %d", key, resp.StatusCode) |
| + } |
| +} |
| + |
| +// Put mirrors the door's CAS mapping exactly (storedoor.go's |
| +// putPrecondition / F4): ifMatch == "" sends If-None-Match: * (create-only, |
| +// the contract's only form of an unconditional-looking write), a non-empty |
| +// ifMatch sends If-Match: <etag> (replace-only). There is no unconditional |
| +// Put on this wire, so this client never attempts one. A lost race (412) |
| +// maps back to release.ErrCASConflict — the same error a direct DirStore or |
| +// S3Store Put would return, so a heartbeat or binding-takeover CAS loop |
| +// behaves identically whether or not it is going through the door. |
| +func (s *store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + req, err := s.newRequest(ctx, http.MethodPut, s.objectURL(key), bytes.NewReader(body)) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.ContentLength = int64(len(body)) |
| + if ifMatch == "" { |
| + req.Header.Set("If-None-Match", "*") |
| + } else { |
| + req.Header.Set("If-Match", ifMatch) |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return "", errorf("put %s: %w", key, err) |
| + } |
| + defer resp.Body.Close() |
| + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + return resp.Header.Get("ETag"), nil |
| + case http.StatusPreconditionFailed: |
| + return "", fmt.Errorf("fleetapi: %s: %w", key, release.ErrCASConflict) |
| + default: |
| + return "", errorf("put %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| + } |
| +} |
| + |
| +// Delete is idempotent, matching release.Store's contract: the door |
| +// (storedoor.go) always answers 204 for an in-scope key, deleted or not, |
| +// which is where the idempotency lives — NOT in this client folding a 404 |
| +// into success. A 404 here is only ever a refusal (bad token, bad name, an |
| +// out-of-scope key, a malformed key, a misrouted base): it is never "already |
| +// gone", because "already gone" is exactly the case the door answers 204. |
| +// Treating it as success would make a mis-credentialled client report every |
| +// delete as having worked. |
| +func (s *store) Delete(ctx context.Context, key string) error { |
| + req, err := s.newRequest(ctx, http.MethodDelete, s.objectURL(key), nil) |
| + if err != nil { |
| + return err |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return errorf("delete %s: %w", key, err) |
| + } |
| + defer resp.Body.Close() |
| + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| + if resp.StatusCode != http.StatusNoContent { |
| + return errorf("delete %s: unexpected status %d: %s", key, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| + } |
| + return nil |
| +} |
| + |
| +// List is the door's /store/list — see listDecode. Note the spec's callout: |
| +// the door 404s a root listing (List("")) the same as any other out-of-scope |
| +// prefix (aclListScope refuses an empty prefix outright), and this client |
| +// does not special-case it: an empty-prefix caller gets exactly that error, |
| +// same as any other caller would over a direct S3Store asking for a prefix |
| +// its bucket policy refuses. |
| +func (s *store) List(ctx context.Context, prefix string) ([]string, error) { |
| + return s.listDecode(ctx, "/store/list", prefix) |
| +} |
| + |
| +// ListDirs is the door's /store/dirs (the delimiter-aware listing). |
| +func (s *store) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + return s.listDecode(ctx, "/store/dirs", prefix) |
| +} |
| + |
| +func (s *store) listDecode(ctx context.Context, path, prefix string) ([]string, error) { |
| + req, err := s.newRequest(ctx, http.MethodGet, s.listURL(path, prefix), nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + resp, err := s.client.Do(req) |
| + if err != nil { |
| + return nil, errorf("list %s: %w", prefix, err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| + return nil, errorf("list %s: unexpected status %d: %s", prefix, resp.StatusCode, strings.TrimSpace(string(respBody))) |
| + } |
| + // The door never sends null (storedoor.go: "an empty listing is []"), but |
| + // decoding into a nil slice handles either shape the same way a caller |
| + // ranging over the result cares about: zero iterations. |
| + var out []string |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + return nil, errorf("list %s: decode response: %w", prefix, err) |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/fleetapi/fleetapi_internal_test.go b/internal/fleetapi/fleetapi_internal_test.go| new file mode 100644 |
| index 000000000..ea1dada3e |
| --- /dev/null |
| +++ b/internal/fleetapi/fleetapi_internal_test.go |
| @@ -0,0 +1,39 @@ |
| +package fleetapi |
| + |
| +// White-box (package fleetapi, not fleetapi_test): pins the transport |
| +// construction New() does, which no exported surface exposes — C2's fix |
| +// shape (a Transport with dial/TLS/response-header timeouts and no |
| +// Client.Timeout) is otherwise invisible to a caller until an actual slow |
| +// transfer either survives or doesn't. |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +func TestClientHasNoWholeExchangeTimeoutButBoundsResponseHeaders(t *testing.T) { |
| + s, err := New("https://tunnel.example.com", "pi/box-a", "tok") |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + st, ok := s.(*store) |
| + if !ok { |
| + t.Fatalf("New returned %T, want *store", s) |
| + } |
| + if st.client.Timeout != 0 { |
| + t.Errorf("client.Timeout = %v, want 0 (a whole-exchange deadline would fail slow-but-progressing artifact downloads)", st.client.Timeout) |
| + } |
| + tr, ok := st.client.Transport.(*http.Transport) |
| + if !ok { |
| + t.Fatalf("client.Transport = %T, want *http.Transport", st.client.Transport) |
| + } |
| + if tr.ResponseHeaderTimeout != callTimeout { |
| + t.Errorf("ResponseHeaderTimeout = %v, want %v (a wedged edge must still fail fast)", tr.ResponseHeaderTimeout, callTimeout) |
| + } |
| + if tr.TLSHandshakeTimeout != callTimeout { |
| + t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, callTimeout) |
| + } |
| + if tr.DialContext == nil { |
| + t.Error("DialContext = nil, want a dialer with a connect timeout set") |
| + } |
| +} |
diff --git a/internal/fleetapi/fleetapi_test.go b/internal/fleetapi/fleetapi_test.go| new file mode 100644 |
| index 000000000..4956c9b18 |
| --- /dev/null |
| +++ b/internal/fleetapi/fleetapi_test.go |
| @@ -0,0 +1,366 @@ |
| +package fleetapi_test |
| + |
| +// Round-trip tests against the REAL store door (internal/edge/storedoor.go, |
| +// Task 2) over a real net.Listener + release.DirStore — no hand-rolled fake |
| +// speaks for the wire here. The door is reached by wiring a full edge.Edge |
| +// the same way production does (registry fleet/box rows, EnableStatic, |
| +// EnableTunnels, Reload) and pointing the hub's reserved hostname at the |
| +// listener's own address, so an ordinary HTTP client naturally sends the |
| +// Host header the door's routing (edge.go: r.Host == hub.Host(), port |
| +// stripped) requires — no Host-header override needed, the same shape a real |
| +// box dialing CARLOS_TUNNEL_URL would produce. |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/fleetapi" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + testSqid = "ab12" |
| + testFleet = "pi" |
| + testLabel = "box-a" |
| + testToken = "box-a-bearer-token-abc123" |
| +) |
| + |
| +func sha256Hex(s string) string { |
| + sum := sha256.Sum256([]byte(s)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// testDoor wires a real edge.Edge with the store door armed, over a plain |
| +// net.Listener whose bound IP becomes the hub's reserved hostname — the |
| +// production shape (EnableStatic + EnableTunnels + Reload), minus TLS, which |
| +// the door itself has no opinion about (edge.go terminates that separately). |
| +type testDoor struct { |
| + base string |
| + store *release.DirStore |
| +} |
| + |
| +func newTestDoor(t *testing.T) testDoor { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("registry.Open: %v", err) |
| + } |
| + if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{ |
| + Fleet: testFleet, Label: testLabel, TokenHash: sha256Hex(testToken), Status: "active", |
| + }); err != nil { |
| + t.Fatalf("AddFleetBox: %v", err) |
| + } |
| + |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatalf("listen: %v", err) |
| + } |
| + t.Cleanup(func() { ln.Close() }) |
| + host := ln.Addr().(*net.TCPAddr).IP.String() // "127.0.0.1" — edge.go strips the port before comparing |
| + |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("edge.New: %v", err) |
| + } |
| + dirStore := release.NewDirStore(t.TempDir()) |
| + e.EnableStatic(dirStore, nil, filepath.Join(t.TempDir(), "cache")) |
| + e.EnableTunnels(edge.NewTunnelHub(host, nil)) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + srv := &http.Server{Handler: e} |
| + go srv.Serve(ln) //nolint:errcheck |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + return testDoor{base: "http://" + ln.Addr().String(), store: dirStore} |
| +} |
| + |
| +func (d testDoor) client(t *testing.T, name, token string) release.Store { |
| + t.Helper() |
| + s, err := fleetapi.New(d.base, name, token) |
| + if err != nil { |
| + t.Fatalf("fleetapi.New: %v", err) |
| + } |
| + return s |
| +} |
| + |
| +func (d testDoor) box(t *testing.T) release.Store { |
| + return d.client(t, testFleet+"/"+testLabel, testToken) |
| +} |
| + |
| +func seed(t *testing.T, s *release.DirStore, key, body string) string { |
| + t.Helper() |
| + etag, err := s.Put(context.Background(), key, []byte(body), "") |
| + if err != nil { |
| + t.Fatalf("seed %s: %v", key, err) |
| + } |
| + return etag |
| +} |
| + |
| +// ---- New() argument validation -------------------------------------------- |
| + |
| +func TestNewRefusesEmptyArgs(t *testing.T) { |
| + cases := []struct{ base, name, token string }{ |
| + {"", "pi/box-a", "tok"}, |
| + {"https://tunnel.example.com", "", "tok"}, |
| + {"https://tunnel.example.com", "pi/box-a", ""}, |
| + {"not-a-url", "pi/box-a", "tok"}, |
| + } |
| + for _, c := range cases { |
| + if s, err := fleetapi.New(c.base, c.name, c.token); err == nil { |
| + t.Errorf("New(%q, %q, %q) = %v, nil; want an error", c.base, c.name, c.token, s) |
| + } |
| + } |
| +} |
| + |
| +// ---- Get / Put CAS round trip ---------------------------------------------- |
| + |
| +func TestGetPutCASRoundTrip(t *testing.T) { |
| + d := newTestDoor(t) |
| + s := d.box(t) |
| + ctx := context.Background() |
| + key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" |
| + |
| + // Create-only (ifMatch == "" -> If-None-Match: *). |
| + etag1, err := s.Put(ctx, key, []byte(`{"n":1}`), "") |
| + if err != nil { |
| + t.Fatalf("create-only Put: %v", err) |
| + } |
| + if etag1 == "" { |
| + t.Fatal("create-only Put returned no etag") |
| + } |
| + |
| + // Round-trip Get. |
| + body, gotEtag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if string(body) != `{"n":1}` { |
| + t.Errorf("body = %q, want the stored object", body) |
| + } |
| + if gotEtag != etag1 { |
| + t.Errorf("etag = %q, want %q (etags are opaque, passed through unmodified)", gotEtag, etag1) |
| + } |
| + |
| + // Create-only again over an existing object: CAS conflict. |
| + if _, err := s.Put(ctx, key, []byte("again"), ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Errorf("create-only Put over existing object: err = %v, want release.ErrCASConflict", err) |
| + } |
| + |
| + // Replace with a stale etag: CAS conflict. |
| + if _, err := s.Put(ctx, key, []byte("stale"), "0000000000000000000000000000000000000000000000000000000000000000"); !errors.Is(err, release.ErrCASConflict) { |
| + t.Errorf("replace with stale etag: err = %v, want release.ErrCASConflict", err) |
| + } |
| + |
| + // Replace with the fresh etag: succeeds, new etag differs. |
| + etag2, err := s.Put(ctx, key, []byte(`{"n":2}`), gotEtag) |
| + if err != nil { |
| + t.Fatalf("replace Put: %v", err) |
| + } |
| + if etag2 == "" || etag2 == etag1 { |
| + t.Errorf("replace etag = %q, want a new etag (was %q)", etag2, etag1) |
| + } |
| + body, _, err = s.Get(ctx, key) |
| + if err != nil || string(body) != `{"n":2}` { |
| + t.Fatalf("stored body = %q, %v; want {\"n\":2}", body, err) |
| + } |
| +} |
| + |
| +// ---- Get missing -------------------------------------------------------- |
| + |
| +func TestGetMissingIsNotExist(t *testing.T) { |
| + d := newTestDoor(t) |
| + s := d.box(t) |
| + _, _, err := s.Get(context.Background(), "fleets/"+testFleet+"/status/boxes/nosuchbox.json") |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get missing in-scope key: err = %v, want errors.Is(err, os.ErrNotExist)", err) |
| + } |
| +} |
| + |
| +// ---- List / ListDirs ------------------------------------------------------ |
| + |
| +func TestListAndListDirsRoundTrip(t *testing.T) { |
| + d := newTestDoor(t) |
| + seed(t, d.store, "apps/"+testSqid+"/web/channels/stable.json", "{}") |
| + seed(t, d.store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}") |
| + seed(t, d.store, "apps/zz99/web/channels/stable.json", "someone else's") |
| + s := d.box(t) |
| + ctx := context.Background() |
| + |
| + keys, err := s.List(ctx, "apps/") |
| + if err != nil { |
| + t.Fatalf("List: %v", err) |
| + } |
| + want := []string{"apps/" + testSqid + "/web/channels/stable.json", "apps/" + testSqid + "/web/releases/v1/manifest.json"} |
| + if !equalUnordered(keys, want) { |
| + t.Errorf("List(apps/) = %v, want %v (own account only)", keys, want) |
| + } |
| + |
| + dirs, err := s.ListDirs(ctx, "apps/") |
| + if err != nil { |
| + t.Fatalf("ListDirs: %v", err) |
| + } |
| + if !equalUnordered(dirs, []string{"apps/" + testSqid + "/web/"}) { |
| + t.Errorf("ListDirs(apps/) = %v, want [apps/%s/web/]", dirs, testSqid) |
| + } |
| + |
| + // An empty listing is [] on the wire, never null, and this client must |
| + // decode that into a zero-length (possibly nil) slice a caller can range |
| + // over without a nil check. |
| + empty, err := s.List(ctx, "apps/"+testSqid+"/web/nosuchapp/") |
| + if err != nil { |
| + t.Fatalf("List (empty): %v", err) |
| + } |
| + if len(empty) != 0 { |
| + t.Errorf("List of an empty prefix = %v, want empty", empty) |
| + } |
| + |
| + // The door 404s a root listing (aclListScope refuses prefix == "") — |
| + // this client does not special-case it, so List("") surfaces as a plain |
| + // error, not success and not the not-found mapping Get uses. |
| + if _, err := s.List(ctx, ""); err == nil { |
| + t.Error("List(\"\") = nil error, want the door's refusal to surface") |
| + } else if errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("List(\"\") = %v; List has no not-found mapping (only Get does)", err) |
| + } |
| +} |
| + |
| +func equalUnordered(got, want []string) bool { |
| + if len(got) != len(want) { |
| + return false |
| + } |
| + seen := map[string]int{} |
| + for _, g := range got { |
| + seen[g]++ |
| + } |
| + for _, w := range want { |
| + if seen[w] == 0 { |
| + return false |
| + } |
| + seen[w]-- |
| + } |
| + return true |
| +} |
| + |
| +// ---- Delete ----------------------------------------------------------- |
| + |
| +func TestDeleteIsIdempotent(t *testing.T) { |
| + d := newTestDoor(t) |
| + key := "fleets/" + testFleet + "/metrics/boxes/" + testLabel + "/raw/1699.json" |
| + seed(t, d.store, key, "{}") |
| + s := d.box(t) |
| + ctx := context.Background() |
| + |
| + for i := 0; i < 2; i++ { |
| + if err := s.Delete(ctx, key); err != nil { |
| + t.Fatalf("Delete (attempt %d): %v", i+1, err) |
| + } |
| + } |
| + if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get after Delete: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +// TestDeleteWithWrongCredentialErrors pins C1: a 404 on DELETE is only ever |
| +// a refusal (the door answers 204 for every in-scope key, present or |
| +// absent — that's where Delete's idempotency lives). A mis-credentialled |
| +// client must see an error, not silent "success" for a delete that never |
| +// happened. |
| +func TestDeleteWithWrongCredentialErrors(t *testing.T) { |
| + d := newTestDoor(t) |
| + key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" |
| + seed(t, d.store, key, "{}") |
| + |
| + wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") |
| + if err := wrongToken.Delete(context.Background(), key); err == nil { |
| + t.Fatal("Delete with wrong token = nil error, want an error (the key must still exist)") |
| + } |
| + if _, _, err := d.box(t).Get(context.Background(), key); err != nil { |
| + t.Fatalf("the refused Delete removed the object anyway: %v", err) |
| + } |
| + |
| + otherAccountKey := "apps/zz99/web/channels/stable.json" |
| + seed(t, d.store, otherAccountKey, "someone else's") |
| + if err := d.box(t).Delete(context.Background(), otherAccountKey); err == nil { |
| + t.Fatal("Delete of another tenant's key = nil error, want an error") |
| + } |
| + if _, _, err := d.store.Get(context.Background(), otherAccountKey); err != nil { |
| + t.Fatalf("the refused Delete removed another tenant's object anyway: %v", err) |
| + } |
| +} |
| + |
| +// ---- Auth failure is opaque, matching the door ----------------------- |
| + |
| +// TestAuthFailureSurfacesAsNotFound pins the door's central opacity |
| +// invariant on the client side: a bad token answers 404, indistinguishable |
| +// from a missing key, so this client's Get must map it the same way — never |
| +// a distinct "auth failed" error a caller could branch on to learn it has a |
| +// bad credential versus a bad key. |
| +func TestAuthFailureSurfacesAsNotFound(t *testing.T) { |
| + d := newTestDoor(t) |
| + key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" |
| + seed(t, d.store, key, "{}") |
| + |
| + wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") |
| + if _, _, err := wrongToken.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get with wrong token: err = %v, want os.ErrNotExist (opaque, like a missing key)", err) |
| + } |
| + |
| + unknownFleet := d.client(t, "nosuch/box", testToken) |
| + if _, _, err := unknownFleet.Get(context.Background(), key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get from an unregistered fleet: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +// ---- token never leaks into an error string ---------------------------- |
| + |
| +// TestTokenNeverInErrorStrings exercises every failure path this client can |
| +// produce against a live door and checks none of them echo the bearer |
| +// token — the credential itself, not just its hash — into the returned |
| +// error's text. |
| +func TestTokenNeverInErrorStrings(t *testing.T) { |
| + d := newTestDoor(t) |
| + key := "fleets/" + testFleet + "/status/boxes/" + testLabel + ".json" |
| + seed(t, d.store, key, "{}") |
| + ctx := context.Background() |
| + |
| + wrongToken := d.client(t, testFleet+"/"+testLabel, "not-the-real-token") |
| + _, _, getErr := wrongToken.Get(ctx, key) |
| + |
| + s := d.box(t) |
| + _, putErr := s.Put(ctx, key, []byte("x"), "") // CAS conflict: already exists |
| + _, _, missingErr := s.Get(ctx, "fleets/"+testFleet+"/status/boxes/nosuchbox.json") |
| + _, listErr := s.List(ctx, "") |
| + |
| + for name, err := range map[string]error{ |
| + "wrong-token get": getErr, |
| + "cas conflict put": putErr, |
| + "missing get": missingErr, |
| + "refused list": listErr, |
| + } { |
| + if err == nil { |
| + t.Errorf("%s: no error to check (expected one)", name) |
| + continue |
| + } |
| + if strings.Contains(err.Error(), testToken) { |
| + t.Errorf("%s: error %q contains the bearer token", name, err.Error()) |
| + } |
| + if strings.Contains(err.Error(), "not-the-real-token") { |
| + t.Errorf("%s: error %q contains the wrong-token value", name, err.Error()) |
| + } |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 231e0a1e7..23fb23f62 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 |
| @@ -68,6 +72,19 @@ |
| // — an invalid result refuses to arm the wake-time/startup binding |
| // publish (fleetbind.go) loudly, and degrades the heartbeat's label back |
| // to the hashed pseudonym rather than disabling it outright. |
| +// - CARLOS_TUNNEL_URL / CARLOS_TUNNEL_TOKEN — the tunnel env |
| +// tunnel_client.go already dials with, read a SECOND time here (spec |
| +// 2026-08-11-fleet-api-surface, F5) as the fleet store door's address |
| +// and credential: on a fleet box with NO bucket env set, openDeployStore |
| +// and the status/metrics store both become internal/fleetapi clients |
| +// speaking release.Store over the edge's /store/ routes as |
| +// "<fleet>/<label>", so the box needs no AWS credentials on the |
| +// platform's deployment bucket at all. A bucket still set WINS |
| +// everywhere (the migration fallback): every platform box, and any |
| +// grandfathered fleet box that still carries bucket credentials, keeps |
| +// direct S3 byte for byte. Missing tunnel env on a fleet box with no |
| +// bucket is a loud error, not a silent degrade — that box has no way to |
| +// reach a store at all. |
| // - STATUS_BOX_REGION — an operator-set, coarse region word (e.g. |
| // "EU · Ireland") carried into status.Probes.Region and published |
| // verbatim on this box's snapshot; "" omits the field. Deliberately |
| @@ -128,6 +145,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/fleetapi" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -173,11 +191,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) |
| } |
| @@ -1458,6 +1483,19 @@ func instancesLoop(reg *registry.Registry) { |
| // box that cannot verify pointers must not delete anything. |
| func retentionLoop(reg *registry.Registry) { |
| ctx := context.Background() |
| + // Platform boxes only (spec F6). Retention prunes the PLATFORM's release |
| + // objects — a customer's box scheduling deletes of them was already wrong |
| + // under direct S3, and through the store door it is refused outright (the |
| + // door's write/delete ACL scopes a fleet to fleets/<fleet>/**), so arming |
| + // here would buy nothing but an hourly loop of 404s. Reuses |
| + // shouldSkipPublisherElection because the question is exactly "is this a |
| + // fleet box", the same one instancesLoop asks — and it is deliberately the |
| + // bare CARLOS_FLEET test, not resolveFleetIdentity: a fleet box whose |
| + // LABEL is misconfigured is still a fleet box, and must still not prune. |
| + if shouldSkipPublisherElection(os.Getenv) { |
| + log.Printf("agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)") |
| + return |
| + } |
| store, err := openDeployStore(ctx) |
| if err != nil { |
| log.Printf("agent: retention loop disabled: %v", err) |
| @@ -1585,12 +1623,13 @@ func shouldSkipPublisherElection(getenv func(string) string) bool { |
| // other tick failure. |
| func statusLoop(reg *registry.Registry) { |
| ctx := context.Background() |
| + // bucket is read for the lease key alone (lease.Acquire below), which a |
| + // fleet box never reaches: shouldSkipPublisherElection short-circuits the |
| + // election before it, and a fleet box is the only shape that gets a store |
| + // with no bucket behind it (openStatusStore). Every other box resolves a |
| + // real bucket here exactly as before. |
| bucket := resolveStatusBucket(os.Getenv) |
| - if bucket == "" { |
| - log.Printf("agent: status loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set (CARLOS_DEPLOYMENT_DIR deployments don't publish status yet — set CARLOS_STATUS_BUCKET explicitly)") |
| - return |
| - } |
| - 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) |
| @@ -1615,9 +1654,13 @@ func statusLoop(reg *registry.Registry) { |
| log.Printf("agent: %v — heartbeat will publish under the hashed pseudonym instead", err) |
| } |
| - s, err := release.NewS3Store(ctx, bucket) |
| + s, target, err := openStatusStore(ctx, os.Getenv) |
| if err != nil { |
| - log.Printf("agent: status loop disabled: %v", err) |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: status loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set (CARLOS_DEPLOYMENT_DIR deployments don't publish status yet — set CARLOS_STATUS_BUCKET explicitly)") |
| + } else { |
| + log.Printf("agent: status loop disabled: %v", err) |
| + } |
| return |
| } |
| // store is what statusTick actually reads/writes through: identical to s |
| @@ -1626,12 +1669,13 @@ func statusLoop(reg *registry.Registry) { |
| // release.NewPrefixStore and the package doc comment's |
| // CARLOS_STATUS_PREFIX entry. |
| store := release.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 |
| - // to a bucket the edge/console never look at — this line is the one |
| - // place an operator can confirm which bucket actually won. |
| - log.Printf("agent: status loop armed (%s + jitter), publishing to bucket %s", status.SnapshotPeriod, bucket) |
| + // The effective destination 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 to a bucket the edge/console never look at — this line is the |
| + // one place an operator can confirm which bucket (or, on a bucketless |
| + // fleet box, which door identity) actually won. |
| + log.Printf("agent: status loop armed (%s + jitter), publishing to %s", status.SnapshotPeriod, target) |
| // canaryStore/canaryPub resolve the release deployment config |
| // countExpiredCanaries needs to answer §5's "how many canaries on this |
| @@ -1645,7 +1689,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 |
| @@ -1956,19 +2009,142 @@ func bounceFn(svc *activator.Service) func(host string) bool { |
| } |
| // openDeployStore opens the release store the box adopts from: |
| -// CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (a directory — |
| -// tests and single-box no-AWS deployments). Mirrors cli.go's openStore, but |
| -// returns the error instead of dying: the agent degrades, the CLI exits. |
| +// CARLOS_DEPLOYMENT_BUCKET (S3), CARLOS_DEPLOYMENT_DIR (a directory — tests |
| +// and single-box no-AWS deployments), or — on a fleet box with neither set — |
| +// the edge's store door over the tunnel env (spec F5, openFleetDoorStore). |
| +// Mirrors cli.go's openStore, but returns the error instead of dying: the |
| +// agent degrades, the CLI exits. |
| func openDeployStore(ctx context.Context) (release.Store, error) { |
| - if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| + return openDeployStoreEnv(ctx, os.Getenv) |
| +} |
| + |
| +// openDeployStoreEnv is openDeployStore with the environment injected — the |
| +// same getenv seam resolveStatusBucket already uses in this file, so the |
| +// selection rule can be tested without mutating process-wide env. |
| +// |
| +// Order is the whole contract (spec F5): a bucket or dir still set WINS, |
| +// before fleet identity is even consulted, so every platform box and every |
| +// grandfathered fleet box that still has bucket credentials keeps exactly |
| +// today's store, byte for byte. Only a box that resolves a fleet identity |
| +// AND has no bucket/dir at all reaches the door — that is the cutover |
| +// switch, per box, no flag day: drop the bucket var and the box is on the |
| +// API; put it back and it is off. |
| +func openDeployStoreEnv(ctx context.Context, getenv func(string) string) (release.Store, error) { |
| + if b := getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| return release.NewS3Store(ctx, b) |
| } |
| - if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| + if d := getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| return release.NewDirStore(d), nil |
| } |
| + identity, err := resolveFleetIdentity(getenv) |
| + switch { |
| + case err == nil: |
| + return openFleetDoorStore(getenv, identity) |
| + case !errors.Is(err, errNotFleetBox): |
| + // CARLOS_FLEET is set but the identity is unusable, and there is no |
| + // bucket to fall back to: this box has no store at all. Return the |
| + // identity error itself rather than the generic "no bucket" line — |
| + // the actual fix is the label, and the generic message would send an |
| + // operator hunting for a bucket var that is deliberately absent. |
| + return nil, err |
| + } |
| return nil, errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") |
| } |
| +// openFleetDoorStore builds the fleetapi client a bucketless fleet box adopts |
| +// through: the edge origin it already tunnels to (CARLOS_TUNNEL_URL), its |
| +// channel identity ("<fleet>/<label>" — the same string tunnel_client.go |
| +// registers with and edge.fleetChannelFor dials), and the tunnel bearer token. |
| +// |
| +// Missing tunnel env here is an ERROR, never a silent no-store: the caller |
| +// established that this box is a fleet box with no bucket, so there is no |
| +// other store it could possibly be meant to use, and a quiet nil would leave |
| +// every loop in this file reporting "not configured" about a box that is in |
| +// fact configured — just with one variable missing. |
| +func openFleetDoorStore(getenv func(string) string, identity FleetIdentity) (release.Store, error) { |
| + base := strings.TrimSpace(getenv("CARLOS_TUNNEL_URL")) |
| + token := strings.TrimSpace(getenv("CARLOS_TUNNEL_TOKEN")) |
| + name := identity.Fleet + "/" + identity.Label |
| + if base == "" || token == "" { |
| + return nil, fmt.Errorf("fleet box %s has no deployment bucket and cannot reach the store door: both CARLOS_TUNNEL_URL and CARLOS_TUNNEL_TOKEN must be set", name) |
| + } |
| + 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 |
| +// unconfigured case while sharing one selection rule. |
| +var errNoStatusStore = errors.New("no status store configured") |
| + |
| +// openStatusStore resolves the store statusLoop and metricsLoop publish |
| +// through, plus a human phrase naming where that is for their armed log |
| +// lines. Same F5 rule as openDeployStoreEnv, read through |
| +// resolveStatusBucket: an explicit CARLOS_STATUS_BUCKET (else the |
| +// CARLOS_DEPLOYMENT_BUCKET fallback) WINS and keeps direct S3; only a fleet |
| +// box with neither set speaks the door. |
| +// |
| +// LEDGER NOTE (spec F5): the door serves the DEPLOYMENT store. A fleet box |
| +// that sets CARLOS_STATUS_BUCKET to some OTHER bucket therefore keeps direct |
| +// S3 for status/metrics while its releases may come through the door — that |
| +// is the bucket-set-wins rule doing its job, not a bug: an operator who named |
| +// a bucket explicitly gets that bucket. |
| +// |
| +// The CARLOS_STATUS_PREFIX rebase both loops apply (release.NewPrefixStore) |
| +// is deliberately NOT applied here — it stays at each call site, unchanged, |
| +// and wraps whichever store this returns. |
| +func openStatusStore(ctx context.Context, getenv func(string) string) (release.Store, string, error) { |
| + if bucket := resolveStatusBucket(getenv); bucket != "" { |
| + s, err := release.NewS3Store(ctx, bucket) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + return s, "bucket " + bucket, nil |
| + } |
| + identity, err := resolveFleetIdentity(getenv) |
| + switch { |
| + case err == nil: |
| + s, derr := openFleetDoorStore(getenv, identity) |
| + if derr != nil { |
| + return nil, "", derr |
| + } |
| + return s, "the fleet store door as " + identity.Fleet + "/" + identity.Label, nil |
| + case !errors.Is(err, errNotFleetBox): |
| + return nil, "", err |
| + } |
| + return nil, "", errNoStatusStore |
| +} |
| + |
| // RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the |
| // carlos-adopt.timer unit's ExecStart, and an operator's "converge now". |
| // Runs as root (see carlos-adopt.service): this is the ONLY place the |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index 682ae7181..19783582a 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -7,12 +7,15 @@ import ( |
| "encoding/base64" |
| "errors" |
| "log" |
| + "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "runtime" |
| "slices" |
| "sort" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -1199,6 +1202,425 @@ func TestFastSweepBouncesAWarmHibernatingCanary(t *testing.T) { |
| } |
| } |
| +// ---- store selection: bucket, dir, or the fleet store door (spec F5) ------- |
| + |
| +// doorHit is one request the fake door saw, reduced to the parts these tests |
| +// reason about: which call it was, and the two headers that prove WHICH box |
| +// made it (fleetapi sends the tunnel token and channel name on every request). |
| +type doorHit struct{ Method, Path, Auth, Name string } |
| + |
| +// doorFake stands in for the edge's store door. It answers the single GET |
| +// these tests drive and records every request, so a test can assert both |
| +// halves of the F5 rule: that a bucketless fleet box's store really does |
| +// speak to the door under its channel identity, and — the regression pin that |
| +// matters more — that a bucket-configured box never sends it anything. |
| +type doorFake struct { |
| + url string |
| + mu sync.Mutex |
| + hits []doorHit |
| +} |
| + |
| +func newDoorFake(t *testing.T) *doorFake { |
| + t.Helper() |
| + d := &doorFake{} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + d.mu.Lock() |
| + d.hits = append(d.hits, doorHit{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Carlos-Tunnel-Name")}) |
| + d.mu.Unlock() |
| + if r.Method == http.MethodGet && r.URL.Path == "/store/o/control/serial" { |
| + w.Header().Set("ETag", `"e1"`) |
| + _, _ = w.Write([]byte("42")) |
| + return |
| + } |
| + http.NotFound(w, r) |
| + })) |
| + t.Cleanup(srv.Close) |
| + d.url = srv.URL |
| + return d |
| +} |
| + |
| +func (d *doorFake) seen() []doorHit { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + return append([]doorHit(nil), d.hits...) |
| +} |
| + |
| +// fleetDoorEnv is the env a fleet box that has been CUT OVER carries: fleet |
| +// identity plus tunnel address/credential, and deliberately no bucket and no |
| +// AWS credentials at all — the whole point of the door. |
| +func fleetDoorEnv(doorURL string) map[string]string { |
| + return map[string]string{ |
| + "CARLOS_FLEET": "pi", |
| + "CARLOS_FLEET_BOX": "pi-box", |
| + "CARLOS_TUNNEL_URL": doorURL, |
| + "CARLOS_TUNNEL_TOKEN": "tok-123", |
| + } |
| +} |
| + |
| +// awsEnvForS3Store makes release.NewS3Store constructible in a test without |
| +// network or a developer's real profile — the same shape internal/release's |
| +// own S3 tests use. Nothing is ever called on the resulting client here; these |
| +// tests only assert which store TYPE the selection built. |
| +func awsEnvForS3Store(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("AWS_ACCESS_KEY_ID", "k") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "s") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_REGION", "eu-west-1") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| +} |
| + |
| +// TestOpenDeployStoreSelection pins spec F5's whole rule for the store every |
| +// converge loop on the box reads through. The migration fallback is the |
| +// load-bearing half: a bucket or dir still set must win OUTRIGHT, before fleet |
| +// identity is even consulted, or rolling this branch out would silently move |
| +// every already-working fleet box onto a door it may not be able to reach. |
| +func TestOpenDeployStoreSelection(t *testing.T) { |
| + ctx := context.Background() |
| + dir := t.TempDir() |
| + |
| + t.Run("fleet box with no bucket speaks the door", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + s, err := openDeployStoreEnv(ctx, envFunc(fleetDoorEnv(door.url))) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + body, etag, err := s.Get(ctx, "control/serial") |
| + if err != nil { |
| + t.Fatalf("Get through the door: %v", err) |
| + } |
| + if string(body) != "42" || etag != `"e1"` { |
| + t.Fatalf("Get = %q, %q; want \"42\", \"e1\"", body, etag) |
| + } |
| + hits := door.seen() |
| + if len(hits) != 1 { |
| + t.Fatalf("door saw %d requests, want 1: %+v", len(hits), hits) |
| + } |
| + if hits[0].Path != "/store/o/control/serial" { |
| + t.Fatalf("door path = %q, want /store/o/control/serial", hits[0].Path) |
| + } |
| + // The identity on the wire is the channel identity the edge dials — |
| + // "<fleet>/<label>", never the hashed status pseudonym. |
| + if hits[0].Name != "pi/pi-box" { |
| + t.Fatalf("Carlos-Tunnel-Name = %q, want pi/pi-box", hits[0].Name) |
| + } |
| + if hits[0].Auth != "Bearer tok-123" { |
| + t.Fatalf("Authorization = %q, want the tunnel token", hits[0].Auth) |
| + } |
| + }) |
| + |
| + t.Run("dir set wins on a fleet box, door untouched", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_DIR"] = dir |
| + s, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.DirStore); !ok { |
| + t.Fatalf("store = %T, want *release.DirStore — a configured dir must win", s) |
| + } |
| + if _, _, err := s.Get(ctx, "control/serial"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get = %v, want the dir store's not-exist", err) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests — a dir-configured box must never reach it", hits) |
| + } |
| + }) |
| + |
| + t.Run("bucket set wins on a fleet box, door untouched", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy" |
| + s, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store — a configured bucket must win", s) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests — a bucket-configured box must never reach it", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box is unchanged", func(t *testing.T) { |
| + s, err := openDeployStoreEnv(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": dir})) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.DirStore); !ok { |
| + t.Fatalf("store = %T, want *release.DirStore", s) |
| + } |
| + _, err = openDeployStoreEnv(ctx, envFunc(map[string]string{})) |
| + if err == nil || !strings.Contains(err.Error(), "neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") { |
| + t.Fatalf("unconfigured platform box err = %v, want today's message verbatim", err) |
| + } |
| + }) |
| + |
| + t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) { |
| + for _, missing := range []string{"CARLOS_TUNNEL_URL", "CARLOS_TUNNEL_TOKEN"} { |
| + env := fleetDoorEnv("https://tunnel.example.com") |
| + delete(env, missing) |
| + _, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err == nil { |
| + t.Fatalf("missing %s: want an error, got a store", missing) |
| + } |
| + if !strings.Contains(err.Error(), "pi/pi-box") || !strings.Contains(err.Error(), missing) { |
| + t.Fatalf("missing %s: err = %v, want it to name the box and the variable", missing, err) |
| + } |
| + // The token must never be quoted back into an error a log line or |
| + // a status page could carry. |
| + if strings.Contains(err.Error(), "tok-123") { |
| + t.Fatalf("err leaks the tunnel token: %v", err) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("unusable fleet identity with no bucket names the identity", func(t *testing.T) { |
| + _, err := openDeployStoreEnv(ctx, envFunc(map[string]string{ |
| + "CARLOS_FLEET": "pi", |
| + "CARLOS_TUNNEL_NAME": "legacy-box", // no "pi/" prefix: underivable label |
| + "CARLOS_TUNNEL_URL": "https://tunnel.example.com", |
| + "CARLOS_TUNNEL_TOKEN": "tok-123", |
| + })) |
| + if err == nil || !strings.Contains(err.Error(), "CARLOS_TUNNEL_NAME") { |
| + t.Fatalf("err = %v, want the identity error, not the generic no-bucket line", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestOpenStatusStoreSelection pins the same rule for the status/metrics |
| +// store, including the ledger call it makes deliberately: the door serves the |
| +// DEPLOYMENT store, so a fleet box that names its own CARLOS_STATUS_BUCKET |
| +// keeps direct S3 for status — bucket-set-wins, on purpose. |
| +func TestOpenStatusStoreSelection(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("fleet box with no bucket speaks the door", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + s, target, err := openStatusStore(ctx, envFunc(fleetDoorEnv(door.url))) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, "control/serial"); err != nil { |
| + t.Fatalf("Get through the door: %v", err) |
| + } |
| + if hits := door.seen(); len(hits) != 1 || hits[0].Name != "pi/pi-box" { |
| + t.Fatalf("door hits = %+v, want one request as pi/pi-box", hits) |
| + } |
| + if !strings.Contains(target, "pi/pi-box") { |
| + t.Fatalf("armed-log target = %q, want it to name the door identity", target) |
| + } |
| + }) |
| + |
| + t.Run("status bucket set on a fleet box keeps direct S3", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_STATUS_BUCKET"] = "carlos-status" |
| + s, target, err := openStatusStore(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store", s) |
| + } |
| + if target != "bucket carlos-status" { |
| + t.Fatalf("target = %q, want %q", target, "bucket carlos-status") |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests", hits) |
| + } |
| + }) |
| + |
| + t.Run("deployment bucket fallback still applies", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy" |
| + s, target, err := openStatusStore(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store", s) |
| + } |
| + if target != "bucket carlos-deploy" { |
| + t.Fatalf("target = %q, want %q", target, "bucket carlos-deploy") |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box with no bucket stays unconfigured", func(t *testing.T) { |
| + _, _, err := openStatusStore(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": t.TempDir()})) |
| + if !errors.Is(err, errNoStatusStore) { |
| + t.Fatalf("err = %v, want errNoStatusStore — a DIR deployment publishes no status", err) |
| + } |
| + }) |
| + |
| + t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) { |
| + env := fleetDoorEnv("https://tunnel.example.com") |
| + delete(env, "CARLOS_TUNNEL_TOKEN") |
| + _, _, err := openStatusStore(ctx, envFunc(env)) |
| + if err == nil || errors.Is(err, errNoStatusStore) { |
| + t.Fatalf("err = %v, want a loud construction error, not the unconfigured sentinel", err) |
| + } |
| + }) |
| +} |
| + |
| +// 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 |
| +// fleet box must not arm the loop at all — one log line, then out, before it |
| +// ever opens a store. |
| +func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| + const skipLine = "agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)" |
| + |
| + t.Run("fleet box skips before arming", func(t *testing.T) { |
| + logs := captureLog(t) |
| + door := newDoorFake(t) |
| + for k, v := range fleetDoorEnv(door.url) { |
| + t.Setenv(k, v) |
| + } |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + |
| + 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) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero — the skip must land BEFORE the store opens", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box still arms", func(t *testing.T) { |
| + pub, _, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + t.Setenv("CARLOS_FLEET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + |
| + // retentionLoop never returns once armed, so the arming log line is |
| + // what proves it: run it in its own goroutine (it parks in a |
| + // time.Sleep for a minute, long past the end of this test) against a |
| + // synchronized sink, and wait for the line to appear. |
| + var mu sync.Mutex |
| + var buf bytes.Buffer |
| + old, flags := log.Writer(), log.Flags() |
| + log.SetOutput(writerFunc(func(p []byte) (int, error) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + return buf.Write(p) |
| + })) |
| + log.SetFlags(0) |
| + t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) }) |
| + |
| + // 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 { |
| + mu.Lock() |
| + got := buf.String() |
| + mu.Unlock() |
| + if strings.Contains(got, "agent: retention loop armed") { |
| + if strings.Contains(got, skipLine) { |
| + t.Fatalf("platform box took the fleet skip: %q", got) |
| + } |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("retention loop never armed on a platform box; log = %q", got) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + }) |
| +} |
| + |
| +// writerFunc adapts a func to io.Writer — the log sink above needs its own |
| +// mutex because the loop under test keeps running (and may keep logging) |
| +// after the test returns, which a bare bytes.Buffer would race on. |
| +type writerFunc func(p []byte) (int, error) |
| + |
| +func (f writerFunc) Write(p []byte) (int, error) { return f(p) } |
| // ---- stamped units for the metrics sweep (#110 column) ---------------------- |
| // TestStampedUnitNamesDedupesAndSkipsUnstamped pins the metrics sweep's one |
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 9d6e26add..f3ffc1296 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -766,14 +766,16 @@ func metricsLoop(execRoot string, stampedUnits func() []string) { |
| }() |
| ctx := context.Background() |
| - bucket := resolveStatusBucket(os.Getenv) |
| - if bucket == "" { |
| - log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| - return |
| - } |
| - raw, err := release.NewS3Store(ctx, bucket) |
| + // Same selection rule statusLoop uses, in one place for both (agent.go's |
| + // openStatusStore): a status/deployment bucket wins and keeps direct S3; |
| + // a fleet box with neither set publishes through the edge's store door. |
| + raw, target, err := openStatusStore(ctx, os.Getenv) |
| if err != nil { |
| - log.Printf("agent: metrics loop disabled: %v", err) |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| + } else { |
| + log.Printf("agent: metrics loop disabled: %v", err) |
| + } |
| return |
| } |
| // s is what metricsFlush/alertTick actually read/write through: the same |
| @@ -785,8 +787,11 @@ func metricsLoop(execRoot string, stampedUnits func() []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") |
| @@ -795,7 +800,7 @@ func metricsLoop(execRoot string, stampedUnits func() []string) { |
| logRejectedAlertOverrides(os.Getenv) |
| webhookURL := os.Getenv("CARLOS_ALERT_WEBHOOK") |
| - log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket) |
| + log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to %s", target) |
| sm := &sampler{} |
| probes := realSampleProbes(execRoot, stampedUnits) |