Merge pull request #141 from carlosframework/worktree-fleets-v2
Fleets console v2: the Fleet tab, tags, placement moves, fleet fast-ping
24 files changed,
+2985
−92
apimode.go+18 −0docs/superpowers/plans/2026-08-10-fleets-console.md+489 −0docs/superpowers/specs/2026-08-10-fleets-console-design.md+162 −0fleets.go+79 −6fleets_cli_test.go+98 −3infra/dev/region-pi.md+6 −0internal/console/accountfleet.go+223 −0internal/console/accountfleet_test.go+394 −0internal/console/apicli.go+2 −2internal/console/authcli.go+1 −0internal/console/fleets.go+368 −55internal/console/fleets_test.go+631 −0internal/console/server.go+56 −2internal/console/templates/account_fleet.html+46 −0internal/console/templates/app_instances.html+20 −0internal/console/templates/fleet_byo.html+61 −0internal/console/templates/fleetdetail.html+22 −1internal/console/templates/layout.html+1 −0internal/edge/fleet_e2e_test.go+11 −10internal/edge/tunnel.go+37 −5internal/edge/tunnel_test.go+63 −0internal/registry/fleets.go+66 −5internal/registry/fleets_test.go+127 −0main.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}} <host></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 <your-bucket> <name> |
| +carlos fleets add-box <name> <label></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=<your-bucket> |
| +CARLOS_FLEET=<name> |
| +CARLOS_FLEET_BOX=<label> |
| +CARLOS_REGION=fleet/<name> |
| +CARLOS_STATUS_PREFIX=fleets/<name>/ |
| +CARLOS_TUNNEL_URL=<this deployment's tunnel address> |
| +CARLOS_TUNNEL_TOKEN=<from carlos fleets add-box <name> <label> — shown once> |
| +CARLOS_TUNNEL_NAME=<name>/<label></code></pre> |
| +<p class="small"><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 |