| 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. |
| 22 | package console |
| 23 | |
| 24 | import ( |
| 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). |
| 50 | type 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 | |
| 59 | type 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. |
| 72 | func (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. |
| 104 | func 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. |
| 125 | func 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.) |
| 143 | var 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). |
| 156 | func (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. |
| 229 | func (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. |
| 270 | func (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". |
| 331 | func (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. |
| 387 | func (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. |
| 433 | func (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. |
| 490 | func (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. |
| 517 | const 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. |
| 578 | func (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. |
| 729 | func (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)". |
| 774 | func 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). |
| 791 | func (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. |
| 834 | func (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. |
| 885 | func (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. |
| 920 | func (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. |
| 962 | type 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. |
| 985 | type 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. |
| 998 | func (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). |
| 1022 | func (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. |
| 1036 | func (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. |
| 1061 | func (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}. |
| 1150 | func (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. |
| 1171 | func 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. |
| 1182 | func (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. |
| 1242 | func (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. |
| 1287 | func (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 | |