carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Customer fleets (2026-08-05-customer-fleets-design.md): the console-API
2// door onto internal/registry's account-owned Fleet/FleetBox rows and onto
3// an app's placement — where its routes (and its already-provisioned
4// instances) should run. Every route below is bearer-authenticated and
5// account-scoped, the same apiAccount/apiAccountApp resolution the rest of
6// the CLI API uses, for the same reason: an unknown account, someone else's
7// account, and — new here — someone else's FLEET must be one indistinguishable
8// 404, because fleet names live in one flat, cross-account namespace
9// (fleets.name is the registry's primary key, not scoped per account), so a
10// caller's own valid {account} segment could otherwise be used to probe
11// which fleet names exist elsewhere.
12//
13// ONE HONEST EXCEPTION to that closed-probing posture: POST .../new's 409
14// ("that name is taken") is unavoidably a cross-account existence oracle.
15// Fleet names are that same flat, global namespace, and CreateFleet's
16// insert-only uniqueness check (registry/fleets.go) has to answer "already
17// exists" the same way whether the existing owner is you or a stranger —
18// there is no way to make "is this name free" account-scoped when the
19// thing being asked about is a single shared channel-identity namespace. No
20// other door in this file leaks anything through it: the 409 says a name is
21// taken, never by whom.
22package console
23
24import (
25 "context"
26 "crypto/rand"
27 "crypto/subtle"
28 "encoding/hex"
29 "errors"
30 "log"
31 "net/http"
32 "net/url"
33 "os"
34 "regexp"
35 "sort"
36 "strings"
37 "time"
38
39 "github.com/carlosframework/platform/internal/hostops"
40 "github.com/carlosframework/platform/internal/provision"
41 "github.com/carlosframework/platform/internal/registry"
42 "github.com/carlosframework/platform/internal/release"
43 "github.com/carlosframework/platform/internal/status"
44)
45
46// fleetBoxView and fleetView are the wire shapes for GET .../fleets and the
47// "fleet" field POST .../new returns — deliberately NOT registry.Fleet /
48// registry.FleetBox: those carry TokenHash, which this door must never echo
49// back (the plaintext is shown once, at mint/rotate, and nowhere else).
50type fleetBoxView struct {
51 Label string `json:"label"`
52 Status string `json:"status"`
53 // Tags is additive (2026-08-10, Task 2): omitted entirely for an
54 // untagged box, never a bare "" — existing callers parsing this shape
55 // before tags existed see nothing new unless a box actually carries some.
56 Tags string `json:"tags,omitempty"`
57}
58
59type fleetView struct {
60 Name string `json:"name"`
61 Bucket string `json:"bucket"`
62 Boxes []fleetBoxView `json:"boxes"`
63}
64
65// apiFleet resolves and authorizes one {account}/{fleet} CLI API request:
66// the account half is apiAccount's own resolution (bearer -> address ->
67// caller's own memberships), and the fleet half is GetFleet — with a fleet
68// that does not exist and a fleet owned by a DIFFERENT account answering the
69// exact same 404, mirroring apiAccountAppBy's "unknown and not-yours are one
70// answer" rule for apps. It also hands back the registry handle it opened,
71// since every caller needs one for whatever it does next.
72func (s *Server) apiFleet(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, fleet *registry.Fleet, reg consoleRegistry, ok bool) {
73 addr, acct, ok = s.apiAccount(w, r)
74 if !ok {
75 return "", nil, nil, nil, false
76 }
77 reg, err := openRegistry(s.cfg.Registry)
78 if err != nil {
79 log.Printf("console: api fleet: open registry: %v", err)
80 jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry")
81 return "", nil, nil, nil, false
82 }
83 name := r.PathValue("fleet")
84 f, err := reg.GetFleet(name)
85 if err != nil {
86 log.Printf("console: api fleet %s/%s: %v", acct.Sqid, name, err)
87 jsonError(w, http.StatusInternalServerError, "could not read the fleet")
88 return "", nil, nil, nil, false
89 }
90 if f == nil || f.Account != acct.Sqid {
91 http.NotFound(w, r)
92 return "", nil, nil, nil, false
93 }
94 return addr, acct, f, reg, true
95}
96
97// findFleetBox looks up one box by label within a fleet already proven to
98// exist and be owned by the caller — a plain scan of ListFleetBoxes rather
99// than a new registry method, since this door is the only caller and a
100// fleet's box count is small. (nil, nil) means no such label; that answers
101// its own refusal (404, "no such box") rather than the fleet-ownership 404
102// above, because within an ALREADY-OWNED fleet a bad label is an ordinary
103// not-found, not a cross-tenant probe.
104func findFleetBox(reg consoleRegistry, fleet, label string) (*registry.FleetBox, error) {
105 boxes, err := reg.ListFleetBoxes(fleet)
106 if err != nil {
107 return nil, err
108 }
109 for i := range boxes {
110 if boxes[i].Label == label {
111 return &boxes[i], nil
112 }
113 }
114 return nil, nil
115}
116
117// mintFleetToken mints one bearer credential the way every token-bearing
118// door in this console does: hex(32 bytes of crypto/rand) as the plaintext,
119// sha256 hex of THAT STRING (not the raw bytes) as what gets stored — the
120// exact convention hashToken already applies to CLI/app tokens, and the one
121// internal/edge's tunnel listener assumes when it hashes a presented bearer
122// token to check against FleetChannelAuth's table (tunnel.go). Reusing
123// hashToken here, rather than hashing the raw bytes directly, is what keeps
124// mint and verify agreeing on what "the token" means.
125func mintFleetToken() (plaintext, hash string, err error) {
126 var raw [32]byte
127 if _, err := rand.Read(raw[:]); err != nil {
128 return "", "", err
129 }
130 plaintext = hex.EncodeToString(raw[:])
131 return plaintext, hashToken(plaintext), nil
132}
133
134// bucketNameRe is the S3 bucket-name shape: 3-63 characters, lowercase
135// letters/digits/dot/hyphen, starting and ending with a letter or digit.
136// (The real S3 rules also forbid a few extra things — adjacent dots,
137// IP-address-shaped names — that this simplified check does not chase;
138// those still fail cleanly when the customer's tofu tries to use the
139// bucket, which is a world away from THIS value's actual exposure: it ends
140// up verbatim in a fleet box's env file, on hardware this console does not
141// control, so a value that is not even bucket-name-shaped must never reach
142// that far only to fail somewhere with no error message at all.)
143var bucketNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
144
145// handleAPIFleetNew answers `carlos fleets create`: mints the account-owned
146// fleet record. The data bucket is taken on trust that it already exists
147// (customer tofu, spec §"Surfaces") — this door only checks its NAME is
148// bucket-shaped and records it.
149//
150// CreateFleet (registry/fleets.go) is insert-only — ON CONFLICT DO NOTHING,
151// RowsAffected is the atomic "did I win" answer — so there is no
152// read-then-write window here for a second account to silently take over a
153// name a first caller just created (an earlier revision of this handler did
154// a GetFleet pre-check before UpsertFleet, which had exactly that race;
155// CreateFleet replaces both calls with the one atomic one).
156func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) {
157 addr, acct, ok := s.apiAccount(w, r)
158 if !ok {
159 return
160 }
161 p, _ := principalFrom(r.Context())
162 // The fleets namespace is flat and global with no delete door (see this
163 // file's package doc comment) — the same "a claimed name is a permanent
164 // row, not a pointer the next call overwrites" shape apps' create budget
165 // exists for, so this spends the SAME scarce per-day budget
166 // (handleAPICreateApp), not the ordinary per-minute write budget every
167 // other fleet door here uses. Every fleets door is registered with the
168 // zero role Set, so only a person ever reaches here — allowCreate is
169 // used anyway to keep one idiom.
170 if !s.allowCreate(p, time.Now()) {
171 jsonError(w, http.StatusTooManyRequests,
172 "fleet creation limit reached for today — try again tomorrow")
173 return
174 }
175 // Plans 3a: fleets need a plan that includes them.
176 if err := s.checkFleets(acct); err != nil {
177 if isPlanCap(err) {
178 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
179 return
180 }
181 log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err)
182 jsonError(w, http.StatusBadGateway, "could not check this account's plan. Try again")
183 return
184 }
185 var req struct {
186 Name string `json:"name"`
187 Bucket string `json:"bucket"`
188 }
189 if !readJSON(w, r, &req) {
190 return
191 }
192 name := strings.TrimSpace(req.Name)
193 if !registry.ValidTunnelName(name) {
194 jsonError(w, http.StatusBadRequest,
195 "invalid fleet name — 1-32 characters of a-z, 0-9 and hyphen")
196 return
197 }
198 bucket := strings.TrimSpace(req.Bucket)
199 if !bucketNameRe.MatchString(bucket) {
200 jsonError(w, http.StatusBadRequest,
201 "invalid bucket name — 3-63 characters of lowercase letters, digits, dot and hyphen, "+
202 "starting and ending with a letter or digit (S3 bucket-name shape)")
203 return
204 }
205 reg, err := openRegistry(s.cfg.Registry)
206 if err != nil {
207 log.Printf("console: fleet new %s/%s: open registry: %v", acct.Sqid, name, err)
208 jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry")
209 return
210 }
211 created, err := reg.CreateFleet(registry.Fleet{Name: name, Account: acct.Sqid, Bucket: bucket})
212 if err != nil {
213 log.Printf("console: fleet new %s/%s: %v", acct.Sqid, name, err)
214 jsonError(w, http.StatusInternalServerError, "could not create the fleet — try again")
215 return
216 }
217 if !created {
218 jsonError(w, http.StatusConflict, "that name is taken")
219 return
220 }
221 s.audit(acct, name, addr, "fleet-create", "created fleet "+name+" (cli)")
222 writeJSON(w, http.StatusOK, map[string]any{
223 "fleet": fleetView{Name: name, Bucket: bucket, Boxes: []fleetBoxView{}},
224 })
225}
226
227// handleAPIFleetList answers `carlos fleets ls`: every fleet the caller's
228// account owns, each with its boxes' label and status — never a token hash.
229func (s *Server) handleAPIFleetList(w http.ResponseWriter, r *http.Request) {
230 _, acct, ok := s.apiAccount(w, r)
231 if !ok {
232 return
233 }
234 reg, err := openRegistry(s.cfg.Registry)
235 if err != nil {
236 log.Printf("console: fleet list %s: open registry: %v", acct.Sqid, err)
237 jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry")
238 return
239 }
240 all, err := reg.ListFleets()
241 if err != nil {
242 log.Printf("console: fleet list %s: %v", acct.Sqid, err)
243 jsonError(w, http.StatusInternalServerError, "could not list fleets")
244 return
245 }
246 out := make([]fleetView, 0)
247 for _, f := range all {
248 if f.Account != acct.Sqid {
249 continue
250 }
251 boxes, err := reg.ListFleetBoxes(f.Name)
252 if err != nil {
253 log.Printf("console: fleet list %s: boxes of %s: %v", acct.Sqid, f.Name, err)
254 jsonError(w, http.StatusInternalServerError, "could not list fleets")
255 return
256 }
257 bv := make([]fleetBoxView, 0, len(boxes))
258 for _, b := range boxes {
259 bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status, Tags: b.Tags})
260 }
261 out = append(out, fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: bv})
262 }
263 writeJSON(w, http.StatusOK, map[string]any{"fleets": out})
264}
265
266// handleAPIFleetAddBox answers `carlos fleets add-box`: mints a box row and its
267// bearer token. The plaintext appears exactly once, in this response body —
268// only its sha256 hex reaches the registry (AddFleetBox), and it is never
269// logged.
270func (s *Server) handleAPIFleetAddBox(w http.ResponseWriter, r *http.Request) {
271 addr, acct, fleet, reg, ok := s.apiFleet(w, r)
272 if !ok {
273 return
274 }
275 p, _ := principalFrom(r.Context())
276 if !s.allowWrite(p, time.Now()) {
277 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
278 return
279 }
280 var req struct {
281 Label string `json:"label"`
282 }
283 if !readJSON(w, r, &req) {
284 return
285 }
286 label := strings.TrimSpace(req.Label)
287 if !registry.ValidTunnelName(label) {
288 jsonError(w, http.StatusBadRequest,
289 "invalid box label — 1-32 characters of a-z, 0-9 and hyphen")
290 return
291 }
292 plaintext, hash, err := mintFleetToken()
293 if err != nil {
294 log.Printf("console: fleet add-box %s/%s: mint token: %v", acct.Sqid, fleet.Name, err)
295 jsonError(w, http.StatusInternalServerError, "could not mint a token — try again")
296 return
297 }
298 // The fleet is already proven to exist (apiFleet), so AddFleetBox's own
299 // "no fleet" case is unreachable here — but a tx/busy/disk failure is
300 // not, and must not be rendered as the ordinary duplicate-box 409:
301 // errors.Is against the sentinel is what tells the two apart, rather
302 // than assuming every failure here is the uniqueness guard.
303 if err := reg.AddFleetBox(registry.FleetBox{Fleet: fleet.Name, Label: label, TokenHash: hash, Status: "active"}); err != nil {
304 if errors.Is(err, registry.ErrFleetBoxActive) {
305 jsonError(w, http.StatusConflict, "fleet "+fleet.Name+" already has an active box "+label)
306 return
307 }
308 log.Printf("console: fleet add-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
309 jsonError(w, http.StatusInternalServerError, "could not add the box — try again")
310 return
311 }
312 s.audit(acct, fleet.Name, addr, "fleet-add-box", "added box "+label+" to fleet "+fleet.Name+" (cli)")
313 writeJSON(w, http.StatusOK, map[string]any{"label": label, "token": plaintext})
314}
315
316// handleAPIFleetRotateBox answers `carlos fleets rotate-token`: a fresh token
317// for a box that already exists, its hash replacing the stored one.
318//
319// ROTATION IS NOT REVOCATION. It refuses the box's NEXT dial-in attempt with
320// the OLD token — the edge's tunnel listener checks a presented token's hash
321// against the registry on every new connection, and the old hash simply
322// stops matching once this write lands (spec's "Edge: dynamic fleet
323// channels", within the console's usual convergence window; carried from
324// Task 3's own rotation contract for the platform's own tunnel clients). It
325// does NOT evict a channel that is already connected: a box mid-session
326// keeps talking on the connection it already holds, because rotation only
327// changes what is checked when a NEW connection is authenticated. A box
328// whose credential may be compromised needs DETACH (handleAPIFleetDetachBox),
329// which the edge honours by closing the live channel outright — rotation
330// alone is the wrong tool for "get this box off the network right now".
331func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request) {
332 addr, acct, fleet, reg, ok := s.apiFleet(w, r)
333 if !ok {
334 return
335 }
336 p, _ := principalFrom(r.Context())
337 if !s.allowWrite(p, time.Now()) {
338 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
339 return
340 }
341 var req struct{}
342 if !readJSON(w, r, &req) {
343 return
344 }
345 label := r.PathValue("label")
346 box, err := findFleetBox(reg, fleet.Name, label)
347 if err != nil {
348 log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
349 jsonError(w, http.StatusInternalServerError, "could not read the box")
350 return
351 }
352 if box == nil {
353 jsonError(w, http.StatusNotFound, "no such box")
354 return
355 }
356 plaintext, hash, err := mintFleetToken()
357 if err != nil {
358 log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err)
359 jsonError(w, http.StatusInternalServerError, "could not mint a token — try again")
360 return
361 }
362 if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil {
363 log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
364 jsonError(w, http.StatusInternalServerError, "could not rotate the token — try again")
365 return
366 }
367 s.audit(acct, fleet.Name, addr, "fleet-rotate-box",
368 "rotated the token for "+fleet.Name+"/"+label+" (cli)")
369 writeJSON(w, http.StatusOK, map[string]any{"token": plaintext})
370}
371
372// handleAPIFleetTagBox answers `carlos fleets tag-box`: a FULL REPLACE of a
373// box's tag list, never a merge — an empty "tags" clears every tag rather
374// than being refused as a no-op, matching SetFleetBoxTags's own plain
375// overwrite (`UPDATE ... SET tags = ?`, registry/fleets.go): whatever this
376// door validates and passes through is exactly what lands.
377//
378// Grammar (registry.ValidBoxTags) is checked here, before the registry
379// write ever runs — the same "a shape the writer is certain to refuse
380// should never reach it" principle handleAPIFleetNew's name/bucket checks
381// and handleAPIFleetAddBox's label check already follow. The gate order and
382// refusal shapes below deliberately match rotate's: apiFleet's byte-
383// identical unknown/foreign-fleet 404 first, then an unknown LABEL within
384// an already-owned fleet is its own ordinary 404 ("no such box") — see
385// findFleetBox's doc comment for why that one is not the cross-tenant-probe
386// 404.
387func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) {
388 addr, acct, fleet, reg, ok := s.apiFleet(w, r)
389 if !ok {
390 return
391 }
392 p, _ := principalFrom(r.Context())
393 if !s.allowWrite(p, time.Now()) {
394 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
395 return
396 }
397 var req struct {
398 Tags string `json:"tags"`
399 }
400 if !readJSON(w, r, &req) {
401 return
402 }
403 tags := strings.TrimSpace(req.Tags)
404 if !registry.ValidBoxTags(tags) {
405 jsonError(w, http.StatusBadRequest,
406 "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
407 return
408 }
409 label := r.PathValue("label")
410 box, err := findFleetBox(reg, fleet.Name, label)
411 if err != nil {
412 log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
413 jsonError(w, http.StatusInternalServerError, "could not read the box")
414 return
415 }
416 if box == nil {
417 jsonError(w, http.StatusNotFound, "no such box")
418 return
419 }
420 if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil {
421 log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
422 jsonError(w, http.StatusInternalServerError, "could not tag the box — try again")
423 return
424 }
425 s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)")
426 writeJSON(w, http.StatusOK, map[string]any{})
427}
428
429// handleAPIFleetDetachBox answers `carlos fleets detach-box`: flips a box to
430// "detached", which the edge treats as an outright refusal (a dial-in is
431// refused, and — unlike rotation — an already-connected channel is closed).
432// This is the hard revocation rotation is not.
433func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) {
434 addr, acct, fleet, reg, ok := s.apiFleet(w, r)
435 if !ok {
436 return
437 }
438 p, _ := principalFrom(r.Context())
439 if !s.allowWrite(p, time.Now()) {
440 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
441 return
442 }
443 var req struct{}
444 if !readJSON(w, r, &req) {
445 return
446 }
447 label := r.PathValue("label")
448 box, err := findFleetBox(reg, fleet.Name, label)
449 if err != nil {
450 log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
451 jsonError(w, http.StatusInternalServerError, "could not read the box")
452 return
453 }
454 if box == nil {
455 jsonError(w, http.StatusNotFound, "no such box")
456 return
457 }
458 if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil {
459 log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
460 jsonError(w, http.StatusInternalServerError, "could not detach the box — try again")
461 return
462 }
463 s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (cli)")
464 writeJSON(w, http.StatusOK, map[string]any{})
465}
466
467// appPlacement reports an app's fleet placement target ("fleet/<name>"), or
468// "" for none.
469//
470// 2026-08-06 ruling: the STORE's placement column (Store.AppPlacement) is
471// consulted FIRST — the fact `carlos apps place` and `carlos apps create
472// -place` both write (placeAppTarget, handleAPICreateApp) — because it is
473// the only source that exists the instant an app is placed with zero
474// routes yet, which a route-derived reading structurally cannot answer (see
475// the KNOWN LIMITATION this comment used to carry, below). Only when the
476// store has never recorded a placement for this app (an app placed by an
477// OLDER build, before this column existed, still carries its placement
478// only in its routes) does this fall back to the original route-derived
479// reading: placeAppTarget stamps every one of an app's routes with the SAME
480// target in one call, so by construction they are uniform once placed, and
481// the first fleet-shaped Region found among them is authoritative.
482//
483// The one caller outside this file is handleAPIInstanceCreate
484// (apiinstances.go): a fleet-placed app's NEW instances must inherit its
485// placement uniformly (spec's "one flag on the app, all its instances
486// follow") rather than trusting whatever region a create request's body
487// happens to send — see placeAppTarget's doc comment for why that
488// inheritance has to be a separate mechanism from the route/record stamping
489// below, not the same one.
490func (s *Server) appPlacement(reg consoleRegistry, acct *Account, app string) (string, error) {
491 stored, err := s.store.AppPlacement(acct.ID, app)
492 if err != nil {
493 return "", err
494 }
495 if stored != "" {
496 return stored, nil
497 }
498 // Fallback: route-derived placement, for an app placed before the store
499 // column existed (see the doc comment above).
500 routes, err := reg.List()
501 if err != nil {
502 return "", err
503 }
504 for _, rt := range routes {
505 if rt.Account != acct.Sqid || rt.App != app {
506 continue
507 }
508 if _, ok := registry.FleetPlacement(rt.Region); ok {
509 return rt.Region, nil
510 }
511 }
512 return "", nil
513}
514
515// placeInstancesFailed is placeAppTarget's answer when it cannot read the
516// app's instance records.
517const placeInstancesFailed = "could not update this app's instances — try again"
518
519// placeAppTarget is placement's whole mechanism, and the ONLY place it
520// lives: repoints where an app's routes (and its already-provisioned
521// instances) should run. target "" clears placement back to
522// platform-served/unconstrained; any other value must parse as
523// "fleet/<name>" (registry.FleetPlacement) naming a fleet acct ITSELF owns —
524// placing onto another account's fleet is refused with the same
525// byte-identical 404 apiFleet gives an unowned one, since fleet names are a
526// flat, cross-account namespace (see this file's package doc comment).
527//
528// Every door onto placement calls this — the CLI API's handleAPIPlace, the
529// app Instances tab's handleAppPlace, the fleet page's
530// handleFleetPlacePage — because the ordering discipline below is the whole
531// correctness of the operation, and a second copy of it is a second place
532// for it to be got wrong. Callers own authentication, the app-ownership
533// gate, rate limiting, the audit line and how the outcome is rendered;
534// nothing else.
535//
536// The return is a translation contract, not an HTTP response: (0, "") means
537// it landed. A non-zero status with a MESSAGE is an ordinary refusal the
538// caller renders in its own idiom (jsonError, or a flash). A non-zero status
539// with an EMPTY message is exactly one case — http.StatusNotFound, the
540// cross-account target refusal — and it means the caller must answer with a
541// byte-identical http.NotFound, never a message of its own: a distinguishable
542// error there would turn this door into an oracle for which fleet names exist
543// in someone else's account.
544//
545// THE TRIPWIRE this function exists to close: placement is stored in
546// Route.Region (SetPlacement) AND, separately, in every one of the app's
547// already-provisioned instance records' Region field
548// (provision.SetRecordRegion) — both are read by provision.Reconcile's
549// filter (rec.Region != cfg.Region) on a fleet box running
550// CARLOS_REGION=fleet/<name>. Stamping routes here does NOT, by itself,
551// repoint where a NEW instance provisions — instance-create validates its
552// Region from the REQUEST BODY (handleAPIInstanceCreate, apiinstances.go),
553// and provision.ValidRegion's shape (no "/") would refuse a "fleet/<name>"
554// value outright if it somehow arrived that way. A new instance inheriting
555// placement is a SEPARATE mechanism (appPlacement, above, consulted by
556// handleAPIInstanceCreate directly) — spec's "one flag on the app, all its
557// instances follow". What THIS function's record write closes is the gap
558// that inheritance cannot: every instance that ALREADY EXISTS at the moment
559// placement changes. Inheritance only ever applies going forward, at
560// create time, so without the write below an already-provisioned record
561// would stay filed under its old region forever — silently exempt from a
562// placement change the caller was just told succeeded.
563//
564// ORDER MATTERS: routes are stamped BEFORE records, never the reverse.
565// provision.Reconcile's adoption pass (reconcile.go:265) retires a route
566// whose Region no longer matches what it minted the route from; a
567// records-first ordering would open a window in which a reconcile pass —
568// running concurrently, on either the OLD or the NEW fleet's box — could
569// see a record already repointed to the new target while the route still
570// names the old one, and read that route as no longer wanted, deleting a
571// route that is still live. Stamping routes first means the route is
572// always ahead of (or equal to) any record a concurrent reconcile pass
573// would compare it against, never behind.
574//
575// A console with no deployment bucket configured (cfg.Releases == nil)
576// refuses the whole request rather than silently doing the routes-only
577// half.
578func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string) {
579 // A temporary app stays on the shared fleet at its own platform address
580 // (spec §4, ruling 5, Task 6 review fix): placement would move its
581 // already-provisioned estate onto a fleet an automatic purge never
582 // looks at. Checked first, before the registry or bucket are even
583 // opened — every caller has already proved membership/ownership.
584 if msg, st := s.temporaryRefusal(acct, app, "serve anything but a static site at "+app+"'s own platform address"); msg != "" {
585 return st, msg
586 }
587 if s.cfg.Releases == nil {
588 return http.StatusServiceUnavailable,
589 "no deployment bucket on this host — placement needs it to stamp this app's instances"
590 }
591
592 reg, err := openRegistry(s.cfg.Registry)
593 if err != nil {
594 log.Printf("console: place %s/%s: open registry: %v", acct.Sqid, app, err)
595 return http.StatusInternalServerError, "could not reach the registry"
596 }
597
598 if target != "" {
599 fleetName, validShape := registry.FleetPlacement(target)
600 if !validShape {
601 return http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"`
602 }
603 f, err := reg.GetFleet(fleetName)
604 if err != nil {
605 log.Printf("console: place %s/%s: get fleet %s: %v", acct.Sqid, app, fleetName, err)
606 return http.StatusInternalServerError, "could not check the fleet"
607 }
608 if f == nil || f.Account != acct.Sqid {
609 // Unknown and not-yours are the same refusal everywhere else in
610 // this file (apiFleet); place's target check is no exception —
611 // this door must not become a way to learn which fleet names
612 // exist in someone else's account. The empty message is the
613 // signal to the caller: answer http.NotFound, say nothing.
614 return http.StatusNotFound, ""
615 }
616 }
617
618 // A place re-homes every record of the app, so a seed that may not move
619 // yet (spec §5.8) refuses it, before any write. A record that cannot be
620 // read refuses it too. SetRecordRegion refuses such a seed again under
621 // its CAS, for a record created after this read.
622 switch block, err := placeSeedBlock(ctx, s.cfg.Releases, acct.Sqid, app); {
623 case err != nil:
624 log.Printf("console: place %s/%s: read instances: %v", acct.Sqid, app, err)
625 return http.StatusBadGateway, placeSeedReadFailed
626 case block != "":
627 return http.StatusConflict, provision.SeedMoveSentence(block)
628 }
629
630 // The store-of-record write (2026-08-06 ruling): appPlacement consults
631 // THIS before it ever walks a route. It has to land before routes/records
632 // are stamped below, not after — the same "never observe an app in a
633 // getting-placed state that some OTHER path already trusts" reasoning
634 // the ORDER MATTERS paragraph above applies to routes-before-records, one
635 // step earlier: a concurrent instance-create must never inherit a target
636 // this request has not actually committed to.
637 if s.cfg.HostClaim {
638 if s.cfg.ClaimPlacement == nil {
639 return http.StatusServiceUnavailable, claimResponseUnavailable
640 }
641 id, err := s.cfg.ClaimPlacement.Start(ctx, acct.Sqid, app, target, "console")
642 if err != nil && !errors.Is(err, hostops.ErrPending) {
643 ref := claimResponseRefusal(err)
644 return ref.Status, ref.Msg
645 }
646 if id == "" {
647 return http.StatusConflict, claimRetireBusyMsg
648 }
649 appID, err := s.store.ClaimAppID(acct.ID, app)
650 if err != nil {
651 return http.StatusServiceUnavailable, claimResponseUnavailable
652 }
653 admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID)
654 if err != nil {
655 return http.StatusServiceUnavailable, claimResponseUnavailable
656 }
657 if admission.Value.Op == nil {
658 return 0, ""
659 }
660 if admission.Value.Op.ID != id {
661 return http.StatusConflict, claimRetireBusyMsg
662 }
663 return http.StatusAccepted, id
664 }
665 if err := s.store.SetAppPlacementContext(ctx, acct.ID, app, target); err != nil {
666 log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err)
667 return http.StatusInternalServerError, "could not record placement — try again"
668 }
669
670 routes, err := reg.List()
671 if err != nil {
672 log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err)
673 return http.StatusBadGateway, "could not read the app's routes — try again"
674 }
675 for _, rt := range routes {
676 if rt.Account != acct.Sqid || rt.App != app {
677 continue
678 }
679 if err := reg.SetPlacement(rt.Host, target); err != nil {
680 log.Printf("console: place %s/%s: set placement on %s: %v", acct.Sqid, app, rt.Host, err)
681 return http.StatusBadGateway, "could not update every route — try again"
682 }
683 }
684
685 ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
686 defer cancel()
687 prefix := provision.RecordsPrefix(acct.Sqid, app)
688 keys, err := s.cfg.Releases.List(ctx, prefix)
689 if err != nil {
690 log.Printf("console: place %s/%s: list instances: %v", acct.Sqid, app, err)
691 return http.StatusBadGateway, placeInstancesFailed
692 }
693 for _, key := range keys {
694 rest, ok := strings.CutPrefix(key, prefix)
695 if !ok || rest == "" || strings.Contains(rest, "/") {
696 continue // not a record at this level
697 }
698 host, ok := strings.CutSuffix(rest, ".json")
699 if !ok || host == "" {
700 continue
701 }
702 if err := provision.SetRecordRegion(ctx, s.cfg.Releases, acct.Sqid, app, host, target); err != nil {
703 if errors.Is(err, os.ErrNotExist) {
704 // The record was retired (DeleteRecord) between the List
705 // above and this write reaching it — not a failure of this
706 // call, just a target that's gone. Every OTHER error still
707 // fails the whole place: this is the one case where "the
708 // thing we were about to touch already doesn't exist" is
709 // indistinguishable from success.
710 log.Printf("console: place %s/%s: instance %s vanished mid-walk, skipping", acct.Sqid, app, host)
711 continue
712 }
713 if msg := seedMoveConflict(err); msg != "" {
714 return http.StatusConflict, msg
715 }
716 log.Printf("console: place %s/%s: stamp instance %s: %v", acct.Sqid, app, host, err)
717 return http.StatusBadGateway, "could not update every instance — try again"
718 }
719 }
720 return 0, ""
721}
722
723// handleAPIPlace answers `carlos apps place`: the bearer-authenticated door
724// onto placeAppTarget. Everything that makes placement CORRECT lives in that
725// function (see its doc comment); what belongs here is the CLI API's own
726// share — bearer resolution, the write budget, the JSON body, the "(cli)"
727// audit line, and translating the core's (status, message) onto this API's
728// refusal shapes.
729func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
730 addr, acct, app, ok := s.apiAccountApp(w, r)
731 if !ok {
732 return
733 }
734 p, _ := principalFrom(r.Context())
735 if !s.allowWrite(p, time.Now()) {
736 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
737 return
738 }
739 var req struct {
740 Target string `json:"target"`
741 }
742 if !readJSON(w, r, &req) {
743 return
744 }
745 target := strings.TrimSpace(req.Target)
746
747 switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
748 case status == http.StatusNotFound:
749 // The cross-account target refusal — byte-identical to any other
750 // not-found, never a JSON message of its own.
751 http.NotFound(w, r)
752 return
753 case status == http.StatusAccepted:
754 writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": msg, "phase": "pending"})
755 return
756 case status != 0:
757 jsonError(w, status, msg)
758 return
759 }
760
761 detail := "cleared placement for " + app + " (cli)"
762 if target != "" {
763 detail = "placed " + app + " on " + target + " (cli)"
764 }
765 s.audit(acct, app, addr, "place", detail)
766 writeJSON(w, http.StatusOK, map[string]any{})
767}
768
769// placeFlash is the one sentence both browser doors say about an outcome,
770// and the one audit detail both record — kept together here so the two forms
771// can never describe the same act differently. suffix is the door's own
772// provenance mark ("(console)"), the same convention the fleet-box page
773// doors use against the CLI API's "(cli)".
774func placeFlash(app, target, suffix string) (flash, detail string) {
775 if target == "" {
776 return app + " is back on the shared fleet.", "cleared placement for " + app + " " + suffix
777 }
778 return app + " is now placed on " + target + ".", "placed " + app + " on " + target + " " + suffix
779}
780
781// handleAppPlace answers the Placement form on an app's Instances tab: move
782// THIS app onto one of the account's fleets, or back to the shared one.
783//
784// The gate skeleton is handleAppRename's, deliberately — accountApp, then
785// CSRF, then owner (a member gets the byte-identical 404, never a 403 that
786// would confirm both the app's existence and the caller's under-privilege).
787// The move itself is placeAppTarget's; this door only translates its outcome
788// into a flash, except for the cross-account target refusal, which it must
789// answer as an ordinary http.NotFound and nothing else (see the core's doc
790// comment on why an empty message is that signal).
791func (s *Server) handleAppPlace(w http.ResponseWriter, r *http.Request) {
792 addr, raw, acct, role, app, ok := s.accountApp(w, r)
793 if !ok {
794 return
795 }
796 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
797 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
798 return
799 }
800 if role != RoleOwner {
801 http.NotFound(w, r)
802 return
803 }
804 back := func(kind, msg string) {
805 s.setFlash(w, kind, msg)
806 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances",
807 http.StatusSeeOther)
808 }
809 target := strings.TrimSpace(r.FormValue("target"))
810 switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
811 case status == http.StatusNotFound:
812 http.NotFound(w, r)
813 return
814 case status == http.StatusAccepted:
815 s.renderClaimAccepted(w, r, app, msg, "pending")
816 return
817 case status != 0:
818 back("err", msg)
819 return
820 }
821 flash, detail := placeFlash(app, target, "(console)")
822 s.audit(acct, app, addr, "place", detail)
823 back("ok", flash)
824}
825
826// handleFleetPlacePage answers the fleet page's "Place an app here" form: the
827// same move from the other end — this fleet is fixed, the app is chosen.
828//
829// ownerFleet proves the FLEET is this account's; it says nothing about the
830// app named in the body, so HasAppIn is a second, independent gate here. Its
831// refusal is accountApp's own "No such app here." 404, which is the same
832// answer a mistyped name gets: app names are per-account, so this reveals
833// nothing about another account's apps.
834func (s *Server) handleFleetPlacePage(w http.ResponseWriter, r *http.Request) {
835 addr, raw, acct, fleet, _, ok := s.ownerFleet(w, r)
836 if !ok {
837 return
838 }
839 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
840 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
841 return
842 }
843 back := func(kind, msg string) {
844 s.setFlash(w, kind, msg)
845 http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
846 }
847 app := strings.TrimSpace(r.FormValue("app"))
848 has, err := s.store.HasAppIn(acct.ID, app)
849 if err != nil {
850 log.Printf("console: fleet place %s/%s: has app %s: %v", acct.Sqid, fleet.Name, app, err)
851 back("err", "Could not read your apps — try again.")
852 return
853 }
854 if !has {
855 s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
856 return
857 }
858 target := registry.PlacementTarget(fleet.Name)
859 switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); {
860 case status == http.StatusNotFound:
861 http.NotFound(w, r)
862 return
863 case status == http.StatusAccepted:
864 s.renderClaimAccepted(w, r, app, msg, "pending")
865 return
866 case status != 0:
867 back("err", msg)
868 return
869 }
870 flash, detail := placeFlash(app, target, "(console)")
871 s.audit(acct, app, addr, "place", detail)
872 back("ok", flash)
873}
874
875// appPlacementSection reads the app Instances tab's Placement section: where
876// this app runs now, and every fleet the account owns to offer as somewhere
877// else it could.
878//
879// known is the section's twin of sharedFleetCard.PlacementKnown and exists
880// for the identical reason: "" is both "this app runs on the shared fleet"
881// and "the read that would have told us failed", and only the first of those
882// is a sentence this page has earned. False means the section says it could
883// not check, rather than making the positive claim — and withholds the form,
884// whose pre-selected option would otherwise be a guess.
885func (s *Server) appPlacementSection(acct *Account, app string) (placement string, fleets []string, known bool, warn string) {
886 reg, err := openRegistry(s.cfg.Registry)
887 if err != nil {
888 log.Printf("console: app placement %s/%s: open registry: %v", acct.Sqid, app, err)
889 return "", nil, false, "Could not reach the fleet registry."
890 }
891 placement, err = s.appPlacement(reg, acct, app)
892 if err != nil {
893 log.Printf("console: app placement %s/%s: %v", acct.Sqid, app, err)
894 return "", nil, false, "Could not read where this app runs."
895 }
896 rows, err := reg.ListFleets()
897 if err != nil {
898 log.Printf("console: app placement %s/%s: list fleets: %v", acct.Sqid, app, err)
899 // Placement itself IS known — only the list of alternatives is
900 // missing, which costs the form and nothing else.
901 return placement, nil, true, "Could not list your fleets."
902 }
903 for _, f := range rows {
904 // Fleet names are one flat, cross-account namespace (this file's
905 // package doc comment): this filter is the only thing keeping another
906 // account's fleet names off the page. ListFleets is already ORDER BY
907 // name, so the offered order is stable without a second sort.
908 if f.Account == acct.Sqid {
909 fleets = append(fleets, f.Name)
910 }
911 }
912 return placement, fleets, true, ""
913}
914
915// fleetPlaceableApps is what the fleet page's own form may offer: the
916// account's live apps that are not ALREADY placed here, derived from the
917// placed-apps grouping loadFleetPage just walked rather than a second route
918// pass. A failed apps read costs the form (nil), never the page — the same
919// soft-fail posture everything else on this page has.
920func (s *Server) fleetPlaceableApps(acct *Account, placed []fleetPagePlacedApp) []string {
921 apps, err := s.store.AppsIn(acct.ID)
922 if err != nil {
923 log.Printf("console: fleet page %s: list apps: %v", acct.Sqid, err)
924 return nil
925 }
926 here := make(map[string]bool, len(placed))
927 for _, p := range placed {
928 here[p.App] = true
929 }
930 out := make([]string, 0, len(apps))
931 for _, a := range apps {
932 if !here[a] {
933 out = append(out, a)
934 }
935 }
936 return out
937}
938
939// ---- the console fleet page (Task 9) --------------------------------------
940//
941// GET /a/{sqid}/fleets/{fleet} is member-readable (any role sees the boxes
942// and placed apps), and its two mutation forms — rotate, detach — are
943// owner-gated, CSRF-checked POSTs onto the same registry writes the CLI API
944// doors above already make (SetFleetBoxToken, SetFleetBoxStatus). Nothing
945// here is a new capability; it is the same fleet, the same rows, on a
946// session instead of a bearer token.
947
948// fleetPageBox is one box's row on the owner's fleet page: the registry's
949// own record (Label, Status) joined to its heartbeat BY LABEL — the
950// "Box.Label equals fleet_boxes.label" identity ruling from Task 6, because
951// the channel label IS the identity, not a machine pseudonym.
952//
953// Reachable covers both halves of "this box has nothing current to show":
954// no heartbeat has ever joined this row at all (Task 6's KNOWN
955// DEGRADATION — a box with invalid fleet identity config heartbeats under a
956// hashed pseudonym label instead, which never joins here), and a heartbeat
957// joined once but has since gone stale (status.BoxStale against
958// status.SnapshotPeriod, the same threshold HostRows/view.go uses for the
959// platform's own operator page). Both cases render as "no numbers", never a
960// guess — Age distinguishes them for display (see the doc comment on it)
961// but neither ever carries Metrics.
962type fleetPageBox struct {
963 Label string
964 Status string // "active" | "detached" — registry.FleetBox.Status, never TokenHash
965 // Reachable is true only for a heartbeat that both joined and is fresh.
966 Reachable bool
967 // Age is how long ago the last heartbeat was seen, pre-rendered
968 // ("1h2m") by humanAge (view.go) — set only when a heartbeat joined but
969 // has gone stale. Empty AND Reachable false together mean no heartbeat
970 // has ever joined this row: "no heartbeat yet", not "0m ago".
971 Age string
972 // Tunnel and Metrics are set only when Reachable — an unreachable or
973 // never-joined box shows no numbers at all, ever (mirrors HostRows).
974 Tunnel string
975 Metrics *status.BoxMetrics
976 // Tags is registry.FleetBox.Tags (2026-08-10, Task 3) split on "," — nil
977 // for an untagged box, never a slice holding one empty string, so the
978 // template's {{if not .Tags}} placeholder check is a plain nil test.
979 Tags []string
980}
981
982// fleetPagePlacedApp is one app placed on this fleet: its name and the
983// routes (by host) that carry the placement — registry.Route rows whose
984// Region is registry.PlacementTarget(fleet), grouped by App.
985type fleetPagePlacedApp struct {
986 App string
987 Hosts []string
988}
989
990// resolveOwnedFleet is the browser routes' fleet-ownership check — the
991// page-side twin of apiFleet above, and for the identical reason: fleet
992// names are one flat, cross-account namespace (this file's package doc
993// comment), so an unknown fleet and one owned by a DIFFERENT account must
994// answer with the exact same http.NotFound an unrouted URL gets, never a
995// distinguishable "no such fleet" message that would turn this into a
996// cross-account oracle. Every browser door onto a {sqid}/{fleet} URL calls
997// this AFTER its own membership gate (account or owner), never before.
998func (s *Server) resolveOwnedFleet(w http.ResponseWriter, r *http.Request, acct *Account) (*registry.Fleet, consoleRegistry, bool) {
999 reg, err := openRegistry(s.cfg.Registry)
1000 if err != nil {
1001 log.Printf("console: fleet page: open registry: %v", err)
1002 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not reach the fleet registry.")
1003 return nil, nil, false
1004 }
1005 name := r.PathValue("fleet")
1006 f, err := reg.GetFleet(name)
1007 if err != nil {
1008 log.Printf("console: fleet page %s/%s: %v", acct.Sqid, name, err)
1009 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the fleet.")
1010 return nil, nil, false
1011 }
1012 if f == nil || f.Account != acct.Sqid {
1013 http.NotFound(w, r)
1014 return nil, nil, false
1015 }
1016 return f, reg, true
1017}
1018
1019// accountFleet gates GET .../fleets/{fleet}: session membership, any role —
1020// a member reads the same page an owner does, just without its forms (the
1021// template's own {{if .IsOwner}} checks, not a second gate here).
1022func (s *Server) accountFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, fleet *registry.Fleet, reg consoleRegistry, ok bool) {
1023 addr, raw, acct, role, ok = s.account(w, r)
1024 if !ok {
1025 return "", "", nil, "", nil, nil, false
1026 }
1027 fleet, reg, ok = s.resolveOwnedFleet(w, r, acct)
1028 if !ok {
1029 return "", "", nil, "", nil, nil, false
1030 }
1031 return addr, raw, acct, role, fleet, reg, true
1032}
1033
1034// ownerFleet gates the two mutation doors (rotate, detach): owner only,
1035// same byte-identical-404 fleet resolution accountFleet uses.
1036func (s *Server) ownerFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, fleet *registry.Fleet, reg consoleRegistry, ok bool) {
1037 addr, raw, acct, ok = s.owner(w, r)
1038 if !ok {
1039 return "", "", nil, nil, nil, false
1040 }
1041 fleet, reg, ok = s.resolveOwnedFleet(w, r, acct)
1042 if !ok {
1043 return "", "", nil, nil, nil, false
1044 }
1045 return addr, raw, acct, fleet, reg, true
1046}
1047
1048// loadFleetPage reads everything the fleet page's body needs beyond the
1049// fleet record itself: every registered box, each joined to its own
1050// heartbeat (read through release.NewPrefixStore(s.cfg.Releases,
1051// "fleets/<fleet>/"), the same rebasing a real fleet box's agent writes
1052// through — release/store.go's doc comment on prefixStore names this page
1053// as its second caller), and every route this account owns whose Region
1054// places it on this fleet, grouped by app.
1055//
1056// Every failure here is soft: a box list, heartbeat read, or route list
1057// that errors is logged and degrades to "nothing to show" for that section
1058// (warn carries the first one, for a flash banner) — this page must never
1059// 500 because one of three independent reads had a bad moment, the same
1060// posture HostRows (view.go) and handleFleet already take.
1061func (s *Server) loadFleetPage(ctx context.Context, reg consoleRegistry, acct *Account, fleet *registry.Fleet) (boxes []fleetPageBox, apps []fleetPagePlacedApp, warn string) {
1062 regBoxes, err := reg.ListFleetBoxes(fleet.Name)
1063 if err != nil {
1064 log.Printf("console: fleet page %s/%s: list boxes: %v", acct.Sqid, fleet.Name, err)
1065 warn = "Could not list this fleet's boxes."
1066 }
1067
1068 // A console with no deployment bucket configured has no heartbeats to
1069 // read at all — every box renders as "no heartbeat yet", not an error.
1070 heartbeats := map[string]status.Box{}
1071 if s.cfg.Releases != nil {
1072 hbs, err := status.LoadBoxes(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet.Name+"/"))
1073 if err != nil {
1074 log.Printf("console: fleet page %s/%s: load heartbeats: %v", acct.Sqid, fleet.Name, err)
1075 // A heartbeat read that failed is NOT the same as "no box has
1076 // ever heartbeated" — the former is "unknown, could not check",
1077 // the latter is a positive claim this page has no business
1078 // making when the truth is "S3 is having a moment". Every box
1079 // below still renders (degrading softly, per this function's
1080 // doc comment), but the flash makes the distinction honest
1081 // rather than silently collapsing it into "no heartbeat yet".
1082 if warn == "" {
1083 warn = "Could not read this fleet's heartbeats."
1084 }
1085 } else {
1086 for _, b := range hbs {
1087 heartbeats[b.Label] = b
1088 }
1089 }
1090 }
1091
1092 now := time.Now()
1093 boxes = make([]fleetPageBox, 0, len(regBoxes))
1094 for _, b := range regBoxes {
1095 row := fleetPageBox{Label: b.Label, Status: b.Status}
1096 if b.Tags != "" {
1097 row.Tags = strings.Split(b.Tags, ",")
1098 }
1099 // Join key is the label, per the Task 6 identity ruling in this
1100 // function's doc comment — an orphan heartbeat under some OTHER
1101 // label (a box heartbeating under a hashed pseudonym) simply never
1102 // matches here, which is exactly the degrade-safely behaviour this
1103 // row is built to have: it falls through to the zero-value
1104 // Reachable=false, Age="" case below, "no heartbeat yet".
1105 if hb, joined := heartbeats[b.Label]; joined {
1106 if status.BoxStale(hb, now, status.SnapshotPeriod) {
1107 row.Age = humanAge(now.Sub(hb.GeneratedAt))
1108 } else {
1109 row.Reachable = true
1110 if hb.Metrics != nil {
1111 row.Tunnel = hb.Metrics.Tunnel
1112 row.Metrics = hb.Metrics
1113 }
1114 }
1115 }
1116 boxes = append(boxes, row)
1117 }
1118
1119 routes, err := reg.List()
1120 if err != nil {
1121 log.Printf("console: fleet page %s/%s: list routes: %v", acct.Sqid, fleet.Name, err)
1122 if warn == "" {
1123 warn = "Could not list this fleet's placed apps."
1124 }
1125 return boxes, nil, warn
1126 }
1127 target := registry.PlacementTarget(fleet.Name)
1128 byApp := map[string][]string{}
1129 for _, rt := range routes {
1130 if rt.Account != acct.Sqid || rt.Region != target {
1131 continue
1132 }
1133 byApp[rt.App] = append(byApp[rt.App], rt.Host)
1134 }
1135 names := make([]string, 0, len(byApp))
1136 for app := range byApp {
1137 names = append(names, app)
1138 }
1139 sort.Strings(names)
1140 apps = make([]fleetPagePlacedApp, 0, len(names))
1141 for _, app := range names {
1142 hosts := byApp[app]
1143 sort.Strings(hosts)
1144 apps = append(apps, fleetPagePlacedApp{App: app, Hosts: hosts})
1145 }
1146 return boxes, apps, warn
1147}
1148
1149// handleFleetPage serves GET /a/{sqid}/fleets/{fleet}.
1150func (s *Server) handleFleetPage(w http.ResponseWriter, r *http.Request) {
1151 addr, raw, acct, role, fleet, reg, ok := s.accountFleet(w, r)
1152 if !ok {
1153 return
1154 }
1155 p := s.accountPage(w, r, addr, raw, acct, role)
1156 p.Title = fleet.Name
1157 p.ActiveNav = "fleet"
1158 p.FleetDetail = fleet
1159 boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet)
1160 p.FleetBoxes = boxes
1161 p.FleetApps = apps
1162 p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps)
1163 if warn != "" && p.FlashErr == "" {
1164 p.FlashErr = warn
1165 }
1166 s.render(w, http.StatusOK, "fleetdetail", p)
1167}
1168
1169// fleetPageURL is where the two mutation POSTs below redirect back to on an
1170// ordinary (non-token) outcome.
1171func fleetPageURL(sqid, fleet string) string {
1172 return "/a/" + url.PathEscape(sqid) + "/fleets/" + url.PathEscape(fleet)
1173}
1174
1175// handleFleetRotateBoxPage answers the owner's "Rotate token" form: the
1176// same SetFleetBoxToken write handleAPIFleetRotateBox makes, with the fresh
1177// plaintext shown directly in this response — never redirected, never put
1178// in a flash cookie — so it is on the page exactly once, the way
1179// printFleetToken (fleets.go, the CLI) prints it exactly once to stdout.
1180// ROTATION IS NOT REVOCATION, same warning, same reasoning: see
1181// handleAPIFleetRotateBox's doc comment.
1182func (s *Server) handleFleetRotateBoxPage(w http.ResponseWriter, r *http.Request) {
1183 addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r)
1184 if !ok {
1185 return
1186 }
1187 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1188 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1189 return
1190 }
1191 back := func(kind, msg string) {
1192 s.setFlash(w, kind, msg)
1193 http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
1194 }
1195 label := strings.TrimSpace(r.FormValue("label"))
1196 box, err := findFleetBox(reg, fleet.Name, label)
1197 if err != nil {
1198 log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1199 back("err", "Could not read the box.")
1200 return
1201 }
1202 if box == nil {
1203 back("err", "No such box.")
1204 return
1205 }
1206 plaintext, hash, err := mintFleetToken()
1207 if err != nil {
1208 log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err)
1209 back("err", "Could not mint a token — try again.")
1210 return
1211 }
1212 if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil {
1213 log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1214 back("err", "Could not rotate the token — try again.")
1215 return
1216 }
1217 s.audit(acct, fleet.Name, addr, "fleet-rotate-box", "rotated the token for "+fleet.Name+"/"+label+" (console)")
1218
1219 p := s.accountPage(w, r, addr, raw, acct, RoleOwner)
1220 p.Title = fleet.Name
1221 p.FleetDetail = fleet
1222 boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet)
1223 p.FleetBoxes = boxes
1224 p.FleetApps = apps
1225 p.FleetPlaceableApps = s.fleetPlaceableApps(acct, apps)
1226 p.FlashErr = warn
1227 p.RotatedLabel = label
1228 p.RotatedToken = plaintext
1229 // This response carries a bearer credential in its body — the same
1230 // reason writeJSON (authcli.go) sets this on every JSON response,
1231 // including the rotate/add-box token bodies. A cached or
1232 // bfcache/session-restored copy of an HTML page is exactly the kind of
1233 // second life a JSON response never gets, which makes the header more
1234 // load-bearing here, not less.
1235 w.Header().Set("Cache-Control", "no-store")
1236 s.render(w, http.StatusOK, "fleetdetail", p)
1237}
1238
1239// handleFleetDetachBoxPage answers the owner's "Detach" form: the same
1240// SetFleetBoxStatus write handleAPIFleetDetachBox makes. No secret to show,
1241// so this redirects back to the fleet page with an ordinary flash notice.
1242func (s *Server) handleFleetDetachBoxPage(w http.ResponseWriter, r *http.Request) {
1243 addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r)
1244 if !ok {
1245 return
1246 }
1247 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1248 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1249 return
1250 }
1251 back := func(kind, msg string) {
1252 s.setFlash(w, kind, msg)
1253 http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
1254 }
1255 label := strings.TrimSpace(r.FormValue("label"))
1256 box, err := findFleetBox(reg, fleet.Name, label)
1257 if err != nil {
1258 log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1259 back("err", "Could not read the box.")
1260 return
1261 }
1262 if box == nil {
1263 back("err", "No such box.")
1264 return
1265 }
1266 if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil {
1267 log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1268 back("err", "Could not detach the box — try again.")
1269 return
1270 }
1271 s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (console)")
1272 back("ok", "Detached "+fleet.Name+"/"+label+".")
1273}
1274
1275// handleFleetTagBoxPage answers the owner's "Tag" form: the same full-replace
1276// SetFleetBoxTags write handleAPIFleetTagBox makes, gate/flash/redirect
1277// skeleton copied from handleFleetRotateBoxPage/handleFleetDetachBoxPage —
1278// ownerFleet then CSRF, then an ordinary redirect-with-flash outcome (no
1279// secret in the body, so there is no reason for this to render directly the
1280// way rotate's response does).
1281//
1282// Grammar (registry.ValidBoxTags) is checked here, before the registry write
1283// ever runs and before the label is even looked up — reject, don't sanitize,
1284// the same order and the same user-facing copy handleAPIFleetTagBox already
1285// uses, so a bad-grammar submission never touches the row regardless of
1286// whether the named box exists.
1287func (s *Server) handleFleetTagBoxPage(w http.ResponseWriter, r *http.Request) {
1288 addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r)
1289 if !ok {
1290 return
1291 }
1292 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
1293 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
1294 return
1295 }
1296 back := func(kind, msg string) {
1297 s.setFlash(w, kind, msg)
1298 http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther)
1299 }
1300 tags := strings.TrimSpace(r.FormValue("tags"))
1301 if !registry.ValidBoxTags(tags) {
1302 back("err", "invalid tags — comma-separated [a-z0-9-] (hyphens interior), max 8")
1303 return
1304 }
1305 label := strings.TrimSpace(r.FormValue("label"))
1306 box, err := findFleetBox(reg, fleet.Name, label)
1307 if err != nil {
1308 log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1309 back("err", "Could not read the box.")
1310 return
1311 }
1312 if box == nil {
1313 back("err", "No such box.")
1314 return
1315 }
1316 if err := reg.SetFleetBoxTags(fleet.Name, label, tags); err != nil {
1317 log.Printf("console: fleet tag-box %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err)
1318 back("err", "Could not tag the box — try again.")
1319 return
1320 }
1321 s.audit(acct, fleet.Name, addr, "fleet-tag-box", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (console)")
1322 back("ok", "Tagged "+fleet.Name+"/"+label+".")
1323}
1324