carlos / platform Public

Merge pull request #141 from carlosframework/worktree-fleets-v2

Fleets console v2: the Fleet tab, tags, placement moves, fleet fast-ping
Paul Campbell 341f874a9663b51692bcbd98058b207e4a254c4a parent 6629c52parent 9cbc67a
24 files changed, +2985 −92
  • apimode.go +18 −0
  • docs/superpowers/plans/2026-08-10-fleets-console.md +489 −0
  • docs/superpowers/specs/2026-08-10-fleets-console-design.md +162 −0
  • fleets.go +79 −6
  • fleets_cli_test.go +98 −3
  • infra/dev/region-pi.md +6 −0
  • internal/console/accountfleet.go +223 −0
  • internal/console/accountfleet_test.go +394 −0
  • internal/console/apicli.go +2 −2
  • internal/console/authcli.go +1 −0
  • internal/console/fleets.go +368 −55
  • internal/console/fleets_test.go +631 −0
  • internal/console/server.go +56 −2
  • internal/console/templates/account_fleet.html +46 −0
  • internal/console/templates/app_instances.html +20 −0
  • internal/console/templates/fleet_byo.html +61 −0
  • internal/console/templates/fleetdetail.html +22 −1
  • internal/console/templates/layout.html +1 −0
  • internal/edge/fleet_e2e_test.go +11 −10
  • internal/edge/tunnel.go +37 −5
  • internal/edge/tunnel_test.go +63 −0
  • internal/registry/fleets.go +66 −5
  • internal/registry/fleets_test.go +127 −0
  • main.go +4 −3
diff --git a/apimode.go b/apimode.go
index 340e476c2..a6b106a5c 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1300,6 +1300,7 @@ func fleetPath(a apiAcct, tail string) string {
type apiFleetBox struct {
Label string `json:"label"`
Status string `json:"status"`
+ Tags string `json:"tags,omitempty"`
}
type apiFleetView struct {
@@ -1396,6 +1397,23 @@ func (s *apiSession) rotateFleetBoxToken(ctx context.Context, a apiAcct, fleet,
return res.Token, nil
}
+// tagFleetBox replaces a box's tag list IN FULL — `carlos fleets tag-box`.
+// tags "" clears every tag; there is no merge form.
+func (s *apiSession) tagFleetBox(ctx context.Context, a apiAcct, fleet, label, tags string) error {
+ in := struct {
+ Tags string `json:"tags"`
+ }{tags}
+ status, body, err := s.postJSON(ctx,
+ fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/tags"), in)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
// detachFleetBox flips a box to "detached" — the hard revocation rotation is
// not: the edge closes an already-connected channel too
// (handleAPIFleetDetachBox). `carlos fleets detach-box`.
diff --git a/docs/superpowers/plans/2026-08-10-fleets-console.md b/docs/superpowers/plans/2026-08-10-fleets-console.md
new file mode 100644
index 000000000..0b75318de
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-fleets-console.md
@@ -0,0 +1,489 @@
+# Fleets Console v2 Implementation Plan
+
+> **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:** The account-level Fleet tab (shared-fleet card, fleets index, BYO instructions), fleet-box tags, console placement moves, and the fleet-channel fast-ping — implementing `docs/superpowers/specs/2026-08-10-fleets-console-design.md` (D1–D8) on branch `worktree-fleets-v2`, stacked on PR #63.
+
+**Architecture:** Everything stacks on #63's fleets machinery. New console surface follows the redesign conventions (accountPage/appPage, `{{.Nav}}` links, `{{.Base}}` forms, gate order session → CSRF → owner-404). One additive registry column (`fleet_boxes.tags`). The edge change is confined to dynamic (fleet) channels' ping cadence.
+
+**Tech Stack:** Go 1.x, modernc sqlite, html/template, x/net/http2. No new dependencies.
+
+## Global Constraints
+
+- Branch `worktree-fleets-v2`; base is #63's tip (`6629c52`). Never touch `main`.
+- Gate before every commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all 20 packages), `gofmt -l` clean on touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need sandbox disabled.
+- Wire/storage shapes are **additive only**: pre-change registry rows and JSON responses must parse unchanged.
+- Console security gate order: session → CSRF → owner (byte-identical 404) — `requireFreshAuth` last. Unknown fleet and other-account fleet answer identical `http.NotFound`.
+- Tag grammar (spec): comma-separated, each tag `^[a-z0-9-]{1,24}$`, max 8 tags, empty string = untagged. Reject, don't sanitize.
+- Fleet ping cadence (spec D7): dynamic channels ping every 5s, timeout 5s; static channels keep 15s/10s.
+- Match surrounding comment density and idiom; icons are vendored Lucide, emoji never chrome.
+
+---
+
+### Task 1: Registry — `fleet_boxes.tags` (additive column + accessors)
+
+**Files:**
+- Modify: `internal/registry/fleets.go` (migrateFleets ~L74, FleetBox struct ~L40, ListFleetBoxes ~L344; add ValidBoxTags + SetFleetBoxTags beside SetFleetBoxToken ~L289)
+- Test: `internal/registry/fleets_test.go`
+
+**Interfaces:**
+- Produces: `FleetBox.Tags string` (comma form, storage-identical); `func ValidBoxTags(tags string) bool`; `func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error` (bumps `rev`; refuses invalid grammar and unknown (fleet,label)).
+- Consumes: existing `migrateFleets`, `freshFleetsConn`, `ValidTunnelName` patterns in the same file.
+
+- [ ] **Step 1: Write the failing tests** in `internal/registry/fleets_test.go` (same package/test style as the existing fleet tests there):
+
+```go
+func TestFleetBoxTags(t *testing.T) {
+ r := openTestRegistry(t) // reuse this file's existing constructor helper (whatever name it uses)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a") // reuse/add tiny helpers mirroring existing tests
+ // grammar
+ for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b",
+ "a,b,c,d,e,f,g,h,i"} {
+ if ValidBoxTags(bad) {
+ t.Errorf("ValidBoxTags(%q) = true, want false", bad)
+ }
+ }
+ for _, good := range []string{"", "office", "office,free", "a-1"} {
+ if !ValidBoxTags(good) {
+ t.Errorf("ValidBoxTags(%q) = false, want true", good)
+ }
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil {
+ t.Fatal("invalid grammar accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil {
+ t.Fatal("unknown box accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil {
+ t.Fatal(err)
+ }
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("tags round-trip: %+v err=%v", boxes, err)
+ }
+ // clearing
+ if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFleetBoxTagsFingerprintMoves(t *testing.T) {
+ // mirror the existing fingerprint test for token writes in this file:
+ // snapshot Fingerprint(), SetFleetBoxTags, assert Fingerprint() changed.
+}
+
+func TestFleetBoxesPreTagsRowsParse(t *testing.T) {
+ // additive-shape: build a registry, then simulate a pre-change row by
+ // creating the table WITHOUT tags in a scratch db file, running
+ // migrate via NewRegistry over it, and asserting ListFleetBoxes returns
+ // Tags == "" with no error. Mirror however the existing routes
+ // pool/region additive tests do this (see registry_test.go).
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/registry/ -run TestFleetBoxTags -count=1` → compile error (`ValidBoxTags` undefined).
+
+- [ ] **Step 3: Implement.** In `migrateFleets`, after the two CREATEs, add the additive column exactly the way `registry.go` adds `routes.pool`/`routes.region` (grep `ALTER TABLE routes ADD COLUMN` and copy that ignore-duplicate-error idiom):
+
+```go
+// tags: additive (2026-08-10-fleets-console-design.md D3). Pre-change rows
+// read as '' — untagged.
+r.sql.Exec(`ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT ''`)
+```
+
+Add to `FleetBox`: `Tags string // comma-separated, ValidBoxTags grammar; "" = untagged`. Extend `ListFleetBoxes`'s SELECT + scan with `tags`. Add:
+
+```go
+var boxTagRe = regexp.MustCompile(`^[a-z0-9-]{1,24}$`)
+
+// ValidBoxTags reports whether tags is a well-formed comma-separated tag
+// list: each tag boxTagRe, at most 8, no empty segments. "" is valid and
+// means untagged.
+func ValidBoxTags(tags string) bool {
+ if tags == "" {
+ return true
+ }
+ parts := strings.Split(tags, ",")
+ if len(parts) > 8 {
+ return false
+ }
+ for _, p := range parts {
+ if !boxTagRe.MatchString(p) {
+ return false
+ }
+ }
+ return true
+}
+
+// SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same
+// RowsAffected guard for an unknown (fleet, label).
+func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error { ... }
+```
+
+`SetFleetBoxTags` body: copy `SetFleetBoxToken`'s shape verbatim (UPDATE … SET tags=?, rev=rev+1 WHERE fleet=? AND label=?; RowsAffected==0 → error), swapping the validation to `ValidBoxTags`.
+
+- [ ] **Step 4: Run** `go test ./internal/registry/ -count=1` → PASS.
+- [ ] **Step 5: Commit** — `git add internal/registry && git commit -m "registry: fleet_boxes.tags — additive column, grammar, rev-bumped writer"`
+
+---
+
+### Task 2: Fleet API + CLI — tags surface (`carlos fleets tag-box`, ls column)
+
+**Files:**
+- Modify: `internal/console/fleets.go` (fleetBoxView + handleAPIFleetList ~L211; new handleAPIFleetTagBox beside handleAPIFleetRotateBox ~L312), `internal/console/authcli.go` (register the door beside the rotate/detach registrations), `fleets.go` (repo root — RunFleets dispatch ~L85, new runFleetsTagBox, runFleetsList ~L278)
+- Test: `internal/console/fleets_test.go` (or wherever the existing fleet API door tests live — find `handleAPIFleetRotateBox` tests and sit beside them), `fleets_cli_test.go` if CLI-level tests exist for fleets verbs (check `cli_test.go` for the fleets pattern; if the existing verbs have no CLI-level tests, API-level tests suffice — do not invent a new harness).
+
+**Interfaces:**
+- Consumes: Task 1's `ValidBoxTags`, `SetFleetBoxTags`, `FleetBox.Tags`.
+- Produces: `POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags` accepting `{"tags":"office,free"}` (owner bearer auth like rotate; 400 on grammar, 404 on unknown box — same shapes as rotate); `fleetBoxView.Tags string` (`json:"tags,omitempty"` — additive); CLI verb `carlos fleets tag-box <fleet> <label> -tags office,free` (full replace, `-tags ""` clears) and a TAGS column in `fleets ls`.
+
+- [ ] **Step 1: Failing API test**, modeled exactly on the existing rotate-box door test:
+
+```go
+func TestAPIFleetTagBox(t *testing.T) {
+ // arrange: server + account + fleet "pi" + box "box-a" (copy the rotate test's arrange)
+ // act: POST /api/cli/fleets/{acct}/pi/boxes/box-a/tags body {"tags":"office,free"}
+ // assert: 200; ListFleetBoxes shows Tags "office,free";
+ // GET /api/cli/fleets/{acct} body contains "tags":"office,free"
+ // bad grammar {"tags":"Office"} → 400
+ // unknown label → 404, byte-identical to rotate's unknown-label 404
+ // other account's fleet → 404 (apiFleet already gives this — assert anyway)
+}
+```
+
+- [ ] **Step 2: Verify failure** — `go test ./internal/console/ -run TestAPIFleetTagBox -count=1` → 404 (route unregistered).
+- [ ] **Step 3: Implement.** Handler is a trimmed copy of `handleAPIFleetRotateBox` (same `apiFleet` gate + `findFleetBox`), ending in `SetFleetBoxTags` + `s.audit(acct, "", addr, "fleet", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)")` + `writeJSON(w, http.StatusOK, map[string]any{})`. Validate `registry.ValidBoxTags` before touching the registry → `jsonError(w, http.StatusBadRequest, "invalid tags — comma-separated [a-z0-9-], max 8")`. Extend `fleetBoxView` with `Tags string \`json:"tags,omitempty"\`` and populate in `handleAPIFleetList`.
+- [ ] **Step 4: CLI.** In root `fleets.go`: add `case "tag-box":` to `RunFleets`; `runFleetsTagBox` copies `runFleetsRotateToken`'s flag/arg shape plus `-tags` string flag (NOTE: distinguish "flag absent" from `-tags ""` is unnecessary — the verb requires `-tags`, and `""` clears). `runFleetsList`: add TAGS to the header and row printf (empty prints as `-`, matching the file's existing column style if it has one; otherwise print raw).
+- [ ] **Step 5: Gate + commit** — full `go build ./... && go test ./... -count=1`; `git commit -m "fleets: tag-box — API door, CLI verb, ls column"`
+
+---
+
+### Task 3: Fleet detail page — tags chips + owner edit
+
+**Files:**
+- Modify: `internal/console/templates/fleetdetail.html` (Boxes table), `internal/console/fleets.go` (`fleetPageBox` ~L626, `loadFleetPage` ~L721; new `handleFleetTagBoxPage` beside `handleFleetRotateBoxPage` ~L837), `internal/console/server.go` (route registration beside L341-342)
+- Test: beside the existing fleet-page tests (find `handleFleetPage`/rotate-page tests in `internal/console`)
+
+**Interfaces:**
+- Consumes: Task 1's accessors; existing `ownerFleet` gate + flash pattern from `handleFleetRotateBoxPage`.
+- Produces: `POST /a/{sqid}/fleets/{fleet}/tags` (form fields `csrf`, `label`, `tags`), redirect back with flash; `fleetPageBox.Tags []string` (split for chips).
+
+- [ ] **Step 1: Failing tests** — extend the fleet-page render test: box with tags shows each tag; POST tags as owner updates + redirects with flash; POST as plain member 403/404s exactly like rotate does; bad grammar → flash error, nothing written.
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** `fleetPageBox` gains `Tags []string`; `loadFleetPage` fills it (`strings.Split` on non-empty). Handler copies `handleFleetRotateBoxPage`'s gate/flash/redirect skeleton, calling `SetFleetBoxTags`. Template: in the Boxes table add a `Tags` column —
+
+```html
+<td>{{range .Tags}}<span class="chip">{{.}}</span> {{end}}{{if not .Tags}}—{{end}}</td>
+```
+
+and, in the owner cell, a third inline form:
+
+```html
+<form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/tags">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="label" value="{{.Label}}">
+ <input type="text" name="tags" value="{{join .Tags ","}}" size="14" placeholder="office,free">
+ <button type="submit" class="linkish">Tag</button>
+</form>
+```
+
+(If the template FuncMap has no `join`, add it in server.go's template setup beside the existing helpers — `"join": strings.Join`.) Reuse the existing `.chip` CSS class; add nothing new to layout.html unless `.chip` doesn't exist (it does — `chip chip-down` is used in this very template).
+- [ ] **Step 4: Gate.** — full test run.
+- [ ] **Step 5: Commit** — `git commit -m "console: fleet page tags — chips + owner edit form"`
+
+---
+
+### Task 4: The Fleet tab — nav, page, shared-fleet card, fleets index
+
+**Files:**
+- Create: `internal/console/accountfleet.go`, `internal/console/accountfleet_test.go`, `internal/console/templates/account_fleet.html`
+- Modify: `internal/console/server.go` (route beside L313; `page` struct fields near the fleet fields ~L528; ActiveNav comment L443), `internal/console/templates/layout.html` (account nav L316-318)
+
+**Interfaces:**
+- Consumes: `FleetView(ctx, s.cfg.Releases)` (view.go:597, reads `status/fleet.json` → `*status.Fleet` with `.Boxes[].Region`), `reg.ListFleets()`/`ListFleetBoxes`, `loadFleetPage` (fleets.go:721) for per-fleet heartbeat freshness, `registry.FleetPlacement`, `s.accountPage` chrome (server.go), `fleetPageURL` (fleets.go:826).
+- Produces: `GET /a/{sqid}/fleet` (`ActiveNav: "fleet"`), template `account_fleet`, view types `sharedFleetCard{Title string; Active bool; Regions []string; Boxes int; PlatformApps int}` and `accountFleetCard{Name string; Boxes, Reachable, Apps int}`; page fields `SharedFleet *sharedFleetCard`, `AccountFleets []accountFleetCard`. Task 5 hangs its BYO link off this page.
+
+- [ ] **Step 1: Failing tests** in `accountfleet_test.go` (copy the arrange style of the fleet-page tests; the console test harness has fixtures for registry + fake release store):
+
+```go
+// renders for owner AND plain member; non-member gets the account gate's usual answer
+// shared card: with a fixture status/fleet.json of two boxes (regions "EU · Ireland",
+// "UK · London") → Regions == both, Boxes == 2
+// Active flips: account route with Region "" → Active; all routes fleet-placed → not Active
+// fleets index: two fleets owned + one other-account fleet → only the two, each with
+// box counts; page links to /a/<seg>/fleets/<name>
+// degraded: no fleet.json in store → page still 200, shared card renders "no boxes yet"
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement `assembleAccountFleet`** in `accountfleet.go` — the `overview.go` posture: every read soft-fails to an empty section, never a 500.
+
+```go
+// assembleAccountFleet builds the Fleet tab's two halves: the shared
+// platform fleet as a PRESENTATION of status/fleet.json (D2 — deliberately
+// not a registry object), and the account's own fleets from the registry.
+func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*sharedFleetCard, []accountFleetCard, string) {
+ card := &sharedFleetCard{Title: "Shared fleet"}
+ if s.cfg.Releases != nil {
+ if f, err := FleetView(ctx, s.cfg.Releases); err == nil && f != nil {
+ card.Boxes = len(f.Boxes)
+ seen := map[string]bool{}
+ for _, b := range f.Boxes {
+ if b.Region != "" && !seen[b.Region] {
+ seen[b.Region] = true
+ card.Regions = append(card.Regions, b.Region)
+ }
+ }
+ sort.Strings(card.Regions)
+ }
+ }
+ // placement split from this account's routes
+ reg, err := openRegistry(s.cfg.Registry)
+ ...
+ routes, _ := reg.List()
+ platformApps := map[string]bool{}
+ fleetApps := map[string]map[string]bool{} // fleet -> app set
+ for _, rt := range routes {
+ if rt.Account != acct.Sqid { continue }
+ if f, ok := registry.FleetPlacement(rt.Region); ok {
+ if fleetApps[f] == nil { fleetApps[f] = map[string]bool{} }
+ fleetApps[f][rt.App] = true
+ } else {
+ platformApps[rt.App] = true
+ }
+ }
+ card.PlatformApps = len(platformApps)
+ card.Active = card.PlatformApps > 0
+ // account fleets
+ fleets, _ := reg.ListFleets()
+ var cards []accountFleetCard
+ for _, f := range fleets {
+ if f.Account != acct.Sqid { continue }
+ boxes, apps, _ := s.loadFleetPage(ctx, reg, acct, &f)
+ c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)}
+ for _, b := range boxes { if b.Reachable { c.Reachable++ } }
+ cards = append(cards, c)
+ }
+ return card, cards, warn
+}
+```
+
+(Exact soft-fail/warn wiring: mirror `loadFleetPage`'s `warn` convention. `_ = fleetApps` is NOT dead — render per-fleet placed counts if `accountFleetCard.Apps` from `loadFleetPage` disagrees; prefer `loadFleetPage`'s apps since it's the page users click through to.)
+
+Handler in `accountfleet.go`:
+
+```go
+// handleAccountFleetTab is the account-level Fleet tab (D1) — a different
+// thing from handleFleet (the operator's global /fleet status page) and
+// from handleFleetPage (one fleet's detail).
+func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) {
+ _, _, acct, role, ok := s.account(w, r)
+ if !ok { return }
+ shared, own, warn := s.assembleAccountFleet(r.Context(), acct)
+ p := s.accountPage(r, acct, role)
+ p.ActiveNav = "fleet"
+ p.SharedFleet, p.AccountFleets, p.FlashErr = shared, own, warn
+ s.render(w, r, "account_fleet", p)
+}
+```
+
+**NOTE to implementer:** `s.account`, `s.accountPage`, `s.render` signatures — copy from `handleTeam` (server.go:1329-ish) exactly; the sketch above compresses. Register `s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab)` beside `/a/{sqid}/team`. Extend the ActiveNav comment to `"apps" | "fleet" | "team" | "settings"`.
+
+Nav (layout.html, after the Apps line):
+
+```html
+<a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a>
+```
+
+Template `account_fleet.html` (pagehead + three sections; `.chip` for regions, `card` layout — copy structural CSS class usage from `apps.html`):
+
+```html
+{{define "account_fleet"}}{{template "top" .}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">{{.Account.Name}}</p>
+ <h1>Fleet</h1>
+ <p class="lede">Where this account's apps run — the shared fleet, and boxes you bring.</p>
+</div>
+
+<h2>{{.SharedFleet.Title}} {{if .SharedFleet.Active}}<span class="chip">Active</span>{{else}}<span class="chip">Not used</span>{{end}}</h2>
+{{if .SharedFleet.Boxes}}
+<p>{{.SharedFleet.Boxes}} box{{if ne .SharedFleet.Boxes 1}}es{{end}} in
+{{range .SharedFleet.Regions}}<span class="chip">{{.}}</span> {{end}}</p>
+<p class="small">{{if .SharedFleet.Active}}{{.SharedFleet.PlatformApps}} of your apps run here. Apps run on the shared fleet unless you place them on a fleet of your own.{{else}}None of your apps run on the shared fleet — they are all placed on your own fleets.{{end}}</p>
+{{else}}<p class="small">No shared boxes are reporting right now.</p>{{end}}
+
+<h2>Your fleets</h2>
+{{if .AccountFleets}}
+<div class="tablewrap"><table>
+ <tr><th>Fleet</th><th>Boxes</th><th>Reachable</th><th>Placed apps</th></tr>
+ {{range .AccountFleets}}
+ <tr>
+ <td><a href="{{$.Nav}}/fleets/{{.Name}}">{{.Name}}</a></td>
+ <td>{{.Boxes}}</td><td>{{.Reachable}}</td><td>{{.Apps}}</td>
+ </tr>
+ {{end}}
+</table></div>
+{{else}}<p class="small">No fleets yet.</p>{{end}}
+
+<h2>Bring your own fleet</h2>
+<p>Run your apps on hardware you own — an office box, a Raspberry Pi, your
+own cloud account. Your boxes dial out to the platform; nothing connects in.</p>
+<p><a class="btn" href="{{.Nav}}/fleet/byo">How to add and harden a box</a></p>
+{{template "bottom" .}}{{end}}
+```
+
+- [ ] **Step 4: Gate** — full run; check the nav renders on every account page (existing server tests exercising layout will catch template errors).
+- [ ] **Step 5: Commit** — `git commit -m "console: the Fleet tab — shared-fleet card, fleets index, nav (D1/D2)"`
+
+---
+
+### Task 5: BYO instructions page
+
+**Files:**
+- Create: `internal/console/templates/fleet_byo.html`
+- Modify: `internal/console/server.go` (route `GET /a/{sqid}/fleet/byo`), `internal/console/accountfleet.go` (handler)
+- Test: extend `accountfleet_test.go`
+
+**Interfaces:**
+- Consumes: Task 4's nav/ActiveNav; the **authoritative env contract** in `infra/dev/region-pi.md` and `internal/host/agent.go`'s package doc (copy variable names from there — do not invent).
+- Produces: `GET /a/{sqid}/fleet/byo`, member-readable, `ActiveNav: "fleet"`.
+
+- [ ] **Step 1: Failing test** — page 200s for a member, contains `carlos fleets create`, `CARLOS_TUNNEL_NAME`, and a "Hardening" heading.
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Handler mirrors Task 4's minus assembly. Template content, in order (pagehead: eyebrow "Fleet · {{.Account.Name}}", h1 "Bring your own fleet"):
+ 1. **What you need** — a Linux box (arm64 or amd64) that can reach the internet outbound; an object-storage bucket you own for the fleet's data; nothing listens inbound, ever.
+ 2. **Create the fleet** — fenced code block: `carlos fleets create -bucket <your-bucket> <name>` then `carlos fleets add-box <name> <label>` with the shown-once token warning (reuse fleetdetail.html's exact rotation-is-not-revocation phrasing for consistency).
+ 3. **Configure the box** — the env contract block copied verbatim from `infra/dev/region-pi.md` (implementer: read that file on this branch and transcribe the fleet env vars — `CARLOS_FLEET`, `CARLOS_FLEET_BOX`, `CARLOS_REGION=fleet/<name>`, `CARLOS_STATUS_PREFIX`, `CARLOS_TUNNEL_NAME=<fleet>/<label>`, `CARLOS_TUNNEL_TOKEN`, `CARLOS_HIBERNATE_BUCKET`, plus whichever tunnel-address/bucket-credential vars that doc lists), with `<name>`/`<label>` placeholders.
+ 4. **Harden it** — checklist (`<ul>`): dedicated non-root user for the agent; the env file root-owned mode 600 (it holds the token); default-deny inbound firewall (the box only dials out); unattended security upgrades on; clock kept sane (chrony/systemd-timesyncd — leases compare timestamps); systemd unit with `ProtectSystem=strict`/`NoNewPrivileges` (point at `internal/host/units/` as the reference shapes); rotate the token if it may have leaked, **Detach** (not rotate) to cut a compromised box off.
+ 5. **Then** — the box appears on your fleet page within minutes (heartbeat cadence); place an app with `carlos apps place` or from the app's Instances tab.
+- [ ] **Step 4: Gate.**
+- [ ] **Step 5: Commit** — `git commit -m "console: BYO fleet instructions page (D8)"`
+
+---
+
+### Task 6: Console placement moves — app Instances tab + fleet page form
+
+**Files:**
+- Modify: `internal/console/fleets.go` (extract `placeAppTarget` core from `handleAPIPlace` ~L479-601; new browser handler `handleAppPlace`), `internal/console/server.go` (route `POST /a/{sqid}/apps/{app}/place`; app-instances assembly additions), `internal/console/templates/app_instances.html` (placement section), `internal/console/templates/fleetdetail.html` ("Place an app here" form + handler route `POST /a/{sqid}/fleets/{fleet}/place`)
+- Test: `internal/console/fleets_test.go` additions + existing `handleAPIPlace` tests must stay green **unchanged** (they pin the refactor)
+
+**Interfaces:**
+- Consumes: `handleAPIPlace`'s existing body — its store-first, routes-before-records ORDER MATTERS discipline moves verbatim into the core; `appPlacement` (fleets.go:412) for current placement; `s.accountApp`/`s.owner` gates; `confirm`? No — plain form + warning copy (D6).
+- Produces: `func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string)` — returns (0, "") on success; both handlers translate. Template data: `AppPlacement string`, `OwnedFleetNames []string` on the instances-tab view; `FleetPlaceableApps []string` on the fleet page.
+
+- [ ] **Step 1: Failing tests.**
+
+```go
+// browser place: owner POST /a/{sqid}/apps/{app}/place form target=fleet/pi →
+// 303 back to instances tab, flash contains "placed"; registry route Region
+// updated; instance record region updated (fake release store fixture)
+// member (non-owner) POST → the owner gate's usual refusal
+// bad csrf → the standard csrf refusal
+// target naming another account's fleet → 404 byte-identical to unknown
+// clearing: target "" → routes cleared, flash "back on the shared fleet"
+// fleet-page form: POST /a/{sqid}/fleets/{fleet}/place app=<name> → same effects,
+// redirect back to fleet page
+// API regression: the existing handleAPIPlace tests all still pass UNTOUCHED
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Refactor + implement.** Mechanical extraction: everything in `handleAPIPlace` from the `s.cfg.Releases == nil` check through the record-stamping loop becomes `placeAppTarget`; the API handler keeps auth, rate-limit, JSON decode, audit(`"(cli)"`), and translates `(status, errMsg)` to `jsonError`. **Move the comments with the code** — the ORDER MATTERS block guards the core, not the handler. Browser handlers:
+ - `handleAppPlace`: gate like the other app POSTs (`s.owner` + `s.accountApp` + CSRF — copy `handleAppRename`'s skeleton), read `target` form value, call core, audit detail suffix `"(console)"`, redirect `{{.Nav}}/apps/{app}/instances` with flash.
+ - `handleFleetPlacePage`: `ownerFleet` gate, `app` form value, target = `registry.PlacementTarget(fleet.Name)`, same core, redirect to fleet page.
+- App Instances tab (find `handleAppInstances`'s assembly in server.go): add `AppPlacement` (via `appPlacement`) and `OwnedFleetNames` (ListFleets filtered by account). Template section (owner-only forms, member sees state):
+
+```html
+<h2>Placement</h2>
+<p>{{if .AppPlacement}}This app runs on your fleet <a href="{{.Nav}}/fleets/{{fleetName .AppPlacement}}"><code>{{fleetName .AppPlacement}}</code></a>.{{else}}This app runs on the shared fleet.{{end}}</p>
+{{if and .IsOwner .OwnedFleetNames}}
+<form method="post" action="{{.Base}}/apps/{{.AppName}}/place">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <select name="target">
+ <option value=""{{if not .AppPlacement}} selected{{end}}>Shared fleet</option>
+ {{range .OwnedFleetNames}}<option value="fleet/{{.}}"{{if eq (printf "fleet/%s" .) $.AppPlacement}} selected{{end}}>fleet/{{.}}</option>{{end}}
+ </select>
+ <button type="submit" class="btn-sm">Move</button>
+</form>
+<p class="small">Moving changes where this app <em>serves</em>. Data does not move:
+hibernated instance state stays in the old placement's bucket.</p>
+{{end}}
+```
+
+(`fleetName` FuncMap helper = `func(target string) string { f, _ := registry.FleetPlacement(target); return f }` — add beside `join`.) Fleet page form (below Placed apps): select over `FleetPlaceableApps` (account's apps not already on this fleet — derive in `loadFleetPage`'s caller from the same route walk) + Move button + the same data-does-not-move line.
+- [ ] **Step 4: Gate** — the pre-existing API place tests passing unmodified is the refactor's proof.
+- [ ] **Step 5: Commit** — `git commit -m "console: placement moves from the app Instances tab and fleet page (D4/D5/D6)"`
+
+---
+
+### Task 7: Edge — fleet channels ping fast (D7)
+
+**Files:**
+- Modify: `internal/edge/tunnel.go` (constants ~L46; `tunnelConn` ~L53; `ServeHTTP` auth branch ~L129; `pingLoop` ~L201)
+- Test: `internal/edge/` beside the existing tunnel tests (grep `pingLoop`/`SetDynamicAuth` tests)
+
+**Interfaces:**
+- Consumes: nothing from other tasks (independent; can run in parallel with 2-6).
+- Produces: `fleetPingEvery = 5s`, `fleetPingTimeout = 5s`; dynamic-auth'd conns ping at fleet cadence; static conns provably unchanged.
+
+- [ ] **Step 1: Failing tests.**
+
+```go
+func TestPingCadence(t *testing.T) {
+ if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout {
+ t.Fatal("static cadence changed")
+ }
+ if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout {
+ t.Fatal("fleet cadence wrong")
+ }
+}
+
+func TestDynamicChannelMarkedDynamic(t *testing.T) {
+ // register via the dynamic-auth path (copy the SetDynamicAuth round-trip
+ // test's arrange: hub + SetDynamicAuth{"pi/box-a": sha256hex(tok)} + a
+ // real handshake through httptest — the existing rotate/detach tests
+ // show how); then assert h.conns["pi/box-a"].dynamic == true, and a
+ // static-auth'd channel's conn has dynamic == false.
+}
+```
+
+- [ ] **Step 2: Verify failure** (`pingCadence` undefined).
+- [ ] **Step 3: Implement.**
+
+```go
+// Fleet (dynamic) channels ping tighter (fleets-console spec D7): a customer
+// fleet's failover is "requests fall back to a surviving box once the dead
+// channel is reaped", so the reap bound IS the user-visible outage window —
+// ≤10s (one 5s sleep + one 5s deadline) instead of the static channels'
+// ~25s. Static platform channels (tito hq) keep the wider cadence: their
+// routes have no fallback box, so a tighter ping only adds churn risk.
+fleetPingEvery = 5 * time.Second
+fleetPingTimeout = 5 * time.Second
+
+func pingCadence(dynamic bool) (every, timeout time.Duration) { ... }
+```
+
+`tunnelConn` gains `dynamic bool`. In `ServeHTTP`, hoist a `viaDynamic bool`, set it `true` inside the dynamic-auth fallback branch when its compare sets `authOK`; store it on the conn (`h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}`) and pass to `go h.pingLoop(name, cc, conn, viaDynamic)`; `pingLoop` uses `pingCadence(dynamic)` for both the sleep and the context deadline.
+- [ ] **Step 4: Gate** — `go test ./internal/edge/ -count=1 -race` (race mode: the edge suite runs it in CI) then the full suite.
+- [ ] **Step 5: Commit** — `git commit -m "edge: fleet channels ping at 5s/5s — reap bound is the failover window (D7)"`
+
+---
+
+### Task 8: Whole-branch gate, docs touch-ups, PR
+
+**Files:**
+- Modify: `infra/dev/region-pi.md` (add a pointer: tags + console Fleet tab exist; BYO page is the canonical box-add instructions now), spec status line (Draft → notes what shipped)
+- No code.
+
+- [ ] **Step 1:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1` all packages; `gofmt -l` on every touched file; `GOOS=darwin go build ./...` (CI runs it; `internal/host` links into the CLI).
+- [ ] **Step 2:** Docs touch-ups above; verify no template references a helper that isn't registered (server tests cover, but `grep -n "join\|fleetName" internal/console/server.go templates` as a belt).
+- [ ] **Step 3:** Commit docs; push branch; open PR **against `worktree-customer-fleets`** (stacked — the diff must show only this plan's work) titled "Fleets console v2: the Fleet tab, tags, placement moves, fleet fast-ping". Body: spec link, D1-D8 summary, "merges after #63; do not deploy" note.
+
+---
+
+## Self-review notes (run before execution)
+
+- Spec coverage: D1/D2 → Task 4; D3 → Tasks 1-3 (+ pools recipe lands in Task 5's step 4? NO — pools recipe is ops docs, folded into Task 5's BYO page? Also no — spec says ops docs; it lives in the Task 8 region-pi.md touch-up and the spec itself. Covered, no code owed); D4/D5/D6 → Task 6; D7 → Task 7; D8 → Task 5. Shared-card site-name variant: deliberately NOT implemented (PR #72 unmerged) — spec says "when present"; today's title is the constant.
+- Type consistency: `sharedFleetCard`/`accountFleetCard` defined Task 4, consumed only there + template; `placeAppTarget` defined and consumed in Task 6; `ValidBoxTags`/`SetFleetBoxTags`/`FleetBox.Tags` defined Task 1, consumed Tasks 2/3.
+- Parallelism: Task 7 is independent; Tasks 2 and 3 depend on 1; Task 5 depends on 4; Task 6 is independent of 1-5 (template merge conflicts with 4 are trivial — different sections).
diff --git a/docs/superpowers/specs/2026-08-10-fleets-console-design.md b/docs/superpowers/specs/2026-08-10-fleets-console-design.md
new file mode 100644
index 000000000..851b823c5
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-fleets-console-design.md
@@ -0,0 +1,162 @@
+# Fleets in the Console (v2): the Fleet tab, the shared fleet, categorization
+
+**Date:** 2026-08-10
+**Status:** Draft — designed autonomously from Paul's 2026-08-10 brief; decisions
+flagged **[Paul]** need his eyes before this is treated as approved. D1-D8 are
+implemented on branch `worktree-fleets-v2`, pending Paul's review.
+**Depends on:** `2026-08-05-customer-fleets-design.md` (v1, PR #63 — this work
+stacks on branch `worktree-customer-fleets` and merges after it).
+**Related issues:** #73 (fleet API surface), #123/#130 (route records, PR #137).
+
+## The brief (Paul, 2026-08-10, condensed)
+
+Fleet should be a console tab. It offers the **shared fleet** ("Carloku fleet
+[Active] — shared fleet in regions [EU] [UK]…") and **bring your own fleet**
+(instructions for adding boxes and hardening them). A registered box appears in
+the console and becomes placeable; you can move instances onto your fleet and
+opt out of the shared fleet. Boxes must be categorisable (e.g. free accounts on
+the office boxes). If a fleet host dies, another member picks up the capacity.
+End state: Eleven moves to Carloku by attaching its two boxes as a fleet,
+opted out of shared; the platform grows US/IE/UK/AU shared boxes plus a
+control instance in the IE AZ.
+
+## What already exists (v1, PR #63) — not redesigned here
+
+Fleets and fleet boxes in the registry (account-owned, token-hashed, channel
+`<fleet>/<label>`), owner-only heartbeats under `status/fleets/…`, dynamic edge
+auth with rotate/detach, placement axis `Route.Region = "" | fleet/<name>`,
+`carlos fleets …` / `carlos apps place`, a per-fleet console page
+(`/a/{sqid}/fleets/{fleet}`), and **reactive failover**: bindings are
+advisory, the edge falls back per-request to any connected channel of the
+fleet, the S3 lease race prevents split-brain, and a dark box cannot reclaim.
+v1's known gaps this spec addresses: no nav entry anywhere, no fleets index,
+no shared-fleet presentation, no categorization, wedged-channel ~25s black
+hole (decision owed), and the v1 trust boundary (#73, out of scope here).
+
+## Decisions
+
+| # | Question | Decision | Why / alternative rejected |
+|---|---|---|---|
+| D1 | Where does the Fleet tab live? **[Paul]** | **Account-level nav**: Apps · Fleet · Team · Settings, at `/a/{sqid}/fleet`. | Fleets are account-scoped and span apps. Paul said "beside Overview", but Overview is an *app* tab (appnav.html) — per-app is the wrong altitude for "your compute". The app's Instances tab gains the placement control instead (D5), so per-app placement IS visible beside Overview. |
+| D2 | What is "the shared fleet" as an object? | **A presentation, not a new table.** The card renders from `status/fleet.json` (already console-read): region chips = distinct `Box.Region` values of fresh boxes; box count; [Active] when ≥1 of the account's apps is platform-placed (`Region == ""`), else [Not used]. Title = "Shared fleet" (takes the deployment site name, e.g. "Carloku fleet", when PR #72's `CONSOLE_SITE_NAME` is present). | Platform boxes are deliberately NOT `fleet_boxes` rows (v1 decision). Inventing a registry row for the shared fleet would duplicate `status/fleet.json` and create a second source of truth. |
+| D3 | Categorization | Two mechanisms, matching the two kinds of box. **Shared fleet: pools** — already end-to-end (`CARLOS_POOLS` on the box, `Route.Pool` stamped from the app manifest, `poolServed` at reconcile). "Free accounts on the office boxes" = office box runs `CARLOS_POOLS=free`, free accounts' app manifests carry `pool: free`; recipe documented, no new code. **Customer fleet boxes: `fleet_boxes.tags`** (comma-separated, additive column) — display + CLI now, placement/affinity filters later. | One general tag system across both would force the shared fleet into the fleets tables, against v1's separation. Pools already paid for their design. |
+| D4 | Opt-out of the shared fleet | **Per-app placement is the opt-out** (exists: `apps place fleet/<f>`), now surfaced in the console. An app placed on a fleet has zero presence on shared boxes by construction (region-scoped reconcile). Whole-account default placement (new apps land on the fleet automatically) stays v2.1 — it needs a console-store account field and touches app-create; specced, not built now. | An account-level "opt out" toggle that force-moves every app is a mass placement change with data-residency consequences (D6); too much authority for one click today. |
+| D5 | "Move instances to that box" | **Placement targets a fleet, not a box** (v1 invariant: the binding/wake race picks the box; pinning would fight the failover story). The console gains move controls at fleet granularity: on the app Instances tab and on the fleet page ("Place an app here"). Box-affinity via tags is v2.1, after a real need (Eleven) exists. | Box-pinning today would make D7's "another member picks up capacity" a lie for pinned apps. |
+| D6 | Does app state follow a placement move? | **No — and the console must say so.** Moving placement re-homes *serving* (records re-target, routes converge within a pass); hibernated instance state lives in the source bucket and does not copy (v1 "nothing migrates"). The move form carries the warning; state-follow-move is a named v2.1 item. | Silent cross-bucket data copying is a residency violation, not a feature. |
+| D7 | Failover when a fleet host dies | Keep v1's reactive model (lease expiry + per-request fallback + takeover-on-wake) and **close the wedged-channel window**: fleet channels get a faster liveness cadence — ping every 5s, dead after one missed ping (10s worst-case black hole, down from ~25s). Platform-box failover (multi-box-per-region, mesh) stays out of scope — today's shared fleet is one box per region and a dead one is the documented restore runbook. **No background reaper, no auto-lease-break**: a stale heartbeat is display + channel-reap input only; the lease race remains the only authority over serving. | Proactive rebinding off heartbeat staleness risks acting on a partitioned-but-alive box; the lease race is the invariant that already survived review. |
+| D8 | BYO instructions | A console page (`/a/{sqid}/fleet/byo`): prerequisites (a bucket you own, outbound-only box), the `carlos fleets create` / `add-box` ceremony with the env contract filled in per-account, and a hardening checklist. Content lives in the template (static per-deployment, personalized only with account/fleet names) — no CMS. | |
+
+## Console surface
+
+Nav: `layout.html` account nav gains `Fleet` between Apps and Team
+(`ActiveNav: "fleet"`). Routes, all behind the standard gate order
+(session → CSRF → owner-404 → freshness last):
+
+- `GET /a/{sqid}/fleet` — the tab. Sections: shared-fleet card (D2); "Your
+ fleets" — one card per registry fleet owned by this account (name, box
+ count with healthy/dark split from heartbeat staleness, placed-app count,
+ links to the existing `/a/{sqid}/fleets/{fleet}` detail page); "Bring your
+ own fleet" teaser linking the BYO page. Member-readable; forms owner-only.
+- `GET /a/{sqid}/fleet/byo` — instructions (D8). Member-readable.
+- `POST /a/{sqid}/apps/{app}/place` — console form of the existing
+ `apps place` API: target `""` (shared) or `fleet/<name>` (owner + CSRF;
+ reuses the API handler's validation + `SetRecordRegion` logic, one
+ implementation). Rendered on the app Instances tab (current placement +
+ move form with the D6 warning) and as "place an app here" on the fleet
+ detail page.
+- Fleet detail page additions: per-box `tags` chips + owner-only edit form
+ (`POST …/fleets/{fleet}/boxes/{label}/tags`).
+
+View-model: a new `assembleAccountFleet` (overview.go pattern) combining
+(a) `FleetView` (`status/fleet.json`) for the shared card, (b) registry
+fleets/boxes/heartbeats for the account (the fleet-page loader generalized
+to a list), (c) the account's apps' placement split (registry routes by
+account: count with `Region == ""` vs per-fleet).
+
+## Data model (additive only)
+
+```sql
+ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT '';
+```
+
+Comma-separated, each tag `^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$` — lowercase
+`[a-z0-9-]`, 1-24 chars, hyphens interior only (leading/trailing hyphen
+rejected; Task 1 review ruling) — max 8 tags. Rows written
+before the change parse unchanged (empty = untagged). `rev` bumps on tag
+writes so the fingerprint/edge poll sees them (display-only today, but the
+column must not be invisible to convergence when placement filters arrive).
+
+## CLI
+
+- `carlos fleets tag-box <fleet> <label> -tags office,free` (owner; `-tags ""`
+ clears; full-replace, not merge).
+- `carlos fleets ls` gains a TAGS column; the fleet API list/detail responses
+ gain `tags` (additive JSON field).
+- `carlos apps place` unchanged (it already does D4/D5's work).
+
+## Edge: fleet channel liveness (D7)
+
+Fleet channels (dynamic-auth channels only — static `CARLOS_TUNNEL_CLIENTS`
+platform channels keep today's cadence) ping every 5s with a 5s deadline;
+one missed ping reaps the channel, which immediately flips
+`ConnectedOfFleet` so the per-request fallback stops selecting the dead box.
+Worst-case request black hole after a silent box death: ≤10s, after which
+requests fall back to a surviving box, whose wake path takes over the lease
+per v1. The reconnecting box re-dials as today; reconnect churn is bounded
+by dial backoff, and a wedged-but-reconnecting box just loses its stamp.
+
+## Security
+
+Unchanged posture: tokens hashed at rest and shown once; owner-gated
+mutations; byte-identical 404 for other-account fleets; fleet heartbeat
+prefix unreachable from the public fleet page (contract-pinned). New
+surfaces to pin: the Fleet tab must not leak other accounts' fleets or the
+platform's per-box private probes (the shared card uses only the public
+`fleet.json` aggregate, never `status/boxes/*` raw snapshots); the tags
+grammar is validated server-side (reject, don't sanitize).
+
+## Testing
+
+- Console: tab renders for owner/member/non-member (404 shape); shared card
+ Active/Not-used flips on placement; region chips from fixture fleet.json;
+ place form round-trip incl. old-console mismatch echo; tags CRUD + grammar
+ rejects; BYO page renders with account names substituted.
+- Registry: tags additive-parse (pre-change rows), rev bump on tag write,
+ fingerprint moves.
+- Edge: fleet channel reaped after one missed 5s ping; fallback selection
+ excludes reaped channel; static platform channels unaffected (cadence
+ assertion); reconnect after reap succeeds.
+- Laddercheck/e2e untouched — no channel-name or wire-shape changes.
+
+## Rollout (recorded, phased — later phases are ops, not this branch)
+
+1. **This branch** (stacked on #63): everything above. Merges after #63;
+ deploy = binary roll + console restart (new templates/endpoints).
+2. **Free-tier office box** (ops): office box joins with `CARLOS_POOLS=free`
+ in its host env; free-tier apps are enabled operator-side with
+ `carlos instances enable -pool free` (store mode; signed-in mode refuses
+ `-pool`) so every instance record created under that app inherits
+ `pool: free` (`provision.Record.Pool`, stamped from the manifest at
+ create, `apiinstances.go`); `poolServed` at reconcile
+ (`internal/provision/reconcile.go`) then scopes that box's
+ provision/adopt passes to instances whose pool it actually serves, so the
+ office box only ever picks up free-tier instances. This paragraph is the
+ canonical recipe; no code change.
+3. **Shared fleet build-out** (pinfra): `region-host` boxes for `us`, `ie`,
+ `uk`, `au`; console (control) pinned in the IE AZ. Region *placement
+ values* (`place=us|…`) remain future per v1 — day one, regions appear as
+ shared-card chips via `STATUS_BOX_REGION`, and instances land per each
+ box's `CARLOS_REGION`/`CARLOS_POOLS` scope.
+4. **Eleven → Carloku**: Eleven account on the Carloku console; Eleven-owned
+ tofu root (region-remote-host pattern) provisions its principal against
+ its own bucket; `carlos fleets create eleven` + `add-box` ×2; boxes flip
+ to the fleet env contract; apps placed `fleet/eleven` — opted out of
+ shared by construction. Gated on operator trust until #73 removes the
+ platform-bucket grant. Data stays in Eleven's bucket throughout.
+
+## Out of scope (named, so they don't creep back in)
+
+Account-default placement and state-follow-move (v2.1, D4/D6); box-affinity
+tags in placement (v2.1, D5); platform multi-box-per-region failover / mesh
+(D7); the region registry bucket object and `place=<region>` values;
+#73's account-scoped fleet API surface; any deployment of this branch.
diff --git a/fleets.go b/fleets.go
index 604b36a6a..8dffa5b06 100644
--- a/fleets.go
+++ b/fleets.go
@@ -27,7 +27,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
)
-const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-box|ls> [flags]
+const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|detach-box|ls> [flags]
create -bucket <bucket> [-account <sqid|name>] <name>
mint an account-owned fleet: a named group of remote boxes
@@ -59,6 +59,13 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-bo
is already connected on — a box whose credential may be
COMPROMISED needs detach-box, not this.
+ tag-box -tags <tags> [-account <sqid|name>] <fleet> <label>
+ replace a box's tags IN FULL: comma-separated segments of
+ 1-24 lowercase a-z/0-9/hyphen characters each (hyphens
+ interior only, never leading or trailing), max 8 tags.
+ -tags "" clears every tag. This is a full replace, not a
+ merge — the tags sent are exactly the tags that land.
+
detach-box [-account <sqid|name>] <fleet> <label>
the hard revocation rotate-token is not: flips the box to
"detached", which refuses its next dial-in AND closes any
@@ -66,8 +73,8 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-bo
needs.
ls [-account <sqid|name>]
- list every fleet the account owns, with each box's label
- and status.
+ list every fleet the account owns, with each box's label,
+ status and tags.
-account <sqid|name> account the fleet belongs to (default: ops) —
resolved by the console against your own
@@ -94,6 +101,8 @@ func RunFleets(args []string) {
runFleetsAddBox(args[1:])
case "rotate-token":
runFleetsRotateToken(args[1:])
+ case "tag-box":
+ runFleetsTagBox(args[1:])
case "detach-box":
runFleetsDetachBox(args[1:])
case "ls":
@@ -248,6 +257,66 @@ func printFleetToken(fleet, label, token string, rotated bool) {
fmt.Fprintln(stdout, " does NOT evict a live session — a COMPROMISED box needs `fleets detach-box` instead.")
}
+// runFleetsTagBox implements `carlos fleets tag-box`: a full replace of a
+// box's tags, checked client-side with registry.ValidBoxTags BEFORE the API
+// call — the same "a shape the server is certain to refuse should cost
+// nothing" principle validFleetName/validBoxLabel already state above.
+//
+// -tags is TRI-state, the exact hazard runAppsPlace's -target comment
+// documents (apps.go): "" is a LEGAL, meaningful value here — it clears
+// every tag — so a flag left unmentioned and one explicitly set to "" are
+// not the same request, and the flag's own zero value cannot tell them
+// apart. Without a positive check, a forgotten `-tags` would silently wipe
+// a box's tags and print "cleared" as if that were asked for. fs.Visit
+// reports only flags actually present on the command line, so — exactly
+// like runAppsPlace's targetSet — that is what decides "not named" from
+// "named, empty", not the value in *tags.
+func runFleetsTagBox(args []string) {
+ fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError)
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)")
+ tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`)
+ fs.Parse(args)
+ const tagBoxUsage = "usage: carlos fleets tag-box -tags <tags> [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <fleet> <label>"
+ if fs.NArg() != 2 {
+ log.Fatal(tagBoxUsage)
+ }
+ tagsSet := false
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == "tags" {
+ tagsSet = true
+ }
+ })
+ if !tagsSet {
+ log.Fatal(tagBoxUsage)
+ }
+ fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1))
+ if err := validFleetName(fleet); err != nil {
+ log.Fatalf("fleets tag-box: %v", err)
+ }
+ if err := validBoxLabel(label); err != nil {
+ log.Fatalf("fleets tag-box: %v", err)
+ }
+ tagsV := strings.TrimSpace(*tags)
+ if !registry.ValidBoxTags(tagsV) {
+ log.Fatal("fleets tag-box: invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
+ }
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil {
+ log.Fatalf("fleets tag-box: %v", err)
+ }
+ if tagsV == "" {
+ fmt.Fprintf(stdout, "cleared tags for %s/%s\n", fleet, label)
+ } else {
+ fmt.Fprintf(stdout, "tagged %s/%s [%s]\n", fleet, label, tagsV)
+ }
+}
+
func runFleetsDetachBox(args []string) {
fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError)
acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
@@ -294,14 +363,18 @@ func runFleetsList(args []string) {
return
}
w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
- fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS")
+ fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS\tTAGS")
for _, f := range fleets {
if len(f.Boxes) == 0 {
- fmt.Fprintf(w, "%s\t%s\t-\t-\n", f.Name, f.Bucket)
+ fmt.Fprintf(w, "%s\t%s\t-\t-\t-\n", f.Name, f.Bucket)
continue
}
for _, b := range f.Boxes {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status)
+ tags := b.Tags
+ if tags == "" {
+ tags = "-"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status, tags)
}
}
w.Flush()
diff --git a/fleets_cli_test.go b/fleets_cli_test.go
index b0764f578..167ef85c2 100644
--- a/fleets_cli_test.go
+++ b/fleets_cli_test.go
@@ -225,6 +225,40 @@ func TestAPISessionFleetDoorsSurfaceServerErrors(t *testing.T) {
if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil {
t.Fatal("detachFleetBox against a 404 must error")
}
+ if err := s.tagFleetBox(context.Background(), acct, "ghost", "x", "office"); err == nil {
+ t.Fatal("tagFleetBox against a 404 must error")
+ }
+}
+
+// TestAPISessionFleetTagBox: tagFleetBox POSTs to the labelled tags door
+// with the tags field in the body (full replace, including the "" clearing
+// case), and returns no token — unlike add-box/rotate-token, this door's
+// response body carries nothing this client reads.
+func TestAPISessionFleetTagBox(t *testing.T) {
+ var gotPath string
+ var gotBody struct{ Tags string }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", "office,free"); err != nil {
+ t.Fatalf("tagFleetBox: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" || gotBody.Tags != "office,free" {
+ t.Fatalf("tagFleetBox request: path=%s body=%+v", gotPath, gotBody)
+ }
+
+ if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", ""); err != nil {
+ t.Fatalf("tagFleetBox clear: %v", err)
+ }
+ if gotBody.Tags != "" {
+ t.Fatalf("tagFleetBox clear sent tags=%q, want empty", gotBody.Tags)
+ }
}
// TestAPISessionPlace: place POSTs the target in the body to .../place, for
@@ -303,6 +337,8 @@ type fakeFleetsConsole struct {
lastCreatePlace atomic.Value // string
lastPlaceTarget atomic.Value // string
lastDetachPath atomic.Value // string
+ lastTagPath atomic.Value // string
+ lastTagBody atomic.Value // string
}
func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole {
@@ -322,7 +358,7 @@ func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole {
json.NewEncoder(w).Encode(map[string]any{
"fleets": []map[string]any{
{"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{
- {"label": "pi-1", "status": "active"},
+ {"label": "pi-1", "status": "active", "tags": "office,free"},
{"label": "pi-2", "status": "detached"},
}},
},
@@ -344,6 +380,13 @@ func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole {
fc.lastDetachPath.Store(r.URL.Path)
json.NewEncoder(w).Encode(map[string]any{})
})
+ mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Tags string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.lastTagPath.Store(r.URL.Path)
+ fc.lastTagBody.Store(req.Tags)
+ json.NewEncoder(w).Encode(map[string]any{})
+ })
mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) {
var req struct{ Name, Place string }
json.NewDecoder(r.Body).Decode(&req)
@@ -500,7 +543,8 @@ func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) {
}
// TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per
-// box, including a detached one, from the fake console's fixed listing.
+// box, including a detached one, from the fake console's fixed listing —
+// and a TAGS column: the tagged box's tags verbatim, the untagged box's "-".
func TestRunFleetsListPrintsEveryBox(t *testing.T) {
fleetsCLIFixture(t)
buf := captureStdout(t)
@@ -508,11 +552,52 @@ func TestRunFleetsListPrintsEveryBox(t *testing.T) {
RunFleets([]string{"ls", "-account", "bab"})
out := buf.String()
- for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached"} {
+ for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached", "TAGS", "office,free"} {
if !strings.Contains(out, want) {
t.Fatalf("fleets ls stdout missing %q:\n%s", want, out)
}
}
+ // pi-2 carries no tags — its row must show the placeholder, not a blank
+ // cell (which whitespace-splitting could not tell apart from a missing
+ // column).
+ for _, line := range strings.Split(out, "\n") {
+ if !strings.Contains(line, "pi-2") {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) == 0 || fields[len(fields)-1] != "-" {
+ t.Fatalf("pi-2 row = %q, want its last (TAGS) field to be the '-' placeholder", line)
+ }
+ }
+}
+
+// TestRunFleetsTagBoxHitsRightPathAndClears: `carlos fleets tag-box` PUTs
+// (POSTs) the tags in the body to the labelled tags door, full-replace, and
+// -tags "" clears — both print their own confirmation, mirroring
+// TestRunAppsPlaceHitsRightPathAndClears's two-call shape.
+func TestRunFleetsTagBoxHitsRightPathAndClears(t *testing.T) {
+ fc := fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunFleets([]string{"tag-box", "-tags", "office,free", "-account", "bab", "acme", "pi-1"})
+ if got := fc.lastTagPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" {
+ t.Fatalf("tag-box path = %v, want the labelled tags door", got)
+ }
+ if got := fc.lastTagBody.Load(); got != "office,free" {
+ t.Fatalf("console saw tags %v, want office,free", got)
+ }
+ if !strings.Contains(buf.String(), "tagged acme/pi-1 [office,free]") {
+ t.Fatalf("stdout = %q, want it to confirm the tags", buf.String())
+ }
+
+ buf2 := captureStdout(t)
+ RunFleets([]string{"tag-box", "-tags", "", "-account", "bab", "acme", "pi-1"})
+ if got := fc.lastTagBody.Load(); got != "" {
+ t.Fatalf("console saw tags %v, want \"\" (cleared)", got)
+ }
+ if !strings.Contains(buf2.String(), "cleared tags for acme/pi-1") {
+ t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String())
+ }
}
// TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place
@@ -594,6 +679,16 @@ func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) {
{"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"},
{"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"},
{"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"},
+ {"fleets tag-box no args", []string{"fleets", "tag-box"}, "usage: carlos fleets tag-box"},
+ {"fleets tag-box bad tags grammar", []string{"fleets", "tag-box", "-tags", "Office", "acme", "pi-1"}, "invalid tags"},
+ {"fleets tag-box bad fleet name", []string{"fleets", "tag-box", "-tags", "office", "Bad Name", "pi-1"}, "not a usable fleet name"},
+ // Review finding: -tags is TRI-state, the exact -target hazard the
+ // "apps place omitted target" case below pins. -tags defaults to ""
+ // and "" is the legal, meaningful "clear every tag" value, so an
+ // omitted -tags must NEVER be read as "clear" — that would silently
+ // destroy a box's tags on a forgotten flag. fleet/label ARE present
+ // here (only -tags is missing) so this fails on the flag, not the args.
+ {"fleets tag-box omitted -tags", []string{"fleets", "tag-box", "acme", "pi-1"}, "usage: carlos fleets tag-box"},
{"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"},
{"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"},
{"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"},
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md
index 07cad7a9c..d0f48c86f 100644
--- a/infra/dev/region-pi.md
+++ b/infra/dev/region-pi.md
@@ -8,6 +8,12 @@ OS: 64-bit Raspberry Pi OS Lite — MANDATORY. Hosts pick release artifacts
by GOOS-GOARCH; this Pi resolves linux-arm64, the same artifact the
Graviton fleet runs. 32-bit resolves nothing.
+Fleet boxes are now first-class in the console: the account Fleet tab
+(`/a/{sqid}/fleet`) lists registered fleets and their boxes (with tags), and
+`/a/{sqid}/fleet/byo` is the canonical box-add + hardening instructions —
+prefer it over hand-assembling the steps below when walking someone else
+through joining a box.
+
Follow infra/remote-host.md with these dev-platform values:
- Tofu: the customer root lives in `paulca/pi-fleet` (private), consuming
diff --git a/internal/console/accountfleet.go b/internal/console/accountfleet.go
new file mode 100644
index 000000000..ab2dd6327
--- /dev/null
+++ b/internal/console/accountfleet.go
@@ -0,0 +1,223 @@
+package console
+
+// The account-level Fleet tab: GET /a/{sqid}/fleet, the page that answers
+// "where does this account's work actually run?".
+//
+// It is a DIFFERENT page from the two that already carry the word fleet:
+// handleFleet (/fleet) is the operator's global platform status page, and
+// handleFleetPage (/a/{sqid}/fleets/{fleet}) is one of this account's own
+// fleets in detail. This one sits between them — member-readable, account
+// scoped, and the only place the shared platform fleet and the account's own
+// boxes are shown as two halves of one question.
+//
+// Two rules shape everything below:
+//
+// D2 — the shared fleet is a PRESENTATION, not a registry object. There is
+// no "shared fleet" row anywhere; the card is assembled from
+// status/fleet.json, the same PUBLIC aggregate the status page renders. The
+// raw status/boxes/* snapshots are deliberately NOT read here: they carry
+// per-box operational detail (release ids, load, memory) that the operator
+// page is gated to show and a tenant's page is not.
+//
+// Soft-fail everywhere — the overview.go/loadFleetPage posture. This page
+// makes three independent reads (the aggregate, the account's routes, its
+// fleets); any one of them having a bad moment costs that section and a
+// flash, never the page.
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// sharedFleetCard is the platform's own fleet as this account sees it:
+// how many boxes are reporting, where they are, and whether any of THIS
+// account's apps are running on them.
+//
+// Boxes/Regions come from the public aggregate and are the same for every
+// viewer; Active/PlatformApps are this account's own placement split and are
+// not. Nothing here is per-box: a tenant learns that the shared fleet exists
+// and roughly where it is, never which machine is which.
+type sharedFleetCard struct {
+ Title string
+ Active bool // at least one of this account's apps runs here
+ Regions []string // distinct, sorted; a box with no region is simply absent
+ Boxes int
+ PlatformApps int // this account's distinct apps NOT placed on a fleet
+ // PlacementKnown is false when the read that decides Active/PlatformApps
+ // failed. Both fields then hold their zero values, which read as the
+ // POSITIVE claim "none of your apps run on the shared fleet" — a claim
+ // this account's routes were never actually consulted for. The card
+ // renders neither verdict when this is false: "we could not check" and
+ // "we checked, and no" are different sentences, and only one of them is
+ // true after a registry outage.
+ PlacementKnown bool
+}
+
+// accountFleetCard is one of the account's own fleets as the index row it
+// links through to the detail page with. Counts come from loadFleetPage —
+// the same function the detail page itself uses — so a row and the page it
+// clicks through to can never disagree about how many boxes a fleet has.
+type accountFleetCard struct {
+ Name string
+ Boxes int
+ Reachable int
+ Apps int
+}
+
+// assembleAccountFleet builds the tab's two halves: the shared platform
+// fleet from status/fleet.json, and the account's own fleets from the
+// registry. warn carries the FIRST read that failed, for a flash banner —
+// every failure degrades its own section to empty and nothing else.
+//
+// fleetsKnown is the fleets index's twin of sharedFleetCard.PlacementKnown,
+// and exists for the identical reason: a nil slice is the SAME value for
+// "this account has no fleets" and "the read that would have listed them
+// failed", and the first of those is a positive claim ("No fleets of your
+// own yet") that a failed registry read has not earned. False means the
+// section renders "unknown", never "none".
+func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (card *sharedFleetCard, fleets []accountFleetCard, fleetsKnown bool, warn string) {
+ card = &sharedFleetCard{Title: "Shared fleet"}
+
+ // A console with no deployment bucket configured has no aggregate to
+ // read at all — that is the empty card, not an error.
+ if s.cfg.Releases != nil {
+ f, err := FleetView(ctx, s.cfg.Releases)
+ switch {
+ case err != nil:
+ // A read that FAILED is not the same claim as "no box is
+ // reporting" — the card stays empty either way, but only this
+ // branch admits the difference, rather than telling a tenant the
+ // platform has no boxes because S3 was slow. (FleetView already
+ // folds a not-yet-published object into (nil, nil), so reaching
+ // here means a real failure.)
+ log.Printf("console: account fleet %s: fleet view: %v", acct.Sqid, err)
+ warn = "Could not read the shared fleet's status."
+ case f != nil:
+ card.Boxes = len(f.Boxes)
+ seen := map[string]bool{}
+ for _, b := range f.Boxes {
+ if b.Region != "" && !seen[b.Region] {
+ seen[b.Region] = true
+ card.Regions = append(card.Regions, b.Region)
+ }
+ }
+ sort.Strings(card.Regions)
+ }
+ }
+
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: account fleet %s: open registry: %v", acct.Sqid, err)
+ if warn == "" {
+ warn = "Could not reach the fleet registry."
+ }
+ return card, nil, false, warn
+ }
+
+ // The placement split. A route whose Region parses as a fleet placement
+ // runs on that fleet; everything else — "" and any future residency code
+ // FleetPlacement declines — runs on the shared fleet, which is exactly
+ // the fallback the platform itself applies. Counted by distinct APP, not
+ // by route, because "3 of your apps run here" is the sentence a tenant
+ // can act on; a host count is noise on this page.
+ //
+ // rt.Account is compared strictly, the same test loadFleetPage and
+ // overview.go make: an unstamped row is not this account's to claim on a
+ // page that goes on to say "of YOUR apps".
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("console: account fleet %s: list routes: %v", acct.Sqid, err)
+ if warn == "" {
+ warn = "Could not read where your apps are running."
+ }
+ } else {
+ platformApps := map[string]bool{}
+ for _, rt := range routes {
+ if rt.Account != acct.Sqid {
+ continue
+ }
+ if _, placed := registry.FleetPlacement(rt.Region); !placed {
+ platformApps[rt.App] = true
+ }
+ }
+ card.PlatformApps = len(platformApps)
+ card.Active = card.PlatformApps > 0
+ card.PlacementKnown = true
+ }
+
+ rows, err := reg.ListFleets()
+ if err != nil {
+ log.Printf("console: account fleet %s: list fleets: %v", acct.Sqid, err)
+ if warn == "" {
+ warn = "Could not list your fleets."
+ }
+ return card, nil, false, warn
+ }
+ for i := range rows {
+ // Fleet names are one flat, cross-account namespace (fleets.go's
+ // package doc comment), so this filter is the ONLY thing standing
+ // between this index and another account's fleet names. &fleets[i]
+ // rather than a ranged copy's address: loadFleetPage takes a pointer,
+ // and one that outlived a loop variable would be a bug waiting for
+ // the day this function keeps it.
+ f := &rows[i]
+ if f.Account != acct.Sqid {
+ continue
+ }
+ boxes, apps, fleetWarn := s.loadFleetPage(ctx, reg, acct, f)
+ if fleetWarn != "" && warn == "" {
+ // loadFleetPage's own wording says "this fleet", which has no
+ // referent on an index of several; say what it costs HERE.
+ warn = "Some fleet details could not be read — the counts below may be incomplete."
+ }
+ c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)}
+ for _, b := range boxes {
+ if b.Reachable {
+ c.Reachable++
+ }
+ }
+ fleets = append(fleets, c)
+ }
+ return card, fleets, true, warn
+}
+
+// handleAccountFleetTab serves GET /a/{sqid}/fleet. Membership in the
+// account is the whole gate: the page is a read, so a member sees exactly
+// what an owner does (there is no mutation on it to withhold).
+func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Fleet"
+ p.ActiveNav = "fleet"
+ shared, own, ownKnown, warn := s.assembleAccountFleet(r.Context(), acct)
+ p.SharedFleet = shared
+ p.AccountFleets = own
+ p.FleetsKnown = ownKnown
+ if warn != "" && p.FlashErr == "" {
+ p.FlashErr = warn
+ }
+ s.render(w, http.StatusOK, "account_fleet", p)
+}
+
+// handleFleetBYO serves GET /a/{sqid}/fleet/byo: the "how to add and harden
+// a box" page the Fleet tab's button links to. It is static, per-deployment
+// content — the account only personalizes the eyebrow and header, same as
+// every other account page — so the handler is handleAccountFleetTab minus
+// the shared-fleet/own-fleets assembly: gate, page scaffold, render.
+func (s *Server) handleFleetBYO(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Bring your own fleet"
+ p.ActiveNav = "fleet"
+ s.render(w, http.StatusOK, "fleet_byo", p)
+}
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go
new file mode 100644
index 000000000..1cd589a81
--- /dev/null
+++ b/internal/console/accountfleet_test.go
@@ -0,0 +1,394 @@
+package console
+
+// The account-level Fleet tab (accountfleet.go): GET /a/{sqid}/fleet, a
+// MEMBER-readable page that answers "where does this account's work run?" in
+// two halves — the shared platform fleet, presented purely from the public
+// status/fleet.json aggregate, and the account's own fleets from the
+// registry. The properties that matter, in the order they'd hurt if lost:
+// the page never 500s because one of its three independent reads had a bad
+// moment (the loadFleetPage/overview.go posture), it never shows another
+// account's fleets, and the shared card's "Active" claim tracks placement —
+// an account whose every route sits on its own fleet must not be told its
+// apps run on the shared one.
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// seedSharedBoxes publishes a status/fleet.json carrying one box per region
+// name given, through the same BuildFleet/PublishFleet path production uses
+// (seedFleetJSON's shape, with the regions the tab renders as chips).
+func seedSharedBoxes(t *testing.T, fx *consoleFixture, regions ...string) {
+ t.Helper()
+ ctx := context.Background()
+ for i, region := range regions {
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: time.Now(),
+ Label: []string{"ada-stargazing", "grace-moonlit", "alan-seaside"}[i],
+ Region: region,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(ctx, fx.rel, routes, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := status.PublishFleet(ctx, fx.rel, f); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fixtureAccount is the account value the assemble-level tests pass in — the
+// same {ID, Sqid} pair the gated handler would hand it.
+func fixtureAccount(fx *consoleFixture) *Account {
+ return &Account{ID: OpsAccountID, Sqid: fx.sqid, Name: "ops"}
+}
+
+// TestAccountFleetTabMemberReadable: the tab is a READ, so a plain member
+// sees it exactly as the owner does; a non-member gets the account gate's
+// byte-identical 404, the same answer an unrouted URL gives.
+func TestAccountFleetTabMemberReadable(t *testing.T) {
+ fx, token := fleetFixture(t, "code-acctfleet-gate")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-acctfleet-owner")
+ st, _, body := get(t, owner, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("owner GET /fleet = %d, want 200:\n%s", st, body)
+ }
+ for _, want := range []string{"Shared fleet", "Your fleets", "acme", "Bring your own fleet"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("owner fleet tab missing %q in:\n%s", want, body)
+ }
+ }
+
+ fx.setUser("sam@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-acctfleet-member")
+ st, _, memberBody := get(t, member, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("member GET /fleet = %d, want 200:\n%s", st, memberBody)
+ }
+ for _, want := range []string{"Shared fleet", "acme"} {
+ if !strings.Contains(memberBody, want) {
+ t.Fatalf("member fleet tab missing %q in:\n%s", want, memberBody)
+ }
+ }
+
+ fx.setUser("bob@keymail.dev")
+ stranger := newBrowser(t)
+ signUp(t, fx, stranger, "code-acctfleet-bob")
+ nonMemberStatus, _, nonMemberBody := get(t, stranger, fx.u("/fleet"))
+ ghostStatus, _, ghostBody := get(t, stranger, fx.ts.URL+"/definitely-not-a-page")
+ if nonMemberStatus != http.StatusNotFound || nonMemberStatus != ghostStatus {
+ t.Fatalf("non-member fleet tab = %d, unrouted page = %d; want both 404", nonMemberStatus, ghostStatus)
+ }
+ if nonMemberBody != ghostBody {
+ t.Fatalf("non-member fleet tab body differs from an unrouted page's:\n%q\n%q", nonMemberBody, ghostBody)
+ }
+}
+
+// TestAccountFleetTabNavLink: the tab is reachable — every account page
+// carries the Fleet nav entry, and it marks itself current on this one.
+func TestAccountFleetTabNavLink(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ // Nav LINKS carry the preferred segment — the account's name, "ops" for
+ // the fixture's built-in account — while forms keep posting to the sqid
+ // path (accountPage: Nav vs Base).
+ st, _, apps := get(t, c, fx.u("/"))
+ if st != http.StatusOK {
+ t.Fatalf("account home = %d:\n%s", st, apps)
+ }
+ if !strings.Contains(apps, `href="/a/ops/fleet"`) {
+ t.Fatalf("account home nav has no Fleet link:\n%s", apps)
+ }
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet = %d:\n%s", st, body)
+ }
+ if !strings.Contains(body, `href="/a/ops/fleet" aria-current="page"`) {
+ t.Fatalf("Fleet tab does not mark its own nav entry current:\n%s", body)
+ }
+}
+
+// TestAccountFleetSharedCardFromFleetJSON: the shared card is a PRESENTATION
+// of the public aggregate — box count and the distinct regions, deduped and
+// sorted, straight off status/fleet.json.
+func TestAccountFleetSharedCardFromFleetJSON(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedSharedBoxes(t, fx, "UK · London", "EU · Ireland")
+
+ card, _, _, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
+ if warn != "" {
+ t.Fatalf("warn = %q, want none on a healthy read", warn)
+ }
+ if card.Boxes != 2 {
+ t.Fatalf("card.Boxes = %d, want 2", card.Boxes)
+ }
+ if len(card.Regions) != 2 || card.Regions[0] != "EU · Ireland" || card.Regions[1] != "UK · London" {
+ t.Fatalf("card.Regions = %v, want both regions, sorted", card.Regions)
+ }
+
+ // And they reach the page.
+ c := signInPaul(t, fx)
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet = %d:\n%s", st, body)
+ }
+ for _, want := range []string{"EU · Ireland", "UK · London", "2 box"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("fleet tab missing %q in:\n%s", want, body)
+ }
+ }
+}
+
+// TestAccountFleetActiveFlipsWithPlacement: "Active" is a claim about THIS
+// account's routes — true while any of its apps still runs on the shared
+// fleet, false once every one of them is placed on a fleet of its own. An
+// account told "your apps run here" when they do not is the failure this
+// test exists to prevent.
+func TestAccountFleetActiveFlipsWithPlacement(t *testing.T) {
+ fx, token := fleetFixture(t, "code-acctfleet-active")
+ reg := openTestReg(t, fx)
+ if err := reg.Upsert(registry.Route{
+ Host: "kass.example.dev", Kind: "instance", App: "kass", Account: fx.sqid,
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ acct := fixtureAccount(fx)
+ card, _, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct)
+ if !card.Active || card.PlatformApps != 1 {
+ t.Fatalf("unplaced route: Active=%v PlatformApps=%d, want true/1", card.Active, card.PlatformApps)
+ }
+
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, placeURL(fx, "kass"), map[string]string{"target": "fleet/acme"}, token); st != http.StatusOK {
+ t.Fatalf("place = %d %v, want 200", st, body)
+ }
+
+ card, own, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct)
+ if card.Active || card.PlatformApps != 0 {
+ t.Fatalf("every route placed: Active=%v PlatformApps=%d, want false/0", card.Active, card.PlatformApps)
+ }
+ if len(own) != 1 || own[0].Apps != 1 {
+ t.Fatalf("own fleets = %+v, want acme carrying the one placed app", own)
+ }
+}
+
+// TestAccountFleetListsOnlyOwnFleets: fleet names are one flat cross-account
+// namespace (fleets.go's package doc), so the index MUST filter by owner —
+// a fleet belonging to another account must not appear here any more than it
+// resolves on the detail page.
+func TestAccountFleetListsOnlyOwnFleets(t *testing.T) {
+ fx, token := fleetFixture(t, "code-acctfleet-own")
+ reg := openTestReg(t, fx)
+ if _, err := reg.CreateFleet(registry.Fleet{Name: "stranger", Account: "zzz", Bucket: "not-yours"}); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"acme", "beta"} {
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": name, "bucket": name + "-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet %s = %d %v", name, st, body)
+ }
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ writeFleetHeartbeat(t, fx, "acme", status.Box{GeneratedAt: time.Now(), Label: "pi-1"})
+
+ _, own, ownKnown, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
+ if warn != "" {
+ t.Fatalf("warn = %q, want none", warn)
+ }
+ if !ownKnown {
+ t.Fatalf("fleetsKnown = false on a healthy registry read")
+ }
+ if len(own) != 2 || own[0].Name != "acme" || own[1].Name != "beta" {
+ t.Fatalf("own fleets = %+v, want exactly acme and beta", own)
+ }
+ if own[0].Boxes != 1 || own[0].Reachable != 1 {
+ t.Fatalf("acme = %+v, want 1 box, 1 reachable", own[0])
+ }
+ if own[1].Boxes != 0 || own[1].Reachable != 0 {
+ t.Fatalf("beta = %+v, want no boxes", own[1])
+ }
+
+ c := signInPaul(t, fx)
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet = %d:\n%s", st, body)
+ }
+ if strings.Contains(body, "stranger") {
+ t.Fatalf("another account's fleet leaked onto the tab:\n%s", body)
+ }
+ for _, want := range []string{`href="/a/ops/fleets/acme"`, `href="/a/ops/fleets/beta"`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("fleet tab missing link %q in:\n%s", want, body)
+ }
+ }
+}
+
+// TestAccountFleetDegradesWithoutSnapshot: a deployment whose status-lease
+// holder has never published status/fleet.json still serves the tab — the
+// shared card renders its empty state, and the account's own fleets (a
+// completely independent read) are unaffected.
+func TestAccountFleetDegradesWithoutSnapshot(t *testing.T) {
+ fx, token := fleetFixture(t, "code-acctfleet-nosnapshot")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+
+ c := signInPaul(t, fx)
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet with no published snapshot = %d, want 200 (degrade, don't 500):\n%s", st, body)
+ }
+ if !strings.Contains(body, "No shared boxes are reporting right now.") {
+ t.Fatalf("missing the shared card's empty state:\n%s", body)
+ }
+ if !strings.Contains(body, "acme") {
+ t.Fatalf("the account's own fleets (an independent read) went missing too:\n%s", body)
+ }
+}
+
+// TestAccountFleetUnreadableRegistryClaimsNothing: one failed registry read
+// takes out BOTH registry-fed sections, and neither may fall through to its
+// zero value and make a positive claim from it — not the card ("none of your
+// apps run here", about routes it never looked at) and not the index ("no
+// fleets of your own yet", about a list it never got). "Could not check" and
+// "checked, and no" are different sentences in both places.
+func TestAccountFleetUnreadableRegistryClaimsNothing(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedSharedBoxes(t, fx, "EU · Ireland")
+ c := signInPaul(t, fx)
+ // A path whose parent directory does not exist: registry.Open fails, and
+ // a failed open is never cached (openRegistry, view.go).
+ fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "gone", "registry.db")
+
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet with an unreadable registry = %d, want 200 (degrade, don't 500):\n%s", st, body)
+ }
+ if strings.Contains(body, "None of your apps run on the shared fleet") ||
+ strings.Contains(body, "Not used") {
+ t.Fatalf("an unread registry rendered as a positive \"not used\" verdict:\n%s", body)
+ }
+ if !strings.Contains(body, "Whether your apps run here could not be read just now.") {
+ t.Fatalf("missing the honest unknown-placement state:\n%s", body)
+ }
+ if strings.Contains(body, "No fleets of your own yet") {
+ t.Fatalf("an unread registry rendered as a positive \"you have no fleets\" verdict:\n%s", body)
+ }
+ if !strings.Contains(body, "Your fleets could not be read just now.") {
+ t.Fatalf("missing the honest unknown-fleets state:\n%s", body)
+ }
+ // The half that DID read is still shown.
+ if !strings.Contains(body, "EU · Ireland") {
+ t.Fatalf("the shared card's own (successful) read went missing:\n%s", body)
+ }
+}
+
+// TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned is the positive half of
+// the test above: a HEALTHY registry that genuinely holds no fleets for this
+// account does get the flat "none" wording — the honest-unknown copy must not
+// have swallowed the ordinary empty state along with the lie.
+func TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet = %d:\n%s", st, body)
+ }
+ if !strings.Contains(body, "No fleets of your own yet.") {
+ t.Fatalf("an account with no fleets and a healthy registry lost its empty state:\n%s", body)
+ }
+ if strings.Contains(body, "could not be read just now") {
+ t.Fatalf("a healthy read rendered as unknown:\n%s", body)
+ }
+}
+
+// TestFleetBYOPageMemberReadable: the BYO instructions page is member-
+// readable, the same gate as the Fleet tab itself — a member deciding
+// whether to bring a box needs to read the recipe as much as the owner
+// does. It must carry the exact commands and env var an operator will
+// actually type/set, and the Hardening section the checklist lives under.
+func TestFleetBYOPageMemberReadable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ st, _, body := get(t, c, fx.u("/fleet/byo"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet/byo = %d, want 200:\n%s", st, body)
+ }
+ for _, want := range []string{"carlos fleets create", "CARLOS_TUNNEL_NAME", "Hardening"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("BYO page missing %q in:\n%s", want, body)
+ }
+ }
+}
+
+// TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost
+// the shared card, never the page — the fleets index is registry-side and
+// keeps rendering, with a flash that says which half is unknown.
+func TestAccountFleetDegradesOnStoreFailure(t *testing.T) {
+ fx, token := fleetFixture(t, "code-acctfleet-storefail")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ seedSharedBoxes(t, fx, "EU · Ireland")
+
+ c := signInPaul(t, fx)
+ // Only the aggregate itself is unreadable: the per-fleet heartbeat reads
+ // still work, so a degraded shared card is the ONLY thing this costs.
+ fx.srv.cfg.Releases = errOnKeyStore{Store: fx.rel,
+ key: "status/fleet.json", err: errors.New("bucket having a moment")}
+
+ st, _, body := get(t, c, fx.u("/fleet"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleet with an unreadable store = %d, want 200 (degrade, don't 500):\n%s", st, body)
+ }
+ if !strings.Contains(body, "acme") {
+ t.Fatalf("the registry-side fleets index went missing on a BUCKET failure:\n%s", body)
+ }
+ if !strings.Contains(body, "Could not read the shared fleet") {
+ t.Fatalf("a failed shared-fleet read rendered as if nothing were wrong:\n%s", body)
+ }
+ if strings.Contains(body, "EU · Ireland") {
+ t.Fatalf("a failed read still showed the last-known regions:\n%s", body)
+ }
+}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 32630b3a1..265d2a58c 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -151,7 +151,7 @@ func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Ac
//
// The optional "place" field (2026-08-06 ruling: `carlos apps create -place
// fleet/<f>`) is validated with the EXACT SAME two checks
-// handleAPIPlace applies to its target — grammar (registry.FleetPlacement)
+// placeAppTarget applies to its target — grammar (registry.FleetPlacement)
// and that the fleet belongs to the CALLING account — before claimApp is
// ever called, and with the same refusal: an unknown-or-foreign fleet is
// the byte-identical 404 apiFleet gives everywhere else, never a 400 that
@@ -198,7 +198,7 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
return
}
if f == nil || f.Account != acct.Sqid {
- // Same byte-identical 404 apiFleet and handleAPIPlace's target
+ // Same byte-identical 404 apiFleet and placeAppTarget's target
// check both give an unknown-or-foreign fleet — see this
// handler's doc comment.
http.NotFound(w, r)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index b42d796c3..c8b1df118 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -287,6 +287,7 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", s.handleAPIFleetAddBox)
s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", s.handleAPIFleetRotateBox)
s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", s.handleAPIFleetDetachBox)
+ s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", s.handleAPIFleetTagBox)
// Placement lives under the apps family, not fleets: its subject is an
// APP, and a target fleet name is only ever the request body's data.
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", s.handleAPIPlace)
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index 50d191d08..5d010fd6f 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -49,6 +49,10 @@ import (
type fleetBoxView struct {
Label string `json:"label"`
Status string `json:"status"`
+ // Tags is additive (2026-08-10, Task 2): omitted entirely for an
+ // untagged box, never a bare "" — existing callers parsing this shape
+ // before tags existed see nothing new unless a box actually carries some.
+ Tags string `json:"tags,omitempty"`
}
type fleetView struct {
@@ -238,7 +242,7 @@ func (s *Server) handleAPIFleetList(w http.ResponseWriter, r *http.Request) {
}
bv := make([]fleetBoxView, 0, len(boxes))
for _, b := range boxes {
- bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status})
+ bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status, Tags: b.Tags})
}
out = append(out, fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: bv})
}
@@ -349,6 +353,62 @@ func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"token": plaintext})
}
+// handleAPIFleetTagBox answers `carlos fleets tag-box`: a FULL REPLACE of a
+// box's tag list, never a merge — an empty "tags" clears every tag rather
+// than being refused as a no-op, matching SetFleetBoxTags's own plain
+// overwrite (`UPDATE ... SET tags = ?`, registry/fleets.go): whatever this
+// door validates and passes through is exactly what lands.
+//
+// Grammar (registry.ValidBoxTags) is checked here, before the registry
+// write ever runs — the same "a shape the writer is certain to refuse
+// should never reach it" principle handleAPIFleetNew's name/bucket checks
+// and handleAPIFleetAddBox's label check already follow. The gate order and
+// refusal shapes below deliberately match rotate's: apiFleet's byte-
+// identical unknown/foreign-fleet 404 first, then an unknown LABEL within
+// an already-owned fleet is its own ordinary 404 ("no such box") — see
+// findFleetBox's doc comment for why that one is not the cross-tenant-probe
+// 404.
+func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) {
+ addr, acct, fleet, reg, ok := s.apiFleet(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ var req struct {
+ Tags string `json:"tags"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ tags := strings.TrimSpace(req.Tags)
+ if !registry.ValidBoxTags(tags) {
+ jsonError(w, http.StatusBadRequest,
+ "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
+ return
+ }
+ label := r.PathValue("label")
+ box, err := findFleetBox(reg, fleet.Name, label)
+ if err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ jsonError(w, http.StatusInternalServerError, "could not read the box")
+ return
+ }
+ if box == nil {
+ jsonError(w, http.StatusNotFound, "no such box")
+ return
+ }
+ if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ jsonError(w, http.StatusInternalServerError, "could not tag the box — try again")
+ return
+ }
+ s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{})
+}
+
// handleAPIFleetDetachBox answers `carlos fleets detach-box`: flips a box to
// "detached", which the edge treats as an outright refusal (a dial-in is
// refused, and — unlike rotation — an already-connected channel is closed).
@@ -391,14 +451,14 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request)
//
// 2026-08-06 ruling: the STORE's placement column (Store.AppPlacement) is
// consulted FIRST — the fact `carlos apps place` and `carlos apps create
-// -place` both write (handleAPIPlace, handleAPICreateApp) — because it is
+// -place` both write (placeAppTarget, handleAPICreateApp) — because it is
// the only source that exists the instant an app is placed with zero
// routes yet, which a route-derived reading structurally cannot answer (see
// the KNOWN LIMITATION this comment used to carry, below). Only when the
// store has never recorded a placement for this app (an app placed by an
// OLDER build, before this column existed, still carries its placement
// only in its routes) does this fall back to the original route-derived
-// reading: handleAPIPlace stamps every one of an app's routes with the SAME
+// reading: placeAppTarget stamps every one of an app's routes with the SAME
// target in one call, so by construction they are uniform once placed, and
// the first fleet-shaped Region found among them is authoritative.
//
@@ -406,7 +466,7 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request)
// (apiinstances.go): a fleet-placed app's NEW instances must inherit its
// placement uniformly (spec's "one flag on the app, all its instances
// follow") rather than trusting whatever region a create request's body
-// happens to send — see handleAPIPlace's doc comment for why that
+// happens to send — see placeAppTarget's doc comment for why that
// inheritance has to be a separate mechanism from the route/record stamping
// below, not the same one.
func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) (string, error) {
@@ -434,16 +494,33 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string)
return "", nil
}
-// handleAPIPlace answers `carlos apps place`: repoints where an app's routes
-// (and its already-provisioned instances) should run. target "" clears
-// placement back to platform-served/unconstrained; any other value must
-// parse as "fleet/<name>" (registry.FleetPlacement) naming a fleet the
-// CALLER'S OWN account owns — placing onto another account's fleet is
-// refused with the same byte-identical 404 apiFleet gives an unowned one,
-// since fleet names are a flat, cross-account namespace (see this file's
-// package doc comment).
+// placeAppTarget is placement's whole mechanism, and the ONLY place it
+// lives: repoints where an app's routes (and its already-provisioned
+// instances) should run. target "" clears placement back to
+// platform-served/unconstrained; any other value must parse as
+// "fleet/<name>" (registry.FleetPlacement) naming a fleet acct ITSELF owns —
+// placing onto another account's fleet is refused with the same
+// byte-identical 404 apiFleet gives an unowned one, since fleet names are a
+// flat, cross-account namespace (see this file's package doc comment).
+//
+// Every door onto placement calls this — the CLI API's handleAPIPlace, the
+// app Instances tab's handleAppPlace, the fleet page's
+// handleFleetPlacePage — because the ordering discipline below is the whole
+// correctness of the operation, and a second copy of it is a second place
+// for it to be got wrong. Callers own authentication, the app-ownership
+// gate, rate limiting, the audit line and how the outcome is rendered;
+// nothing else.
+//
+// The return is a translation contract, not an HTTP response: (0, "") means
+// it landed. A non-zero status with a MESSAGE is an ordinary refusal the
+// caller renders in its own idiom (jsonError, or a flash). A non-zero status
+// with an EMPTY message is exactly one case — http.StatusNotFound, the
+// cross-account target refusal — and it means the caller must answer with a
+// byte-identical http.NotFound, never a message of its own: a distinguishable
+// error there would turn this door into an oracle for which fleet names exist
+// in someone else's account.
//
-// THE TRIPWIRE this handler exists to close: placement is stored in
+// THE TRIPWIRE this function exists to close: placement is stored in
// Route.Region (SetPlacement) AND, separately, in every one of the app's
// already-provisioned instance records' Region field
// (provision.SetRecordRegion) — both are read by provision.Reconcile's
@@ -453,9 +530,9 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string)
// Region from the REQUEST BODY (handleAPIInstanceCreate, apiinstances.go),
// and provision.ValidRegion's shape (no "/") would refuse a "fleet/<name>"
// value outright if it somehow arrived that way. A new instance inheriting
-// placement is a SEPARATE mechanism (appPlacement, below, consulted by
+// placement is a SEPARATE mechanism (appPlacement, above, consulted by
// handleAPIInstanceCreate directly) — spec's "one flag on the app, all its
-// instances follow". What THIS handler's record write closes is the gap
+// instances follow". What THIS function's record write closes is the gap
// that inheritance cannot: every instance that ALREADY EXISTS at the moment
// placement changes. Inheritance only ever applies going forward, at
// create time, so without the write below an already-provisioned record
@@ -476,54 +553,35 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string)
// A console with no deployment bucket configured (cfg.Releases == nil)
// refuses the whole request rather than silently doing the routes-only
// half.
-func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
- addr, acct, app, ok := s.apiAccountApp(w, r)
- if !ok {
- return
- }
- if !s.cli.write.allow(addr, time.Now()) {
- jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
- return
- }
+func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string) {
if s.cfg.Releases == nil {
- jsonError(w, http.StatusServiceUnavailable,
- "no deployment bucket on this host — placement needs it to stamp this app's instances")
- return
- }
- var req struct {
- Target string `json:"target"`
- }
- if !readJSON(w, r, &req) {
- return
+ return http.StatusServiceUnavailable,
+ "no deployment bucket on this host — placement needs it to stamp this app's instances"
}
- target := strings.TrimSpace(req.Target)
reg, err := openRegistry(s.cfg.Registry)
if err != nil {
log.Printf("console: place %s/%s: open registry: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusInternalServerError, "could not reach the registry")
- return
+ return http.StatusInternalServerError, "could not reach the registry"
}
if target != "" {
fleetName, validShape := registry.FleetPlacement(target)
if !validShape {
- jsonError(w, http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"`)
- return
+ return http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"`
}
f, err := reg.GetFleet(fleetName)
if err != nil {
log.Printf("console: place %s/%s: get fleet %s: %v", acct.Sqid, app, fleetName, err)
- jsonError(w, http.StatusInternalServerError, "could not check the fleet")
- return
+ return http.StatusInternalServerError, "could not check the fleet"
}
if f == nil || f.Account != acct.Sqid {
// Unknown and not-yours are the same refusal everywhere else in
// this file (apiFleet); place's target check is no exception —
// this door must not become a way to learn which fleet names
- // exist in someone else's account.
- http.NotFound(w, r)
- return
+ // exist in someone else's account. The empty message is the
+ // signal to the caller: answer http.NotFound, say nothing.
+ return http.StatusNotFound, ""
}
}
@@ -531,20 +589,18 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
// THIS before it ever walks a route. It has to land before routes/records
// are stamped below, not after — the same "never observe an app in a
// getting-placed state that some OTHER path already trusts" reasoning
- // the ORDER MATTERS paragraph below applies to routes-before-records, one
+ // the ORDER MATTERS paragraph above applies to routes-before-records, one
// step earlier: a concurrent instance-create must never inherit a target
// this request has not actually committed to.
if err := s.store.SetAppPlacement(acct.ID, app, target); err != nil {
log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusInternalServerError, "could not record placement — try again")
- return
+ return http.StatusInternalServerError, "could not record placement — try again"
}
routes, err := reg.List()
if err != nil {
log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not read the app's routes — try again")
- return
+ return http.StatusBadGateway, "could not read the app's routes — try again"
}
for _, rt := range routes {
if rt.Account != acct.Sqid || rt.App != app {
@@ -552,19 +608,17 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
}
if err := reg.SetPlacement(rt.Host, target); err != nil {
log.Printf("console: place %s/%s: set placement on %s: %v", acct.Sqid, app, rt.Host, err)
- jsonError(w, http.StatusBadGateway, "could not update every route — try again")
- return
+ return http.StatusBadGateway, "could not update every route — try again"
}
}
- ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
defer cancel()
prefix := provision.RecordsPrefix(acct.Sqid, app)
keys, err := s.cfg.Releases.List(ctx, prefix)
if err != nil {
log.Printf("console: place %s/%s: list instances: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not update this app's instances — try again")
- return
+ return http.StatusBadGateway, "could not update this app's instances — try again"
}
for _, key := range keys {
rest, ok := strings.CutPrefix(key, prefix)
@@ -587,10 +641,45 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
continue
}
log.Printf("console: place %s/%s: stamp instance %s: %v", acct.Sqid, app, host, err)
- jsonError(w, http.StatusBadGateway, "could not update every instance — try again")
- return
+ return http.StatusBadGateway, "could not update every instance — try again"
}
}
+ return 0, ""
+}
+
+// handleAPIPlace answers `carlos apps place`: the bearer-authenticated door
+// onto placeAppTarget. Everything that makes placement CORRECT lives in that
+// function (see its doc comment); what belongs here is the CLI API's own
+// share — bearer resolution, the write budget, the JSON body, the "(cli)"
+// audit line, and translating the core's (status, message) onto this API's
+// refusal shapes.
+func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ var req struct {
+ Target string `json:"target"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ target := strings.TrimSpace(req.Target)
+
+ switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
+ case status == http.StatusNotFound:
+ // The cross-account target refusal — byte-identical to any other
+ // not-found, never a JSON message of its own.
+ http.NotFound(w, r)
+ return
+ case status != 0:
+ jsonError(w, status, msg)
+ return
+ }
detail := "cleared placement for " + app + " (cli)"
if target != "" {
@@ -600,6 +689,170 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{})
}
+// placeFlash is the one sentence both browser doors say about an outcome,
+// and the one audit detail both record — kept together here so the two forms
+// can never describe the same act differently. suffix is the door's own
+// provenance mark ("(console)"), the same convention the fleet-box page
+// doors use against the CLI API's "(cli)".
+func placeFlash(app, target, suffix string) (flash, detail string) {
+ if target == "" {
+ return app + " is back on the shared fleet.", "cleared placement for " + app + " " + suffix
+ }
+ return app + " is now placed on " + target + ".", "placed " + app + " on " + target + " " + suffix
+}
+
+// handleAppPlace answers the Placement form on an app's Instances tab: move
+// THIS app onto one of the account's fleets, or back to the shared one.
+//
+// The gate skeleton is handleAppRename's, deliberately — accountApp, then
+// CSRF, then owner (a member gets the byte-identical 404, never a 403 that
+// would confirm both the app's existence and the caller's under-privilege).
+// The move itself is placeAppTarget's; this door only translates its outcome
+// into a flash, except for the cross-account target refusal, which it must
+// answer as an ordinary http.NotFound and nothing else (see the core's doc
+// comment on why an empty message is that signal).
+func (s *Server) handleAppPlace(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances",
+ http.StatusSeeOther)
+ }
+ target := strings.TrimSpace(r.FormValue("target"))
+ switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
+ case status == http.StatusNotFound:
+ http.NotFound(w, r)
+ return
+ case status != 0:
+ back("err", msg)
+ return
+ }
+ flash, detail := placeFlash(app, target, "(console)")
+ s.audit(acct, app, addr, "place", detail)
+ back("ok", flash)
+}
+
+// handleFleetPlacePage answers the fleet page's "Place an app here" form: the
+// same move from the other end — this fleet is fixed, the app is chosen.
+//
+// ownerFleet proves the FLEET is this account's; it says nothing about the
+// app named in the body, so HasAppIn is a second, independent gate here. Its
+// refusal is accountApp's own "No such app here." 404, which is the same
+// answer a mistyped name gets: app names are per-account, so this reveals
+// nothing about another account's apps.
+func (s *Server) handleFleetPlacePage(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, fleet, _, ok := s.ownerFleet(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
+ }
+ app := strings.TrimSpace(r.FormValue("app"))
+ has, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ log.Printf("console: fleet place %s/%s: has app %s: %v", acct.Sqid, fleet.Name, app, err)
+ back("err", "Could not read your apps — try again.")
+ return
+ }
+ if !has {
+ s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
+ return
+ }
+ target := registry.PlacementTarget(fleet.Name)
+ switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
+ case status == http.StatusNotFound:
+ http.NotFound(w, r)
+ return
+ case status != 0:
+ back("err", msg)
+ return
+ }
+ flash, detail := placeFlash(app, target, "(console)")
+ s.audit(acct, app, addr, "place", detail)
+ back("ok", flash)
+}
+
+// appPlacementSection reads the app Instances tab's Placement section: where
+// this app runs now, and every fleet the account owns to offer as somewhere
+// else it could.
+//
+// known is the section's twin of sharedFleetCard.PlacementKnown and exists
+// for the identical reason: "" is both "this app runs on the shared fleet"
+// and "the read that would have told us failed", and only the first of those
+// is a sentence this page has earned. False means the section says it could
+// not check, rather than making the positive claim — and withholds the form,
+// whose pre-selected option would otherwise be a guess.
+func (s *Server) appPlacementSection(acct *Account, app string) (placement string, fleets []string, known bool, warn string) {
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: app placement %s/%s: open registry: %v", acct.Sqid, app, err)
+ return "", nil, false, "Could not reach the fleet registry."
+ }
+ placement, err = s.appPlacement(reg, acct, app)
+ if err != nil {
+ log.Printf("console: app placement %s/%s: %v", acct.Sqid, app, err)
+ return "", nil, false, "Could not read where this app runs."
+ }
+ rows, err := reg.ListFleets()
+ if err != nil {
+ log.Printf("console: app placement %s/%s: list fleets: %v", acct.Sqid, app, err)
+ // Placement itself IS known — only the list of alternatives is
+ // missing, which costs the form and nothing else.
+ return placement, nil, true, "Could not list your fleets."
+ }
+ for _, f := range rows {
+ // Fleet names are one flat, cross-account namespace (this file's
+ // package doc comment): this filter is the only thing keeping another
+ // account's fleet names off the page. ListFleets is already ORDER BY
+ // name, so the offered order is stable without a second sort.
+ if f.Account == acct.Sqid {
+ fleets = append(fleets, f.Name)
+ }
+ }
+ return placement, fleets, true, ""
+}
+
+// fleetPlaceableApps is what the fleet page's own form may offer: the
+// account's live apps that are not ALREADY placed here, derived from the
+// placed-apps grouping loadFleetPage just walked rather than a second route
+// pass. A failed apps read costs the form (nil), never the page — the same
+// soft-fail posture everything else on this page has.
+func (s *Server) fleetPlaceableApps(acct *Account, placed []fleetPagePlacedApp) []string {
+ apps, err := s.store.AppsIn(acct.ID)
+ if err != nil {
+ log.Printf("console: fleet page %s: list apps: %v", acct.Sqid, err)
+ return nil
+ }
+ here := make(map[string]bool, len(placed))
+ for _, p := range placed {
+ here[p.App] = true
+ }
+ out := make([]string, 0, len(apps))
+ for _, a := range apps {
+ if !here[a] {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
// ---- the console fleet page (Task 9) --------------------------------------
//
// GET /a/{sqid}/fleets/{fleet} is member-readable (any role sees the boxes
@@ -637,6 +890,10 @@ type fleetPageBox struct {
// never-joined box shows no numbers at all, ever (mirrors HostRows).
Tunnel string
Metrics *status.BoxMetrics
+ // Tags is registry.FleetBox.Tags (2026-08-10, Task 3) split on "," — nil
+ // for an untagged box, never a slice holding one empty string, so the
+ // template's {{if not .Tags}} placeholder check is a plain nil test.
+ Tags []string
}
// fleetPagePlacedApp is one app placed on this fleet: its name and the
@@ -753,6 +1010,9 @@ func (s *Server) loadFleetPage(ctx context.Context, reg *registry.Registry, acct
boxes = make([]fleetPageBox, 0, len(regBoxes))
for _, b := range regBoxes {
row := fleetPageBox{Label: b.Label, Status: b.Status}
+ if b.Tags != "" {
+ row.Tags = strings.Split(b.Tags, ",")
+ }
// Join key is the label, per the Task 6 identity ruling in this
// function's doc comment — an orphan heartbeat under some OTHER
// label (a box heartbeating under a hashed pseudonym) simply never
@@ -811,10 +1071,12 @@ func (s *Server) handleFleetPage(w http.ResponseWriter, r *http.Request) {
}
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = fleet.Name
+ p.ActiveNav = "fleet"
p.FleetDetail = fleet
boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet)
p.FleetBoxes = boxes
p.FleetApps = apps
+ p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps)
if warn != "" && p.FlashErr == "" {
p.FlashErr = warn
}
@@ -877,6 +1139,7 @@ func (s *Server) handleFleetRotateBoxPage(w http.ResponseWriter, r *http.Request
boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet)
p.FleetBoxes = boxes
p.FleetApps = apps
+ p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps)
p.FlashErr = warn
p.RotatedLabel = label
p.RotatedToken = plaintext
@@ -925,3 +1188,53 @@ func (s *Server) handleFleetDetachBoxPage(w http.ResponseWriter, r *http.Request
s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (console)")
back("ok", "Detached "+fleet.Name+"/"+label+".")
}
+
+// handleFleetTagBoxPage answers the owner's "Tag" form: the same full-replace
+// SetFleetBoxTags write handleAPIFleetTagBox makes, gate/flash/redirect
+// skeleton copied from handleFleetRotateBoxPage/handleFleetDetachBoxPage —
+// ownerFleet then CSRF, then an ordinary redirect-with-flash outcome (no
+// secret in the body, so there is no reason for this to render directly the
+// way rotate's response does).
+//
+// Grammar (registry.ValidBoxTags) is checked here, before the registry write
+// ever runs and before the label is even looked up — reject, don't sanitize,
+// the same order and the same user-facing copy handleAPIFleetTagBox already
+// uses, so a bad-grammar submission never touches the row regardless of
+// whether the named box exists.
+func (s *Server) handleFleetTagBoxPage(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
+ }
+ tags := strings.TrimSpace(r.FormValue("tags"))
+ if !registry.ValidBoxTags(tags) {
+ back("err", "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
+ return
+ }
+ label := strings.TrimSpace(r.FormValue("label"))
+ box, err := findFleetBox(reg, fleet.Name, label)
+ if err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ back("err", "Could not read the box.")
+ return
+ }
+ if box == nil {
+ back("err", "No such box.")
+ return
+ }
+ if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil {
+ log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
+ back("err", "Could not tag the box — try again.")
+ return
+ }
+ s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (console)")
+ back("ok", "Tagged "+fleet.Name+"/"+label+".")
+}
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go
index 860351ca6..adaf2e77c 100644
--- a/internal/console/fleets_test.go
+++ b/internal/console/fleets_test.go
@@ -220,6 +220,82 @@ func TestFleetDetachBoxFlipsStatus(t *testing.T) {
}
}
+// TestAPIFleetTagBox: a full-replace tag write lands in the registry and
+// shows up on the list door's "tags" field; bad grammar 400s; an unknown
+// label 404s byte-identical to rotate's own unknown-label 404 (both are
+// jsonError's "no such box", not the plain apiFleet 404); a foreign-owned
+// fleet 404s the ordinary apiFleet way (byte-identical to canonicalNotFound,
+// same as every other door in TestFleetCrossAccountAccess404).
+func TestAPIFleetTagBox(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleet-tagbox")
+ if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "pi", "bucket": "pi-data"}, token); status != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", status, body)
+ }
+ if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes", map[string]string{"label": "box-a"}, token); status != http.StatusOK {
+ t.Fatalf("add-box = %d %v", status, body)
+ }
+
+ status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags",
+ map[string]string{"tags": "office,free"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("tag-box = %d %v, want 200", status, body)
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("pi")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("registry boxes = %+v, want tags office,free", boxes)
+ }
+
+ _, listBody := getJSON(t, fleetsURL(fx, fx.sqid), token)
+ fleets := listBody["fleets"].([]any)
+ f := fleets[0].(map[string]any)
+ bx := f["boxes"].([]any)[0].(map[string]any)
+ if bx["tags"] != "office,free" {
+ t.Fatalf("listed box tags = %v, want office,free", bx["tags"])
+ }
+
+ // Bad grammar (uppercase) is a 400, and must not have touched the row.
+ status, body = postJSON(t, fleetsURL(fx, fx.sqid)+"/pi/boxes/box-a/tags",
+ map[string]string{"tags": "Office"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad-grammar tag-box = %d %v, want 400", status, body)
+ }
+ boxes, err = reg.ListFleetBoxes("pi")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boxes[0].Tags != "office,free" {
+ t.Fatalf("registry tags after rejected write = %q, want unchanged office,free", boxes[0].Tags)
+ }
+
+ // Unknown label: byte-identical to rotate's own unknown-label 404.
+ rotStatus, rotHeader, rotBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/rotate", token, `{}`)
+ tagStatus, tagHeader, tagBody := rawReq(t, http.MethodPost, fleetsURL(fx, fx.sqid)+"/pi/boxes/ghost/tags", token, `{"tags":"x"}`)
+ if tagStatus != http.StatusNotFound {
+ t.Fatalf("tag-box unknown label = %d, want 404", tagStatus)
+ }
+ if tagStatus != rotStatus || tagBody != rotBody || tagHeader.Get("Content-Type") != rotHeader.Get("Content-Type") {
+ t.Fatalf("tag-box unknown-label 404 = %d %q (%s), want byte-identical to rotate's %d %q (%s)",
+ tagStatus, tagBody, tagHeader.Get("Content-Type"), rotStatus, rotBody, rotHeader.Get("Content-Type"))
+ }
+
+ // Foreign-owned fleet: the ordinary apiFleet 404, same as every other
+ // door in TestFleetCrossAccountAccess404.
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bobToken := cliToken(t, fx, "bob@keymail.dev", "code-fleet-tagbox-bob")
+ want := canonicalNotFound(t)
+ fStatus, fHeader, fOut := rawReq(t, http.MethodPost, fleetsURL(fx, "cdef")+"/pi/boxes/box-a/tags", bobToken, `{"tags":"x"}`)
+ assertNotFound(t, "tag-box on a foreign-owned fleet", want, fStatus, fHeader, fOut)
+}
+
// TestFleetCrossAccountAccess404: a fleet named under the caller's OWN
// account segment but owned by a DIFFERENT account resolves the exact same
// bytes an outright nonexistent fleet name does — fleet names are one flat
@@ -877,3 +953,558 @@ func TestFleetPageRotateShowsTokenOnceWithWarning(t *testing.T) {
t.Fatalf("no 64-hex token in the rotate response hashes to the stored TokenHash %s:\n%s", boxes[0].TokenHash, body)
}
}
+
+// TestFleetPageTagsChipsRender: a box carrying tags renders each one as its
+// own chip, both on the owner's page and a member's — chips are read-only
+// display, not a mutation surface, so they belong to the same body a member
+// already sees the rest of the row in (TestFleetPageOwnerControlsMemberReadOnly).
+// An untagged box renders the em-dash placeholder instead of an empty cell.
+func TestFleetPageTagsChipsRender(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-chips")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ for _, label := range []string{"pi-1", "pi-2"} {
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": label}, token); st != http.StatusOK {
+ t.Fatalf("add-box %s = %d %v", label, st, body)
+ }
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
+ map[string]string{"tags": "office,free"}, token); st != http.StatusOK {
+ t.Fatalf("tag-box = %d %v", st, body)
+ }
+
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-chips-browser")
+ st, _, body := get(t, c, fx.u("/fleets/acme"))
+ if st != http.StatusOK {
+ t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body)
+ }
+ for _, want := range []string{`<span class="chip chip-env">office</span>`, `<span class="chip chip-env">free</span>`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("tagged box missing chip %q in:\n%s", want, body)
+ }
+ }
+ // pi-2 carries no tags: an em-dash, never an empty cell nor pi-1's chips
+ // leaking onto its row.
+ if !strings.Contains(body, "pi-2") {
+ t.Fatalf("untagged box pi-2 missing from the page:\n%s", body)
+ }
+
+ // A member sees the same chips (read-only display, no gate on it) but
+ // none of the tag form's own markup.
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-fleetpage-tags-chips-member")
+ memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme"))
+ if memberStatus != http.StatusOK {
+ t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody)
+ }
+ for _, want := range []string{`<span class="chip chip-env">office</span>`, `<span class="chip chip-env">free</span>`} {
+ if !strings.Contains(memberBody, want) {
+ t.Fatalf("member view missing chip %q in:\n%s", want, memberBody)
+ }
+ }
+ if strings.Contains(memberBody, "/fleets/acme/tags") {
+ t.Fatalf("member fleet page renders the tag mutation form it must not have:\n%s", memberBody)
+ }
+}
+
+// TestFleetPageTagBoxOwnerUpdatesAndRedirects: the owner's Tag form performs
+// a full-replace write (same as handleAPIFleetTagBox) and redirects back to
+// the fleet page with an ordinary ok flash — no secret involved, unlike
+// rotate, so there is no reason for this to render directly the way
+// handleFleetRotateBoxPage's response does.
+func TestFleetPageTagBoxOwnerUpdatesAndRedirects(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-owner")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-owner-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office,free"}})
+ if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
+ t.Fatalf("tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("registry boxes = %+v, want tags office,free", boxes)
+ }
+
+ _, _, body := get(t, c, fx.u("/fleets/acme"))
+ if !strings.Contains(body, "Tagged acme/pi-1.") {
+ t.Fatalf("no tag flash after redirect:\n%s", body)
+ }
+ if !strings.Contains(body, `<span class="chip chip-env">office</span>`) {
+ t.Fatalf("new tags did not render as chips after the redirect:\n%s", body)
+ }
+}
+
+// TestFleetPageTagBoxMemberGetsSameRefusalAsRotate: a plain member posting
+// the tag form gets the exact same refusal ownerFleet already gives rotate
+// and detach — the gate is s.owner (role != RoleOwner -> 404, matching a
+// non-member's genuinely-unrouted 404), never a distinguishable 403.
+func TestFleetPageTagBoxMemberGetsSameRefusalAsRotate(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-member")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-member-browser")
+ csrf := sessionToken(t, fx, c)
+
+ tagStatus, _, tagBody := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"office"}})
+ rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/acme/rotate"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}})
+ if tagStatus != http.StatusNotFound {
+ t.Fatalf("member tag-box = %d, want 404", tagStatus)
+ }
+ if tagStatus != rotStatus || tagBody != rotBody {
+ t.Fatalf("member tag-box refusal (%d %q) not byte-identical to rotate's (%d %q)",
+ tagStatus, tagBody, rotStatus, rotBody)
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boxes[0].Tags != "" {
+ t.Fatalf("member's refused tag POST still wrote tags: %+v", boxes)
+ }
+}
+
+// TestFleetPageTagBoxWithoutCSRFForbidden: the tag form is CSRF-gated like
+// every other mutation on this console — the same 403 rotate's CSRF check
+// gives (TestFleetPageRotateWithoutCSRFForbidden).
+func TestFleetPageTagBoxWithoutCSRFForbidden(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-csrf")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-csrf-browser")
+ st, _, _ := postForm(t, c, fx.u("/fleets/acme/tags"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}, "tags": {"office"}})
+ if st != http.StatusForbidden {
+ t.Fatalf("tag-box with a bad csrf = %d, want 403", st)
+ }
+}
+
+// TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten: reject, don't
+// sanitize — invalid grammar (uppercase, here) never reaches SetFleetBoxTags,
+// the same "checked before the write, not after" posture
+// handleAPIFleetTagBox already takes, and the flash copy matches Task 2's
+// API error text verbatim.
+func TestFleetPageTagBoxBadGrammarFlashesErrorNothingWritten(t *testing.T) {
+ fx, token := fleetFixture(t, "code-fleetpage-tags-badgrammar")
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes",
+ map[string]string{"label": "pi-1"}, token); st != http.StatusOK {
+ t.Fatalf("add-box = %d %v", st, body)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/tags",
+ map[string]string{"tags": "office"}, token); st != http.StatusOK {
+ t.Fatalf("seed tag-box = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetpage-tags-badgrammar-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/fleets/acme/tags"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}, "tags": {"Office"}})
+ if status != http.StatusSeeOther || loc != fx.path("/fleets/acme") {
+ t.Fatalf("bad-grammar tag-box = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/acme"))
+ }
+
+ _, _, body := get(t, c, fx.u("/fleets/acme"))
+ if !strings.Contains(body, "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8") {
+ t.Fatalf("bad-grammar tag-box did not flash the expected error:\n%s", body)
+ }
+
+ reg := openTestReg(t, fx)
+ boxes, err := reg.ListFleetBoxes("acme")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if boxes[0].Tags != "office" {
+ t.Fatalf("registry tags after rejected write = %q, want unchanged office", boxes[0].Tags)
+ }
+}
+
+// ---- Task 6: the browser placement doors ---------------------------------
+//
+// Two forms, one mechanism: the app's Instances tab moves THIS app to any of
+// the account's fleets (or back to the shared one), and the fleet page moves
+// any of the account's apps onto THIS fleet. Both call placeAppTarget, so
+// what these tests pin is the doors' own share — the owner gate, CSRF, the
+// cross-account 404, the redirect, and that the same routes-and-records
+// stamping the CLI API's tests already prove really happened.
+
+// placeFixture is a console with titogo claimed, two of its routes and one
+// already-provisioned instance record seeded, and an owned fleet "pi" to
+// move it onto. Routes are seeded through a second handle BEFORE the console
+// opens its own (openTestReg's doc comment).
+func placeFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx, token := fleetFixture(t, code)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ reg := openTestReg(t, fx)
+ for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Region "" throughout: the app starts genuinely unplaced, so "nothing
+ // moved" and "moved back to the shared fleet" are the same assertion —
+ // assertPlacement can check one value across all three stores. (The API
+ // door's own tripwire test covers stamping OVER a pre-existing region.)
+ rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo",
+ Account: fx.sqid, Channel: "stable", CreatedAt: now.UTC()}
+ if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+ if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new",
+ map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); st != http.StatusOK {
+ t.Fatalf("create fleet = %d %v", st, body)
+ }
+ return fx, token
+}
+
+// assertPlacement is the whole tripwire in one call: the store of record, the
+// app's routes, and its already-provisioned instance record all agree on
+// where the app runs. Anything less is the routes-only half-move the API
+// door's own tests (TestPlaceStampsRoutesAndInstances) exist to refuse.
+func assertPlacement(t *testing.T, fx *consoleFixture, want string) {
+ t.Helper()
+ if got, err := fx.st.AppPlacement(OpsAccountID, "titogo"); err != nil || got != want {
+ t.Fatalf("store placement = %q %v, want %q", got, err, want)
+ }
+ reg := openTestReg(t, fx)
+ for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} {
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil {
+ t.Fatalf("route %s: %v", host, err)
+ }
+ if rt.Region != want {
+ t.Fatalf("route %s Region = %q, want %q", host, rt.Region, want)
+ }
+ }
+ got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Region != want {
+ t.Fatalf("instance record Region = %q, want %q", got.Region, want)
+ }
+}
+
+// TestAppPlacePageMovesAndClears: the owner's Placement form on the app's
+// Instances tab stamps routes AND records (placeAppTarget's whole job) and
+// comes back to the tab with a flash saying what happened — then clears the
+// same way, which is a MOVE BACK to the shared fleet, not a no-op.
+func TestAppPlacePageMovesAndClears(t *testing.T) {
+ fx, _ := placeFixture(t, "code-appplace-move")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-appplace-move-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/place"),
+ url.Values{"csrf": {csrf}, "target": {"fleet/pi"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") {
+ t.Fatalf("place = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances"))
+ }
+ assertPlacement(t, fx, "fleet/pi")
+
+ _, _, body := get(t, c, fx.u("/apps/titogo/instances"))
+ if !strings.Contains(body, "placed on fleet/pi") {
+ t.Fatalf("no placed flash after the redirect:\n%s", body)
+ }
+
+ status, loc, _ = postForm(t, c, fx.u("/apps/titogo/place"),
+ url.Values{"csrf": {csrf}, "target": {""}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/instances") {
+ t.Fatalf("clear = %d -> %q, want 303 -> %q", status, loc, fx.path("/apps/titogo/instances"))
+ }
+ assertPlacement(t, fx, "")
+ _, _, body = get(t, c, fx.u("/apps/titogo/instances"))
+ if !strings.Contains(body, "back on the shared fleet") {
+ t.Fatalf("no cleared flash after the redirect:\n%s", body)
+ }
+}
+
+// TestAppPlacePageMemberRefused: placement is an owner act. A plain member
+// gets the same byte-identical 404 every other owner-only app POST gives —
+// never a 403 that would confirm the app is there and the caller merely
+// under-privileged — and nothing moves.
+func TestAppPlacePageMemberRefused(t *testing.T) {
+ fx, _ := placeFixture(t, "code-appplace-member")
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-appplace-member-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/place"),
+ url.Values{"csrf": {csrf}, "target": {"fleet/pi"}})
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("member place = %d %q, want the unrouted page's %d %q", status, body, ghostStatus, ghostBody)
+ }
+ assertPlacement(t, fx, "")
+}
+
+// TestAppPlacePageWithoutCSRFForbidden: the form is CSRF-gated like every
+// other mutation on this console.
+func TestAppPlacePageWithoutCSRFForbidden(t *testing.T) {
+ fx, _ := placeFixture(t, "code-appplace-csrf")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-appplace-csrf-browser")
+
+ status, _, _ := postForm(t, c, fx.u("/apps/titogo/place"),
+ url.Values{"csrf": {"wrong"}, "target": {"fleet/pi"}})
+ if status != http.StatusForbidden {
+ t.Fatalf("place with a bad csrf = %d, want 403", status)
+ }
+ assertPlacement(t, fx, "")
+}
+
+// TestAppPlacePageForeignFleetIsIdenticalNotFound: the browser twin of
+// TestPlaceRefusesForeignFleet. A target naming a fleet ANOTHER account owns
+// must answer exactly what an unrouted URL does — fleet names are one flat,
+// cross-account namespace, so a distinguishable refusal here would be a
+// cross-account existence oracle.
+func TestAppPlacePageForeignFleetIsIdenticalNotFound(t *testing.T) {
+ fx, _ := placeFixture(t, "code-appplace-foreign")
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ bobToken := cliToken(t, fx, "bob@keymail.dev", "code-appplace-foreign-bob")
+ if st, body := postJSON(t, fleetsURL(fx, "cdef")+"/new",
+ map[string]string{"name": "widgets", "bucket": "bob-data"}, bobToken); st != http.StatusOK {
+ t.Fatalf("bob's fleet create = %d %v", st, body)
+ }
+
+ fx.setUser("paul@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-appplace-foreign-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/place"),
+ url.Values{"csrf": {csrf}, "target": {"fleet/widgets"}})
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("place onto a foreign fleet = %d %q, want the unrouted page's %d %q",
+ status, body, ghostStatus, ghostBody)
+ }
+ assertPlacement(t, fx, "")
+}
+
+// TestAppInstancesPlacementSection: the tab states where the app runs for
+// everyone, and offers the move only to the owner — with the
+// data-does-not-move warning beside the form, because "move" is the one word
+// a customer could reasonably read as "take my data with it".
+func TestAppInstancesPlacementSection(t *testing.T) {
+ fx, _ := placeFixture(t, "code-appplace-section")
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-appplace-section-owner")
+ st, _, body := get(t, owner, fx.u("/apps/titogo/instances"))
+ if st != http.StatusOK {
+ t.Fatalf("GET instances = %d:\n%s", st, body)
+ }
+ for _, want := range []string{
+ "<h2>Placement</h2>",
+ "This app runs on the shared fleet.",
+ `action="` + fx.path("/apps/titogo/place") + `"`,
+ `<option value="fleet/pi"`,
+ // The D6 warning, rendered across two source lines — matched in the
+ // two halves the template actually emits (and stopped short of the
+ // apostrophe html/template escapes).
+ "Data does not move:",
+ "hibernated instance state stays in the old placement",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("owner instances tab missing %q in:\n%s", want, body)
+ }
+ }
+
+ fx.setUser("sam@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-appplace-section-member")
+ st, _, memberBody := get(t, member, fx.u("/apps/titogo/instances"))
+ if st != http.StatusOK {
+ t.Fatalf("member GET instances = %d:\n%s", st, memberBody)
+ }
+ if !strings.Contains(memberBody, "This app runs on the shared fleet.") {
+ t.Fatalf("member instances tab lost the placement statement:\n%s", memberBody)
+ }
+ if strings.Contains(memberBody, fx.path("/apps/titogo/place")) {
+ t.Fatalf("member instances tab renders the move form it must not have:\n%s", memberBody)
+ }
+}
+
+// TestAppInstancesPlacementShowsFleet: once placed, the tab names the fleet
+// and links to its page, and the form's current fleet is the selected option
+// rather than a form that reads as "unplaced" over a placed app.
+func TestAppInstancesPlacementShowsFleet(t *testing.T) {
+ fx, token := placeFixture(t, "code-appplace-shows")
+ if st, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token); st != http.StatusOK {
+ t.Fatalf("place = %d %v", st, body)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-appplace-shows-browser")
+ st, _, body := get(t, c, fx.u("/apps/titogo/instances"))
+ if st != http.StatusOK {
+ t.Fatalf("GET instances = %d:\n%s", st, body)
+ }
+ if !strings.Contains(body, "/fleets/pi") || !strings.Contains(body, "<code>pi</code>") {
+ t.Fatalf("placed app's tab does not name and link its fleet:\n%s", body)
+ }
+ if !strings.Contains(body, `<option value="fleet/pi" selected>`) {
+ t.Fatalf("the current fleet is not the form's selected option:\n%s", body)
+ }
+}
+
+// TestFleetPagePlaceFormPlacesApp: the fleet page's own form is the same move
+// from the other end — pick one of the account's apps, and it lands here.
+func TestFleetPagePlaceFormPlacesApp(t *testing.T) {
+ fx, _ := placeFixture(t, "code-fleetplace-form")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetplace-form-browser")
+ csrf := sessionToken(t, fx, c)
+
+ st, _, body := get(t, c, fx.u("/fleets/pi"))
+ if st != http.StatusOK {
+ t.Fatalf("GET fleet page = %d:\n%s", st, body)
+ }
+ for _, want := range []string{
+ `action="` + fx.path("/fleets/pi/place") + `"`,
+ `<option value="titogo">titogo</option>`,
+ // The D6 warning, rendered across two source lines — matched in the
+ // two halves the template actually emits (and stopped short of the
+ // apostrophe html/template escapes).
+ "Data does not move:",
+ "hibernated instance state stays in the old placement",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("fleet page missing %q in:\n%s", want, body)
+ }
+ }
+
+ status, loc, _ := postForm(t, c, fx.u("/fleets/pi/place"),
+ url.Values{"csrf": {csrf}, "app": {"titogo"}})
+ if status != http.StatusSeeOther || loc != fx.path("/fleets/pi") {
+ t.Fatalf("fleet place = %d -> %q, want 303 -> %q", status, loc, fx.path("/fleets/pi"))
+ }
+ assertPlacement(t, fx, "fleet/pi")
+
+ _, _, body = get(t, c, fx.u("/fleets/pi"))
+ if !strings.Contains(body, "placed on fleet/pi") {
+ t.Fatalf("no placed flash on the fleet page:\n%s", body)
+ }
+ // The app is now among the placed apps, so it is no longer offered as
+ // something to place HERE.
+ if strings.Contains(body, `<option value="titogo">titogo</option>`) {
+ t.Fatalf("an app already placed on this fleet is still offered as placeable:\n%s", body)
+ }
+}
+
+// TestFleetPagePlaceFormMemberRefused: same gate as the fleet page's other
+// mutations — a member's POST is byte-identical to rotate's refusal.
+func TestFleetPagePlaceFormMemberRefused(t *testing.T) {
+ fx, _ := placeFixture(t, "code-fleetplace-member")
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetplace-member-browser")
+ csrf := sessionToken(t, fx, c)
+
+ placeStatus, _, placeBody := postForm(t, c, fx.u("/fleets/pi/place"),
+ url.Values{"csrf": {csrf}, "app": {"titogo"}})
+ rotStatus, _, rotBody := postForm(t, c, fx.u("/fleets/pi/rotate"),
+ url.Values{"csrf": {csrf}, "label": {"pi-1"}})
+ if placeStatus != http.StatusNotFound || placeStatus != rotStatus || placeBody != rotBody {
+ t.Fatalf("member fleet place refusal (%d %q) not byte-identical to rotate's (%d %q)",
+ placeStatus, placeBody, rotStatus, rotBody)
+ }
+ assertPlacement(t, fx, "")
+
+ // And the form is not even offered on their page.
+ _, _, body := get(t, c, fx.u("/fleets/pi"))
+ if strings.Contains(body, fx.path("/fleets/pi/place")) {
+ t.Fatalf("member fleet page renders the place form it must not have:\n%s", body)
+ }
+}
+
+// TestFleetPagePlaceFormForeignApp: an app the account does not claim is the
+// same "no such app here" a mistyped app name gets on any other app door —
+// the fleet's own ownership having already been proven says nothing about
+// the app named in the body.
+func TestFleetPagePlaceFormForeignApp(t *testing.T) {
+ fx, _ := placeFixture(t, "code-fleetplace-foreignapp")
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "bobapp", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-fleetplace-foreignapp-browser")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, _ := postForm(t, c, fx.u("/fleets/pi/place"),
+ url.Values{"csrf": {csrf}, "app": {"bobapp"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("placing another account's app = %d, want 404", status)
+ }
+ if got, err := fx.st.AppPlacement(42, "bobapp"); err != nil || got != "" {
+ t.Fatalf("another account's app placement = %q %v, want untouched", got, err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index b23e8f31c..1f3a659d6 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -133,6 +133,17 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// set, the licence, and why template.HTML is safe there and nowhere
// else. Chrome is drawn; emoji stay content.
"icon": icon,
+ // join renders fleetPageBox.Tags back into the tag form's editable
+ // text input, comma-separated — the exact grammar SetFleetBoxTags
+ // expects back, so an owner opening the form sees the same string they'd
+ // type to leave it unchanged.
+ "join": strings.Join,
+ // fleetName reads the fleet out of a placement target ("fleet/pi" -> "pi")
+ // so the Placement section can NAME the fleet and link to its page rather
+ // than printing the raw Region value at a customer. A target that does not
+ // parse yields "", which the template's own {{if}} already handles as
+ // "not placed" — the same reading appPlacement itself takes.
+ "fleetName": func(target string) string { f, _ := registry.FleetPlacement(target); return f },
}).ParseFS(tmplFS, "templates/*.html"))
const (
@@ -310,6 +321,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
// account-by-session, so a link a teammate opens lands where you meant.
s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome)
+ s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab)
+ s.mux.HandleFunc("GET /a/{sqid}/fleet/byo", s.handleFleetBYO)
s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings)
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why
@@ -330,6 +343,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary)
// The app Settings writes (appsettings.go): owner-only, and delete/move
// behind passkey step-up besides.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
@@ -340,6 +354,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/fleets/{fleet}", s.handleFleetPage)
s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage)
s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage)
+ s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/tags", s.handleFleetTagBoxPage)
+ s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/place", s.handleFleetPlacePage)
s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
// Owner-only account administration, every one of them behind passkey
@@ -440,7 +456,7 @@ type page struct {
Error string
Welcome bool // one-shot "account created" banner
- ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+ ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings"
ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
AppName string // app pages: the {app} segment, for the tab bar and eyebrow
@@ -534,6 +550,34 @@ type page struct {
FleetDetail *registry.Fleet
FleetBoxes []fleetPageBox
FleetApps []fleetPagePlacedApp
+ // FleetPlaceableApps is the fleet page's "Place an app here" form: the
+ // account's apps that are not already placed on THIS fleet
+ // (fleetPlaceableApps). Empty means the form renders nothing to choose,
+ // so the form itself is withheld — never an empty select.
+ FleetPlaceableApps []string
+
+ // App Instances tab, "Placement" (Task 6): where this app runs
+ // ("fleet/<name>", or "" for the shared fleet), and the account's own
+ // fleets as the owner's move options. AppPlacementKnown false means the
+ // read FAILED — the section then says so instead of claiming the shared
+ // fleet, and the move form is withheld rather than pre-selecting a guess.
+ AppPlacement string
+ AppPlacementKnown bool
+ OwnedFleetNames []string
+
+ // /a/{sqid}/fleet (accountfleet.go): the account-level Fleet TAB, which
+ // is neither of the two pages above — the shared platform fleet as this
+ // account sees it (built from the PUBLIC status/fleet.json aggregate,
+ // never the raw per-box snapshots) and an index of the account's own
+ // fleets. Set only by handleAccountFleetTab; every other page leaves
+ // SharedFleet nil and renders no such section.
+ // FleetsKnown says the AccountFleets read SUCCEEDED, so an empty slice
+ // may be rendered as "no fleets yet" rather than "unknown" — the twin of
+ // sharedFleetCard.PlacementKnown, and load-bearing for the same reason:
+ // nil is otherwise both answers at once.
+ SharedFleet *sharedFleetCard
+ AccountFleets []accountFleetCard
+ FleetsKnown bool
// RotatedLabel/RotatedToken are set only on the response to a rotate
// POST, to show the fresh bearer token exactly once — never stored,
// never re-derivable from a later view of this same page.
@@ -1577,6 +1621,16 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics)
+ // Placement (Task 6): where this app runs, and where else it could. Soft
+ // like everything else on this tab — a failed read costs the section's
+ // certainty (AppPlacementKnown) and a flash, never the page.
+ placement, fleets, known, warn := s.appPlacementSection(acct, p.AppName)
+ p.AppPlacement = placement
+ p.OwnedFleetNames = fleets
+ p.AppPlacementKnown = known
+ if warn != "" && p.FlashErr == "" {
+ p.FlashErr = warn
+ }
s.render(w, http.StatusOK, "app_instances", p)
}
@@ -1674,7 +1728,7 @@ func normalizeAppName(name string) string {
//
// placement is an ALREADY-VALIDATED "" or "fleet/<name>" string — the
// caller (handleAPICreateApp) checks grammar and fleet ownership itself,
-// the same inline checks handleAPIPlace's target uses, BEFORE calling this,
+// the same inline checks placeAppTarget's target uses, BEFORE calling this,
// so an invalid or foreign placement never reaches here at all and never
// costs a claim attempt. handleNewApp (the browser door) has no placement
// concept and always passes "".
diff --git a/internal/console/templates/account_fleet.html b/internal/console/templates/account_fleet.html
new file mode 100644
index 000000000..833f4acd2
--- /dev/null
+++ b/internal/console/templates/account_fleet.html
@@ -0,0 +1,46 @@
+{{define "account_fleet"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Fleet · {{.Account.Name}}</p>
+ <h1>Fleet</h1>
+ <p class="lede">Where this account's apps run — the shared fleet, and boxes you bring.</p>
+</div>
+
+<h2>{{.SharedFleet.Title}}
+ {{if .SharedFleet.PlacementKnown}}{{if .SharedFleet.Active}}<span class="chip chip-env">Active</span>
+ {{else}}<span class="chip chip-env">Not used</span>{{end}}{{end}}</h2>
+{{if .SharedFleet.Boxes}}
+<p>{{.SharedFleet.Boxes}} box{{if ne .SharedFleet.Boxes 1}}es{{end}}{{if .SharedFleet.Regions}} in
+ {{range .SharedFleet.Regions}}<span class="chip chip-env">{{.}}</span> {{end}}{{end}}</p>
+{{else}}<p class="small">No shared boxes are reporting right now.</p>{{end}}
+{{if .SharedFleet.PlacementKnown}}
+<p class="small">{{if .SharedFleet.Active}}{{.SharedFleet.PlatformApps}} of your apps
+ {{if eq .SharedFleet.PlatformApps 1}}runs{{else}}run{{end}} here. Apps run on the shared fleet
+ unless you place them on a fleet of your own.{{else}}None of your apps run on the shared
+ fleet.{{end}}</p>
+{{else}}<p class="small">Whether your apps run here could not be read just now.</p>{{end}}
+
+<h2>Your fleets</h2>
+{{if .AccountFleets}}
+<div class="tablewrap">
+<table>
+ <tr><th>Fleet</th><th>Boxes</th><th>Reachable</th><th>Placed apps</th></tr>
+ {{range .AccountFleets}}
+ <tr>
+ <td><a href="{{$.Nav}}/fleets/{{.Name}}">{{.Name}}</a></td>
+ <td>{{.Boxes}}</td>
+ <td>{{.Reachable}}</td>
+ <td>{{.Apps}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else if .FleetsKnown}}<p class="small">No fleets of your own yet.</p>
+{{else}}<p class="small">Your fleets could not be read just now.</p>{{end}}
+
+<h2>Bring your own fleet</h2>
+<p>Run your apps on hardware you own — an office box, a Raspberry Pi, your own cloud account.
+Your boxes dial out to the platform; nothing connects in.</p>
+<p><a class="btn" href="{{.Nav}}/fleet/byo">How to add and harden a box</a></p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index ef50d2681..4a58045c1 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -32,6 +32,26 @@ instance's own peak.</p>
to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
{{end}}
+<h2>Placement</h2>
+{{if .AppPlacementKnown}}
+<p>{{if .AppPlacement}}This app runs on your fleet <a href="{{.Nav}}/fleets/{{fleetName .AppPlacement}}"><code>{{fleetName .AppPlacement}}</code></a>.{{else}}This app runs on the shared fleet.{{end}}</p>
+{{if and .IsOwner .OwnedFleetNames}}
+<form method="post" action="{{.Base}}/apps/{{.AppName}}/place" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="place-target">Where this app runs</label>
+ <select id="place-target" name="target">
+ <option value=""{{if not .AppPlacement}} selected{{end}}>Shared fleet</option>
+ {{range .OwnedFleetNames}}<option value="fleet/{{.}}"{{if eq (printf "fleet/%s" .) $.AppPlacement}} selected{{end}}>fleet/{{.}}</option>{{end}}
+ </select>
+ <button type="submit" class="btn-sm">Move</button>
+</form>
+<p class="small">Moving changes where this app <em>serves</em>. Data does not move:
+hibernated instance state stays in the old placement's bucket.</p>
+{{end}}
+{{else}}
+<p class="small">Could not read where this app runs.</p>
+{{end}}
+
{{if and .Instances .Instances.Reporting .Instances.Instances}}
<h2>Inside the app</h2>
<p class="small">This app manages its own internal instances, and reports
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html
new file mode 100644
index 000000000..5d88b38c2
--- /dev/null
+++ b/internal/console/templates/fleet_byo.html
@@ -0,0 +1,61 @@
+{{define "fleet_byo"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Fleet · {{.Account.Name}}</p>
+ <h1>Bring your own fleet</h1>
+ <p class="lede">Run your apps on hardware you own. Your boxes dial out to the platform over an
+ outbound tunnel; nothing ever connects in.</p>
+</div>
+
+<h2>What you need</h2>
+<ul>
+ <li>A Linux box, arm64 or amd64, that can reach the internet outbound.</li>
+ <li>An object-storage bucket you own for the fleet's data.</li>
+ <li>Nothing listens inbound, ever — no port to open, no firewall rule to punch a hole in.</li>
+</ul>
+
+<h2>Create the fleet</h2>
+<p>From a terminal signed in to this console:</p>
+<pre><code>carlos fleets create -bucket &lt;your-bucket&gt; &lt;name&gt;
+carlos fleets add-box &lt;name&gt; &lt;label&gt;</code></pre>
+<p class="small">(shown once — the console does not store or re-show the plaintext)</p>
+<p class="small">ROTATION IS NOT REVOCATION: this refuses the box's NEXT dial-in with the OLD
+token, but does NOT evict a channel it already holds — a COMPROMISED box needs Detach, not
+another Rotate.</p>
+
+<h2>Configure the box</h2>
+<p>On the box, in a root-owned env file the agent's unit loads (see Hardening below):</p>
+<pre><code>AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+CARLOS_HIBERNATE_BUCKET=&lt;your-bucket&gt;
+CARLOS_FLEET=&lt;name&gt;
+CARLOS_FLEET_BOX=&lt;label&gt;
+CARLOS_REGION=fleet/&lt;name&gt;
+CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/
+CARLOS_TUNNEL_URL=&lt;this deployment's tunnel address&gt;
+CARLOS_TUNNEL_TOKEN=&lt;from carlos fleets add-box &lt;name&gt; &lt;label&gt; — shown once&gt;
+CARLOS_TUNNEL_NAME=&lt;name&gt;/&lt;label&gt;</code></pre>
+<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>
+
+<h2>Hardening</h2>
+<ul>
+ <li>Run the agent as a dedicated non-root user, not root.</li>
+ <li>Make the env file root-owned, mode 600 — it holds the tunnel token.</li>
+ <li>Default-deny inbound on the box's firewall; it only ever dials out.</li>
+ <li>Turn on unattended security upgrades.</li>
+ <li>Keep the clock sane — chrony or systemd-timesyncd; leases compare timestamps.</li>
+ <li>Run the agent under a systemd unit with <code>ProtectSystem=strict</code> and
+ <code>NoNewPrivileges=yes</code> — see <code>internal/host/units/</code> in the platform repo
+ for the reference shapes.</li>
+ <li>Rotate the token if it may have leaked. If the box itself may be compromised,
+ <strong>Detach</strong> it instead — rotating only refuses its next dial-in, it does not evict
+ a channel the box already holds.</li>
+</ul>
+
+<h2>Then</h2>
+<p>The box appears on your fleet page within minutes — it self-reports on a heartbeat cadence, not
+instantly. From there, place an app on it with <code>carlos apps place</code> or from the app's
+Instances tab.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/fleetdetail.html b/internal/console/templates/fleetdetail.html
index ba8fc9fda..a9f0f774b 100644
--- a/internal/console/templates/fleetdetail.html
+++ b/internal/console/templates/fleetdetail.html
@@ -23,7 +23,7 @@
<div class="tablewrap">
<table>
<tr>
- <th>Label</th><th>Status</th><th>Tunnel</th>
+ <th>Label</th><th>Status</th><th>Tags</th><th>Tunnel</th>
<th>Memory free</th><th>Load</th><th>Disk free</th><th>Release</th>
{{if .IsOwner}}<th></th>{{end}}
</tr>
@@ -31,6 +31,7 @@
<tr>
<td>{{.Label}}</td>
<td>{{.Status}}</td>
+ <td>{{range .Tags}}<span class="chip chip-env">{{.}}</span> {{end}}{{if not .Tags}}—{{end}}</td>
{{if .Reachable}}
<td>{{emptyOrDash .Tunnel}}</td>
<td>{{if .Metrics}}{{bytesOrDash .Metrics.MemAvailBytes}} / {{bytesOrDash .Metrics.MemTotalBytes}}{{else}}—{{end}}</td>
@@ -54,6 +55,12 @@
<input type="hidden" name="label" value="{{.Label}}">
<button type="submit" class="linkish danger">Detach</button>
</form>
+ <form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/tags">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="label" value="{{.Label}}">
+ <input type="text" name="tags" value="{{join .Tags ","}}" size="14" placeholder="office,free">
+ <button type="submit" class="linkish">Tag</button>
+ </form>
</td>
{{end}}
</tr>
@@ -78,4 +85,18 @@ not evict a channel it already holds; a compromised box needs Detach instead.</p
</table>
</div>
{{else}}<p class="small">No apps placed on this fleet yet — <code>carlos apps place</code> sends one here.</p>{{end}}
+
+{{if and .IsOwner .FleetPlaceableApps}}
+<h3>Place an app here</h3>
+<form method="post" action="{{.Base}}/fleets/{{.FleetDetail.Name}}/place" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="place-app">App to place on this fleet</label>
+ <select id="place-app" name="app">
+ {{range .FleetPlaceableApps}}<option value="{{.}}">{{.}}</option>{{end}}
+ </select>
+ <button type="submit" class="btn-sm">Move</button>
+</form>
+<p class="small">Moving changes where that app <em>serves</em>. Data does not move:
+hibernated instance state stays in the old placement's bucket.</p>
+{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 55235fdab..36ceb9555 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -314,6 +314,7 @@
{{if .Address}}<nav>
{{if .Account}}
<a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a>
+ <a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a>
<a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a>
<a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a>
{{end}}
diff --git a/internal/edge/fleet_e2e_test.go b/internal/edge/fleet_e2e_test.go
index b48fcaaec..94a04565f 100644
--- a/internal/edge/fleet_e2e_test.go
+++ b/internal/edge/fleet_e2e_test.go
@@ -415,14 +415,15 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
// T4 CARRY (observation, not a gate): the edge does not learn a channel
// died from the channel dying. Nothing unregisters a dead conn except
- // pingLoop — tunnelPingEvery (15s) + tunnelPingTimeout (10s), so up to
- // ~25s of black hole in which the hub still reports the box connected and
- // rule 1 keeps dispatching to a corpse instead of falling over to the
- // healthy survivor. That window IS the venue-Wi-Fi failure mode: an app
- // stays down for ~25s after its box drops off the network, even though
- // another box could have served it immediately. Observed here without
- // paying the 25s: the assertions below are that the channel is STILL
- // registered and the request does NOT reach box-a.
+ // pingLoop — for a fleet channel that is fleetPingEvery (5s) +
+ // fleetPingTimeout (5s), so up to ~10s of black hole (spec D7 narrowed it
+ // from the static channels' ~25s) in which the hub still reports the box
+ // connected and rule 1 keeps dispatching to a corpse instead of falling
+ // over to the healthy survivor. That window IS the venue-Wi-Fi failure
+ // mode: an app stays down for up to ~10s after its box drops off the
+ // network, even though another box could have served it immediately.
+ // Observed here without paying the 10s: the assertions below are that the
+ // channel is STILL registered and the request does NOT reach box-a.
if got := hub.State(e2eFleet + "/box-b"); got != "connected" {
t.Fatalf("(b) hub state for the dead box = %q, want %q — if the hub now reaps eagerly, the black-hole note above is stale and this test should be updated to assert the better behavior", got, "connected")
}
@@ -431,7 +432,7 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK && string(body) == "box-a" {
- t.Fatal("(b) the request fell over to box-a before the dead channel was reaped — good news, but it means the ~25s black-hole window this test documents is gone and the comment above needs rewriting")
+ t.Fatal("(b) the request fell over to box-a before the dead channel was reaped — good news, but it means the ~10s black-hole window this test documents is gone and the comment above needs rewriting")
}
t.Logf("(b) black-hole window: dead-but-registered channel answered %d %q (no fallover)", resp.StatusCode, string(body))
} else {
@@ -440,7 +441,7 @@ func TestTwoBoxFleetEndToEnd(t *testing.T) {
// pingLoop's reap, on demand — CloseChannel is exactly what it calls, so
// the rest of the sequence runs at the speed the fleet would see once the
- // ping has fired, not 25s later.
+ // ping has fired, not 10s later.
hub.CloseChannel(e2eFleet + "/box-b")
waitChannel(t, hub, e2eFleet+"/box-b", "", time.Second)
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go
index fc9eda944..d40999a60 100644
--- a/internal/edge/tunnel.go
+++ b/internal/edge/tunnel.go
@@ -48,11 +48,34 @@ const (
// notices its conn die and re-dials (seconds — the bound Task 10 asserts).
tunnelPingEvery = 15 * time.Second
tunnelPingTimeout = 10 * time.Second
+ // Fleet (dynamic) channels ping tighter (fleets-console spec D7): a
+ // customer fleet's failover is "requests fall back to a surviving box once
+ // the dead channel is reaped", so the reap bound IS the user-visible
+ // outage window — ≤10s (one 5s sleep + one 5s deadline) instead of the
+ // static channels' ~25s. Static platform channels (tito hq) keep the wider
+ // cadence: their routes have no fallback box, so a tighter ping only adds
+ // churn risk for no availability gain.
+ fleetPingEvery = 5 * time.Second
+ fleetPingTimeout = 5 * time.Second
)
+// pingCadence picks a channel's liveness cadence from which credential source
+// admitted it. Split out from pingLoop so the choice is testable without
+// waiting on wall-clock pings.
+func pingCadence(dynamic bool) (every, timeout time.Duration) {
+ if dynamic {
+ return fleetPingEvery, fleetPingTimeout
+ }
+ return tunnelPingEvery, tunnelPingTimeout
+}
+
type tunnelConn struct {
cc *http2.ClientConn
raw net.Conn
+ // dynamic records that this conn authenticated against the registry-backed
+ // (fleet) credential source rather than the static env map — the one fact
+ // ServeHTTP knows and pingLoop needs for pingCadence.
+ dynamic bool
}
type TunnelHub struct {
@@ -126,6 +149,10 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// there is exactly one 401 below regardless of which source (or neither)
// missed; a caller cannot distinguish "wrong static token" from "unknown
// dynamic channel" from "wrong dynamic token" by response shape.
+ // viaDynamic is that branch's other output: which source admitted this
+ // conn decides its ping cadence (pingCadence), and this is the only place
+ // that fact is known.
+ viaDynamic := false
if !authOK && name != "" && hasBearer {
h.mu.Lock()
wantHash, dynOK := h.dynamicAuth[name]
@@ -134,6 +161,7 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sum := sha256.Sum256([]byte(token))
gotHash := hex.EncodeToString(sum[:])
authOK = subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) == 1
+ viaDynamic = authOK
}
}
if !authOK {
@@ -189,19 +217,23 @@ func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if old := h.conns[name]; old != nil {
old.raw.Close() // a re-register replaces the previous channel
}
- h.conns[name] = &tunnelConn{cc: cc, raw: conn}
+ h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}
h.mu.Unlock()
log.Printf("edge: tunnel %s: connected from %s", name, conn.RemoteAddr())
- go h.pingLoop(name, cc, conn)
+ go h.pingLoop(name, cc, conn, viaDynamic)
}
// pingLoop is the edge-side liveness check: a dead ping closes the raw conn
// and unregisters the channel, so requests fail fast to 502 instead of
// hanging on a black-holed TCP session (the venue-Wi-Fi failure mode).
-func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn) {
+// dynamic carries the credential source through from ServeHTTP: fleet
+// channels reap on a tighter bound because that bound is their failover
+// window (see pingCadence).
+func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dynamic bool) {
+ every, timeout := pingCadence(dynamic)
for {
- time.Sleep(tunnelPingEvery)
- ctx, cancel := context.WithTimeout(context.Background(), tunnelPingTimeout)
+ time.Sleep(every)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := cc.Ping(ctx)
cancel()
if err != nil {
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go
index 4c48acfba..3f7b278c7 100644
--- a/internal/edge/tunnel_test.go
+++ b/internal/edge/tunnel_test.go
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/registry"
)
@@ -637,3 +638,65 @@ func TestTunnelRouteIsNeverActivatorManaged(t *testing.T) {
t.Fatalf("activator woke a local process for a tunnel-served host (%d call(s))", act.served)
}
}
+
+// TestPingCadence pins the cadence SELECTION rather than wall-clock ping
+// timing: a fleet (dynamic) channel's reap bound is the customer-visible
+// failover window, so it pings tighter, while the static platform channels
+// keep the wider cadence they have always had. Asserting the selection is
+// what lets this be a millisecond test instead of a 25-second one.
+func TestPingCadence(t *testing.T) {
+ if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout {
+ t.Fatalf("static cadence changed: got %s/%s, want %s/%s", e, to, tunnelPingEvery, tunnelPingTimeout)
+ }
+ // The static numbers themselves, spelled out: a future edit that widened
+ // or narrowed them would otherwise pass the comparison above unnoticed.
+ if tunnelPingEvery != 15*time.Second || tunnelPingTimeout != 10*time.Second {
+ t.Fatalf("static cadence constants moved: %s/%s, want 15s/10s", tunnelPingEvery, tunnelPingTimeout)
+ }
+ if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout {
+ t.Fatalf("fleet cadence wrong: got %s/%s, want %s/%s", e, to, fleetPingEvery, fleetPingTimeout)
+ }
+ if fleetPingEvery != 5*time.Second || fleetPingTimeout != 5*time.Second {
+ t.Fatalf("fleet cadence constants moved: %s/%s, want 5s/5s", fleetPingEvery, fleetPingTimeout)
+ }
+}
+
+// TestDynamicChannelMarkedDynamic is the other half of the cadence: knowing
+// WHICH cadence to use means the conn has to remember which credential source
+// admitted it, and only a real handshake (httptest.NewRecorder cannot hijack)
+// ever reaches the point where a conn is stored. Both kinds register against
+// ONE hub, so this also re-proves the two credential sources coexist.
+func TestDynamicChannelMarkedDynamic(t *testing.T) {
+ hub := NewTunnelHub("unused.test", map[string]string{"hq": "sekrit"})
+ hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")})
+ // The hub answers registration directly here: no route, no edge, no
+ // reserved-hostname routing — this test is about what ServeHTTP stores.
+ srv := httptest.NewServer(hub)
+ defer srv.Close()
+
+ ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
+ stopDyn := startFakeRemoteHost(t, srv.URL, "pi/box-a", "tok", ok)
+ defer stopDyn()
+ stopStatic := startFakeRemoteHost(t, srv.URL, "hq", "sekrit", ok)
+ defer stopStatic()
+ waitForTunnelState(t, hub, "pi/box-a", "connected", e2eReconnectBound)
+ waitForTunnelState(t, hub, "hq", "connected", e2eReconnectBound)
+
+ if !channelIsDynamic(t, hub, "pi/box-a") {
+ t.Fatal("a channel admitted by the dynamic (fleet) credential source was not marked dynamic — it would ping at the static cadence and its fleet's failover window would be ~25s")
+ }
+ if channelIsDynamic(t, hub, "hq") {
+ t.Fatal("a static env channel was marked dynamic — platform channels must keep the wider cadence")
+ }
+}
+
+func channelIsDynamic(t *testing.T, hub *TunnelHub, name string) bool {
+ t.Helper()
+ hub.mu.Lock()
+ defer hub.mu.Unlock()
+ c := hub.conns[name]
+ if c == nil {
+ t.Fatalf("channel %q is not registered", name)
+ }
+ return c.dynamic
+}
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go
index ce06a2b8f..3593ddfef 100644
--- a/internal/registry/fleets.go
+++ b/internal/registry/fleets.go
@@ -43,6 +43,7 @@ type FleetBox struct {
TokenHash string // sha256 hex of the bearer token; never plaintext
Status string // "active" | "detached"
AddedAt int64
+ Tags string // comma-separated, ValidBoxTags grammar; "" = untagged
}
// tokenHashRe: exactly 64 lowercase hex characters — a sha256 digest,
@@ -55,6 +56,33 @@ func validTokenHash(hash string) bool {
return tokenHashRe.MatchString(hash)
}
+// boxTagRe: a single tag segment — lowercase alphanumerics and interior
+// hyphens, 1-24 characters, never leading or trailing with a hyphen (a
+// leading '-' is how a value gets mistaken for a flag by anything that ever
+// builds an argv from a tag, the same reasoning unitNameRe's first-character
+// carve-out documents). Same shape family as ValidTunnelName, but its own
+// regexp since tags are a comma-separated LIST rather than a single name.
+var boxTagRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$`)
+
+// ValidBoxTags reports whether tags is a well-formed comma-separated tag
+// list: each tag matches boxTagRe, at most 8 tags, no empty segments. ""
+// is valid and means untagged.
+func ValidBoxTags(tags string) bool {
+ if tags == "" {
+ return true
+ }
+ parts := strings.Split(tags, ",")
+ if len(parts) > 8 {
+ return false
+ }
+ for _, p := range parts {
+ if !boxTagRe.MatchString(p) {
+ return false
+ }
+ }
+ return true
+}
+
// validFleetBoxStatus reports whether status is one of the two values the
// FleetBox.Status doc comment promises. Rejecting anything else at the
// writer matters because AddFleetBox's uniqueness guard keys off status =
@@ -82,7 +110,7 @@ CREATE TABLE IF NOT EXISTS fleets (
);`); err != nil {
return err
}
- _, err := r.sql.Exec(`
+ if _, err := r.sql.Exec(`
CREATE TABLE IF NOT EXISTS fleet_boxes (
fleet TEXT NOT NULL,
label TEXT NOT NULL,
@@ -91,8 +119,15 @@ CREATE TABLE IF NOT EXISTS fleet_boxes (
added_at INTEGER NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (fleet, label)
-);`)
- return err
+);`); err != nil {
+ return err
+ }
+ // tags: additive (2026-08-10-fleets-console-design.md D3). Pre-change rows
+ // read as '' — untagged.
+ if _, err := r.sql.Exec(`ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
+ return nil
}
// freshFleetsConn opens a NEW connection over the same file — the List /
@@ -310,6 +345,32 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error {
return nil
}
+// SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same
+// RowsAffected guard for an unknown (fleet, label).
+func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error {
+ if !ValidTunnelName(fleet) {
+ return fmt.Errorf("invalid fleet name %q", fleet)
+ }
+ if !ValidTunnelName(label) {
+ return fmt.Errorf("invalid box label %q", label)
+ }
+ if !ValidBoxTags(tags) {
+ return fmt.Errorf("invalid box tags %q", tags)
+ }
+ res, err := r.sql.Exec(`UPDATE fleet_boxes SET tags = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, tags, fleet, label)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no box %q/%q", fleet, label)
+ }
+ return nil
+}
+
// SetFleetBoxStatus flips a box between "active" and "detached". Errors when
// no (fleet, label) row exists, same contract as SetFleetBoxToken. Rejects
// any status outside those two values — AddFleetBox's uniqueness guard keys
@@ -347,7 +408,7 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) {
return nil, err
}
defer db.Close()
- rows, err := db.Query(`SELECT fleet, label, token_hash, status, added_at FROM fleet_boxes WHERE fleet = ? ORDER BY label`, fleet)
+ rows, err := db.Query(`SELECT fleet, label, token_hash, status, added_at, tags FROM fleet_boxes WHERE fleet = ? ORDER BY label`, fleet)
if err != nil {
return nil, err
}
@@ -355,7 +416,7 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) {
var out []FleetBox
for rows.Next() {
var b FleetBox
- if err := rows.Scan(&b.Fleet, &b.Label, &b.TokenHash, &b.Status, &b.AddedAt); err != nil {
+ if err := rows.Scan(&b.Fleet, &b.Label, &b.TokenHash, &b.Status, &b.AddedAt, &b.Tags); err != nil {
return nil, err
}
out = append(out, b)
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go
index b5f4d3e98..ca052b334 100644
--- a/internal/registry/fleets_test.go
+++ b/internal/registry/fleets_test.go
@@ -1,7 +1,9 @@
package registry
import (
+ "database/sql"
"errors"
+ "path/filepath"
"regexp"
"strings"
"sync"
@@ -571,3 +573,128 @@ func TestPlacementTarget(t *testing.T) {
t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box")
}
}
+
+// mustCreateFleet is a tiny helper for tests that don't care about anything
+// beyond fixed test values for account/bucket.
+func mustCreateFleet(t *testing.T, r *Registry, fleet, account, bucket string) {
+ t.Helper()
+ if _, err := r.CreateFleet(Fleet{Name: fleet, Account: account, Bucket: bucket}); err != nil {
+ t.Fatalf("CreateFleet(%q): %v", fleet, err)
+ }
+}
+
+// mustAddBox is a tiny helper for tests that don't care about anything
+// beyond a fixed test token hash and an "active" status.
+func mustAddBox(t *testing.T, r *Registry, fleet, label string) {
+ t.Helper()
+ if err := r.AddFleetBox(FleetBox{Fleet: fleet, Label: label, TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatalf("AddFleetBox(%q, %q): %v", fleet, label, err)
+ }
+}
+
+// TestFleetBoxTags pins ValidBoxTags's grammar and SetFleetBoxTags's
+// round-trip through ListFleetBoxes, including the invalid-grammar and
+// unknown-box refusals and clearing back to "".
+func TestFleetBoxTags(t *testing.T) {
+ r := openTestRegistry(t)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a")
+ // grammar
+ for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b",
+ "a,b,c,d,e,f,g,h,i"} {
+ if ValidBoxTags(bad) {
+ t.Errorf("ValidBoxTags(%q) = true, want false", bad)
+ }
+ }
+ for _, good := range []string{"", "office", "office,free", "a-1"} {
+ if !ValidBoxTags(good) {
+ t.Errorf("ValidBoxTags(%q) = false, want true", good)
+ }
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil {
+ t.Fatal("invalid grammar accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil {
+ t.Fatal("unknown box accepted")
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil {
+ t.Fatal(err)
+ }
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" {
+ t.Fatalf("tags round-trip: %+v err=%v", boxes, err)
+ }
+ // clearing
+ if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestFleetBoxTagsFingerprintMoves mirrors
+// TestFingerprintChangesOnTokenRotation for tags: a tags write must move
+// Fingerprint the same way a token rotation does, or the edge would never
+// notice a tag change land.
+func TestFleetBoxTagsFingerprintMoves(t *testing.T) {
+ r := openTestRegistry(t)
+ mustCreateFleet(t, r, "pi", "acct1", "bkt")
+ mustAddBox(t, r, "pi", "box-a")
+ before, err := r.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := r.SetFleetBoxTags("pi", "box-a", "office"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := r.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a fleet_boxes tags write: %q", after)
+ }
+}
+
+// TestFleetBoxesPreTagsRowsParse mirrors TestFleetBoxMigratesFromPreChangeDB
+// (registry_test.go): a fleet_boxes row written before the tags column
+// existed is simulated by creating the table without it and inserting a raw
+// row, then Open() (which runs migrateFleets) must still open the file and
+// ListFleetBoxes must read the row back with Tags == "".
+func TestFleetBoxesPreTagsRowsParse(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+CREATE TABLE fleet_boxes (
+ fleet TEXT NOT NULL,
+ label TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ status TEXT NOT NULL,
+ added_at INTEGER NOT NULL,
+ rev INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (fleet, label)
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at) VALUES (?, ?, ?, ?, ?)`,
+ "pi", "box-a", tokenHashA, "active", 1700000000); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ r, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open over a pre-tags DB: %v", err)
+ }
+ defer r.Close()
+ boxes, err := r.ListFleetBoxes("pi")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].Tags != "" {
+ t.Fatalf("pre-tags row should default tags=\"\": %+v", boxes)
+ }
+}
diff --git a/main.go b/main.go
index 56844d97b..c69f51ff7 100644
--- a/main.go
+++ b/main.go
@@ -142,9 +142,10 @@ Your deployment — stand up and administer boxes, routes and accounts
bootstrap prepare a host: units, dirs, user
accounts the tenancy primitive: create | list | migrate
fleets account-owned groups of remote boxes that dial this
- console: create | add-box | rotate-token | detach-box |
- ls — box tokens print exactly once, at mint or rotate,
- and a compromised box needs detach-box, not rotate-token
+ console: create | add-box | rotate-token | tag-box |
+ detach-box | ls — box tokens print exactly once, at mint
+ or rotate, and a compromised box needs detach-box, not
+ rotate-token
add route a hostname on this box to an app
remove drop one of this box's routes
route repoint a route's channel or config environment, grant or