console: fleets API — create, boxes, rotate, detach, place
6 files changed,
+1015
−3
internal/console/authcli.go+13 −0internal/console/fleets.go+469 −0internal/console/fleets_test.go+430 −0internal/console/server.go+7 −3internal/provision/provision.go+48 −0internal/provision/provision_test.go+48 −0
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 6fa89c19d..7a2e68555 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -256,6 +256,19 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet) |
| s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync) |
| + // The customer fleets API (fleets.go) — account-owned remote-box groups |
| + // and where an app's routes/instances place onto them. `new` is a |
| + // literal beside the {fleet} wildcard for the same reason apps/new is: |
| + // the mux prefers a literal regardless of registration order, so there |
| + // is no ambiguity with a fleet that happened to be named "new". |
| + s.mux.HandleFunc("POST /api/cli/fleets/{account}/new", s.handleAPIFleetNew) |
| + s.mux.HandleFunc("GET /api/cli/fleets/{account}", s.handleAPIFleetList) |
| + s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", s.handleAPIFleetAddBox) |
| + s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", s.handleAPIFleetRotateBox) |
| + s.mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", s.handleAPIFleetDetachBox) |
| + // Placement lives under the apps family, not fleets: its subject is an |
| + // APP, and a target fleet name is only ever the request body's data. |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", s.handleAPIPlace) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| new file mode 100644 |
| index 000000000..2fa24e4d8 |
| --- /dev/null |
| +++ b/internal/console/fleets.go |
| @@ -0,0 +1,469 @@ |
| +// Customer fleets (2026-08-05-customer-fleets-design.md): the console-API |
| +// door onto internal/registry's account-owned Fleet/FleetBox rows and onto |
| +// an app's placement — where its routes (and its already-provisioned |
| +// instances) should run. Every route below is bearer-authenticated and |
| +// account-scoped, the same apiAccount/apiAccountApp resolution the rest of |
| +// the CLI API uses, for the same reason: an unknown account, someone else's |
| +// account, and — new here — someone else's FLEET must be one indistinguishable |
| +// 404, because fleet names live in one flat, cross-account namespace |
| +// (fleets.name is the registry's primary key, not scoped per account), so a |
| +// caller's own valid {account} segment could otherwise be used to probe |
| +// which fleet names exist elsewhere. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// fleetBoxView and fleetView are the wire shapes for GET .../fleets and the |
| +// "fleet" field POST .../new returns — deliberately NOT registry.Fleet / |
| +// registry.FleetBox: those carry TokenHash, which this door must never echo |
| +// back (the plaintext is shown once, at mint/rotate, and nowhere else). |
| +type fleetBoxView struct { |
| + Label string `json:"label"` |
| + Status string `json:"status"` |
| +} |
| + |
| +type fleetView struct { |
| + Name string `json:"name"` |
| + Bucket string `json:"bucket"` |
| + Boxes []fleetBoxView `json:"boxes"` |
| +} |
| + |
| +// apiFleet resolves and authorizes one {account}/{fleet} CLI API request: |
| +// the account half is apiAccount's own resolution (bearer -> address -> |
| +// caller's own memberships), and the fleet half is GetFleet — with a fleet |
| +// that does not exist and a fleet owned by a DIFFERENT account answering the |
| +// exact same 404, mirroring apiAccountAppBy's "unknown and not-yours are one |
| +// answer" rule for apps. It also hands back the registry handle it opened, |
| +// since every caller needs one for whatever it does next. |
| +func (s *Server) apiFleet(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, fleet *registry.Fleet, reg *registry.Registry, ok bool) { |
| + addr, acct, ok = s.apiAccount(w, r) |
| + if !ok { |
| + return "", nil, nil, nil, false |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: api fleet: open registry: %v", err) |
| + jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") |
| + return "", nil, nil, nil, false |
| + } |
| + name := r.PathValue("fleet") |
| + f, err := reg.GetFleet(name) |
| + if err != nil { |
| + log.Printf("console: api fleet %s/%s: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not read the fleet") |
| + return "", nil, nil, nil, false |
| + } |
| + if f == nil || f.Account != acct.Sqid { |
| + http.NotFound(w, r) |
| + return "", nil, nil, nil, false |
| + } |
| + return addr, acct, f, reg, true |
| +} |
| + |
| +// findFleetBox looks up one box by label within a fleet already proven to |
| +// exist and be owned by the caller — a plain scan of ListFleetBoxes rather |
| +// than a new registry method, since this door is the only caller and a |
| +// fleet's box count is small. (nil, nil) means no such label; that answers |
| +// its own refusal (404, "no such box") rather than the fleet-ownership 404 |
| +// above, because within an ALREADY-OWNED fleet a bad label is an ordinary |
| +// not-found, not a cross-tenant probe. |
| +func findFleetBox(reg *registry.Registry, fleet, label string) (*registry.FleetBox, error) { |
| + boxes, err := reg.ListFleetBoxes(fleet) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for i := range boxes { |
| + if boxes[i].Label == label { |
| + return &boxes[i], nil |
| + } |
| + } |
| + return nil, nil |
| +} |
| + |
| +// mintFleetToken mints one bearer credential the way every token-bearing |
| +// door in this console does: hex(32 bytes of crypto/rand) as the plaintext, |
| +// sha256 hex of THAT STRING (not the raw bytes) as what gets stored — the |
| +// exact convention hashToken already applies to CLI/app tokens, and the one |
| +// internal/edge's tunnel listener assumes when it hashes a presented bearer |
| +// token to check against FleetChannelAuth's table (tunnel.go). Reusing |
| +// hashToken here, rather than hashing the raw bytes directly, is what keeps |
| +// mint and verify agreeing on what "the token" means. |
| +func mintFleetToken() (plaintext, hash string, err error) { |
| + var raw [32]byte |
| + if _, err := rand.Read(raw[:]); err != nil { |
| + return "", "", err |
| + } |
| + plaintext = hex.EncodeToString(raw[:]) |
| + return plaintext, hashToken(plaintext), nil |
| +} |
| + |
| +// handleFleetNew answers `carlos fleets create`: mints the account-owned |
| +// fleet record. The data bucket is taken on trust — it must already exist |
| +// (customer tofu, spec §"Surfaces") — this door only records its name. |
| +// |
| +// The existence check and the write below are two calls, not one atomic |
| +// insert: UpsertFleet (Task 1) is a plain upsert keyed on name, with no |
| +// insert-only mode to lose a race against — so two concurrent creates of the |
| +// same brand-new name from two different accounts could, in the narrow |
| +// window between them, have the second one silently take over the first's |
| +// row. Accepted for v1 under the same operator-trust posture the whole |
| +// feature ships under (design doc, "Trust boundaries"): fleet creation is a |
| +// deliberate, rare, human-typed act, not a surface under adversarial load. |
| +func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, ok := s.apiAccount(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + Name string `json:"name"` |
| + Bucket string `json:"bucket"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + name := strings.TrimSpace(req.Name) |
| + if !registry.ValidTunnelName(name) { |
| + jsonError(w, http.StatusBadRequest, |
| + "invalid fleet name — 1-32 characters of a-z, 0-9 and hyphen") |
| + return |
| + } |
| + bucket := strings.TrimSpace(req.Bucket) |
| + if bucket == "" { |
| + jsonError(w, http.StatusBadRequest, "bucket is required — the data bucket must already exist") |
| + return |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: fleet new %s/%s: open registry: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") |
| + return |
| + } |
| + if existing, err := reg.GetFleet(name); err != nil { |
| + log.Printf("console: fleet new %s/%s: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not check the fleet name") |
| + return |
| + } else if existing != nil { |
| + jsonError(w, http.StatusConflict, "that name is taken") |
| + return |
| + } |
| + f := registry.Fleet{Name: name, Account: acct.Sqid, Bucket: bucket, CreatedAt: time.Now().Unix()} |
| + if err := reg.UpsertFleet(f); err != nil { |
| + log.Printf("console: fleet new %s/%s: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not create the fleet — try again") |
| + return |
| + } |
| + s.audit(acct, name, addr, "fleet-create", "created fleet "+name+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "fleet": fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: []fleetBoxView{}}, |
| + }) |
| +} |
| + |
| +// handleFleetList answers `carlos fleets ls`: every fleet the caller's |
| +// account owns, each with its boxes' label and status — never a token hash. |
| +func (s *Server) handleAPIFleetList(w http.ResponseWriter, r *http.Request) { |
| + _, acct, ok := s.apiAccount(w, r) |
| + if !ok { |
| + return |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: fleet list %s: open registry: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") |
| + return |
| + } |
| + all, err := reg.ListFleets() |
| + if err != nil { |
| + log.Printf("console: fleet list %s: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, "could not list fleets") |
| + return |
| + } |
| + out := make([]fleetView, 0) |
| + for _, f := range all { |
| + if f.Account != acct.Sqid { |
| + continue |
| + } |
| + boxes, err := reg.ListFleetBoxes(f.Name) |
| + if err != nil { |
| + log.Printf("console: fleet list %s: boxes of %s: %v", acct.Sqid, f.Name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not list fleets") |
| + return |
| + } |
| + bv := make([]fleetBoxView, 0, len(boxes)) |
| + for _, b := range boxes { |
| + bv = append(bv, fleetBoxView{Label: b.Label, Status: b.Status}) |
| + } |
| + out = append(out, fleetView{Name: f.Name, Bucket: f.Bucket, Boxes: bv}) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"fleets": out}) |
| +} |
| + |
| +// handleFleetAddBox answers `carlos fleets add-box`: mints a box row and its |
| +// bearer token. The plaintext appears exactly once, in this response body — |
| +// only its sha256 hex reaches the registry (AddFleetBox), and it is never |
| +// logged. |
| +func (s *Server) handleAPIFleetAddBox(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, fleet, reg, ok := s.apiFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + Label string `json:"label"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + label := strings.TrimSpace(req.Label) |
| + if !registry.ValidTunnelName(label) { |
| + jsonError(w, http.StatusBadRequest, |
| + "invalid box label — 1-32 characters of a-z, 0-9 and hyphen") |
| + return |
| + } |
| + plaintext, hash, err := mintFleetToken() |
| + if err != nil { |
| + log.Printf("console: fleet add-box %s/%s: mint token: %v", acct.Sqid, fleet.Name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint a token — try again") |
| + return |
| + } |
| + // The fleet is already proven to exist (apiFleet), so the only reachable |
| + // failure from here is AddFleetBox's own uniqueness guard: an ACTIVE box |
| + // already registered under this label. |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: fleet.Name, Label: label, TokenHash: hash, Status: "active"}); err != nil { |
| + jsonError(w, http.StatusConflict, "fleet "+fleet.Name+" already has an active box "+label) |
| + return |
| + } |
| + s.audit(acct, fleet.Name, addr, "fleet-add-box", "added box "+label+" to fleet "+fleet.Name+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{"label": label, "token": plaintext}) |
| +} |
| + |
| +// handleFleetRotateBox answers `carlos fleets rotate-token`: a fresh token |
| +// for a box that already exists, its hash replacing the stored one. |
| +// |
| +// ROTATION IS NOT REVOCATION. It refuses the box's NEXT dial-in attempt with |
| +// the OLD token — the edge's tunnel listener checks a presented token's hash |
| +// against the registry on every new connection, and the old hash simply |
| +// stops matching once this write lands (spec's "Edge: dynamic fleet |
| +// channels", within the console's usual convergence window; carried from |
| +// Task 3's own rotation contract for the platform's own tunnel clients). It |
| +// does NOT evict a channel that is already connected: a box mid-session |
| +// keeps talking on the connection it already holds, because rotation only |
| +// changes what is checked when a NEW connection is authenticated. A box |
| +// whose credential may be compromised needs DETACH (handleAPIFleetDetachBox), |
| +// which the edge honours by closing the live channel outright — rotation |
| +// alone is the wrong tool for "get this box off the network right now". |
| +func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, fleet, reg, ok := s.apiFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct{} |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + label := r.PathValue("label") |
| + box, err := findFleetBox(reg, fleet.Name, label) |
| + if err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not read the box") |
| + return |
| + } |
| + if box == nil { |
| + jsonError(w, http.StatusNotFound, "no such box") |
| + return |
| + } |
| + plaintext, hash, err := mintFleetToken() |
| + if err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not mint a token — try again") |
| + return |
| + } |
| + if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not rotate the token — try again") |
| + return |
| + } |
| + s.audit(acct, fleet.Name, addr, "fleet-rotate-box", |
| + "rotated the token for "+fleet.Name+"/"+label+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{"token": plaintext}) |
| +} |
| + |
| +// handleFleetDetachBox answers `carlos fleets detach-box`: flips a box to |
| +// "detached", which the edge treats as an outright refusal (a dial-in is |
| +// refused, and — unlike rotation — an already-connected channel is closed). |
| +// This is the hard revocation rotation is not. |
| +func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, fleet, reg, ok := s.apiFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct{} |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + label := r.PathValue("label") |
| + box, err := findFleetBox(reg, fleet.Name, label) |
| + if err != nil { |
| + log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not read the box") |
| + return |
| + } |
| + if box == nil { |
| + jsonError(w, http.StatusNotFound, "no such box") |
| + return |
| + } |
| + if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil { |
| + log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + jsonError(w, http.StatusInternalServerError, "could not detach the box — try again") |
| + return |
| + } |
| + s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{}) |
| +} |
| + |
| +// handleAPIPlace answers `carlos apps place`: repoints where an app's routes |
| +// (and its already-provisioned instances) should run. target "" clears |
| +// placement back to platform-served/unconstrained; any other value must |
| +// parse as "fleet/<name>" (registry.FleetPlacement) naming a fleet the |
| +// CALLER'S OWN account owns — placing onto another account's fleet is |
| +// refused with the same byte-identical 404 apiFleet gives an unowned one, |
| +// since fleet names are a flat, cross-account namespace (see this file's |
| +// package doc comment). |
| +// |
| +// THE TRIPWIRE this handler exists to close: placement is stored in |
| +// Route.Region (SetPlacement) AND, separately, in every one of the app's |
| +// already-provisioned instance records' Region field |
| +// (provision.SetRecordRegion) — both are read by provision.Reconcile's |
| +// filter (rec.Region != cfg.Region) on a fleet box running |
| +// CARLOS_REGION=fleet/<name>. Stamping routes alone would repoint where a |
| +// BRAND NEW instance provisions but leave every instance that already |
| +// exists filed under its old region forever — silently exempt from a |
| +// placement change the caller was just told succeeded. Both writes happen |
| +// here, in this order (routes, then records); a console with no deployment |
| +// bucket configured (cfg.Releases == nil) refuses the whole request rather |
| +// than silently doing the routes-only half. |
| +func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — placement needs it to stamp this app's instances") |
| + return |
| + } |
| + var req struct { |
| + Target string `json:"target"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + target := strings.TrimSpace(req.Target) |
| + |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: place %s/%s: open registry: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not reach the registry") |
| + return |
| + } |
| + |
| + if target != "" { |
| + fleetName, validShape := registry.FleetPlacement(target) |
| + if !validShape { |
| + jsonError(w, http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"`) |
| + return |
| + } |
| + f, err := reg.GetFleet(fleetName) |
| + if err != nil { |
| + log.Printf("console: place %s/%s: get fleet %s: %v", acct.Sqid, app, fleetName, err) |
| + jsonError(w, http.StatusInternalServerError, "could not check the fleet") |
| + return |
| + } |
| + if f == nil || f.Account != acct.Sqid { |
| + // Unknown and not-yours are the same refusal everywhere else in |
| + // this file (apiFleet); place's target check is no exception — |
| + // this door must not become a way to learn which fleet names |
| + // exist in someone else's account. |
| + http.NotFound(w, r) |
| + return |
| + } |
| + } |
| + |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the app's routes — try again") |
| + return |
| + } |
| + for _, rt := range routes { |
| + if rt.Account != acct.Sqid || rt.App != app { |
| + continue |
| + } |
| + if err := reg.SetPlacement(rt.Host, target); err != nil { |
| + log.Printf("console: place %s/%s: set placement on %s: %v", acct.Sqid, app, rt.Host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update every route — try again") |
| + return |
| + } |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + prefix := provision.RecordsPrefix(acct.Sqid, app) |
| + keys, err := s.cfg.Releases.List(ctx, prefix) |
| + if err != nil { |
| + log.Printf("console: place %s/%s: list instances: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not update this app's instances — try again") |
| + return |
| + } |
| + for _, key := range keys { |
| + rest, ok := strings.CutPrefix(key, prefix) |
| + if !ok || rest == "" || strings.Contains(rest, "/") { |
| + continue // not a record at this level |
| + } |
| + host, ok := strings.CutSuffix(rest, ".json") |
| + if !ok || host == "" { |
| + continue |
| + } |
| + if err := provision.SetRecordRegion(ctx, s.cfg.Releases, acct.Sqid, app, host, target); err != nil { |
| + log.Printf("console: place %s/%s: stamp instance %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update every instance — try again") |
| + return |
| + } |
| + } |
| + |
| + detail := "cleared placement for " + app + " (cli)" |
| + if target != "" { |
| + detail = "placed " + app + " on " + target + " (cli)" |
| + } |
| + s.audit(acct, app, addr, "place", detail) |
| + writeJSON(w, http.StatusOK, map[string]any{}) |
| +} |
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go| new file mode 100644 |
| index 000000000..3e787bbce |
| --- /dev/null |
| +++ b/internal/console/fleets_test.go |
| @@ -0,0 +1,430 @@ |
| +package console |
| + |
| +// The fleets API (fleets.go): account-owned Fleet/FleetBox rows and app |
| +// placement. The properties that matter, in the order they'd hurt if lost: |
| +// a fleet name owned by another account resolves the SAME byte-identical |
| +// 404 an unknown one does (fleet names are one flat, cross-account |
| +// namespace — see fleets.go's package doc), a minted or rotated token's |
| +// plaintext appears exactly once and its stored hash is really its sha256, |
| +// detach flips status without touching the token, and `place` stamps BOTH |
| +// an app's routes and its already-provisioned instance records (the |
| +// tripwire the brief calls out by name) while refusing a target fleet the |
| +// caller does not own. |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// fleetFixture is a console with paul (the fixture's default ops-account |
| +// owner, sqid fx.sqid) holding a live CLI token — owner-level, which is |
| +// exactly the gate every fleet door grants (there is no member/owner split |
| +// in this API; the account-scoped 404 is the only gate). |
| +func fleetFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + return fx, cliToken(t, fx, "paul@keymail.dev", code) |
| +} |
| + |
| +func fleetsURL(fx *consoleFixture, acct string) string { |
| + return fx.ts.URL + "/api/cli/fleets/" + acct |
| +} |
| + |
| +// openTestReg opens a second, independent handle on the fixture's registry |
| +// file to seed rows the console API itself has no door for (routes) — |
| +// called before the console's own handlers ever touch this path, so there |
| +// is no cross-connection WAL-staleness risk (registry.go's List/Fingerprint |
| +// doc comment): the console's cached handle (openRegistry, view.go) does |
| +// not exist yet, and every write below is committed before that first call. |
| +func openTestReg(t *testing.T, fx *consoleFixture) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return reg |
| +} |
| + |
| +// TestFleetCreateListRoundTrip: create, then list, and the created fleet |
| +// comes back with an empty (never null) boxes array. |
| +func TestFleetCreateListRoundTrip(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-roundtrip") |
| + status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create = %d %v, want 200", status, body) |
| + } |
| + f, ok := body["fleet"].(map[string]any) |
| + if !ok { |
| + t.Fatalf("create body = %v, want a \"fleet\" object", body) |
| + } |
| + if f["name"] != "acme" || f["bucket"] != "acme-data" { |
| + t.Fatalf("fleet = %v, want name=acme bucket=acme-data", f) |
| + } |
| + boxes, ok := f["boxes"].([]any) |
| + if !ok || len(boxes) != 0 { |
| + t.Fatalf("fleet.boxes = %v, want an empty array", f["boxes"]) |
| + } |
| + |
| + status, body = getJSON(t, fleetsURL(fx, fx.sqid), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %v, want 200", status, body) |
| + } |
| + list, ok := body["fleets"].([]any) |
| + if !ok || len(list) != 1 { |
| + t.Fatalf("list = %v, want exactly the one fleet", body) |
| + } |
| + got := list[0].(map[string]any) |
| + if got["name"] != "acme" || got["bucket"] != "acme-data" { |
| + t.Fatalf("listed fleet = %v, want name=acme bucket=acme-data", got) |
| + } |
| +} |
| + |
| +// TestFleetCreateDuplicateName409: the exact "that name is taken" wording |
| +// handleAPICreateApp already uses for the same conflict on apps. |
| +func TestFleetCreateDuplicateName409(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-dup") |
| + u := fleetsURL(fx, fx.sqid) + "/new" |
| + if status, body := postJSON(t, u, map[string]string{"name": "acme", "bucket": "b1"}, token); status != http.StatusOK { |
| + t.Fatalf("first create = %d %v, want 200", status, body) |
| + } |
| + status, body := postJSON(t, u, map[string]string{"name": "acme", "bucket": "b2"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("duplicate create = %d %v, want 409", status, body) |
| + } |
| + if body["error"] != "that name is taken" { |
| + t.Fatalf("duplicate error = %q, want the exact convention", body["error"]) |
| + } |
| +} |
| + |
| +// TestFleetAddBoxTokenHash: the minted token is 64 hex characters, its |
| +// sha256 hex is exactly what landed in the registry row, and it never |
| +// appears anywhere in the create-fleet or list responses. |
| +func TestFleetAddBoxTokenHash(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-addbox") |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("add-box = %d %v, want 200", status, body) |
| + } |
| + plaintext, _ := body["token"].(string) |
| + if len(plaintext) != 64 { |
| + t.Fatalf("token = %q, want 64 hex characters", plaintext) |
| + } |
| + if body["label"] != "pi-1" { |
| + t.Fatalf("label = %v, want pi-1", body["label"]) |
| + } |
| + sum := sha256.Sum256([]byte(plaintext)) |
| + wantHash := hex.EncodeToString(sum[:]) |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].Label != "pi-1" || boxes[0].Status != "active" { |
| + t.Fatalf("registry boxes = %+v", boxes) |
| + } |
| + if boxes[0].TokenHash != wantHash { |
| + t.Fatalf("stored hash = %s, want sha256(token) = %s", boxes[0].TokenHash, wantHash) |
| + } |
| +} |
| + |
| +// TestFleetRotateBoxChangesHash: a fresh token comes back, distinct from the |
| +// mint's, and the stored hash moves to match it. |
| +func TestFleetRotateBoxChangesHash(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-rotate") |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + _, addBody := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token) |
| + first, _ := addBody["token"].(string) |
| + |
| + status, rotBody := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/rotate", map[string]string{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("rotate = %d %v, want 200", status, rotBody) |
| + } |
| + second, _ := rotBody["token"].(string) |
| + if second == "" || second == first { |
| + t.Fatalf("rotated token = %q, want a fresh 64-hex value distinct from %q", second, first) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256([]byte(second)) |
| + wantHash := hex.EncodeToString(sum[:]) |
| + if len(boxes) != 1 || boxes[0].TokenHash != wantHash { |
| + t.Fatalf("registry boxes = %+v, want hash %s", boxes, wantHash) |
| + } |
| + // The OLD hash no longer matches anything stored. |
| + oldSum := sha256.Sum256([]byte(first)) |
| + if boxes[0].TokenHash == hex.EncodeToString(oldSum[:]) { |
| + t.Fatalf("registry still carries the pre-rotation hash") |
| + } |
| +} |
| + |
| +// TestFleetDetachBoxFlipsStatus: detach flips status to "detached" and |
| +// leaves the token hash untouched — rotation and detachment are different |
| +// axes (fleets.go's rotate doc comment). |
| +func TestFleetDetachBoxFlipsStatus(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-detach") |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token) |
| + |
| + status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes/pi-1/detach", map[string]string{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("detach = %d %v, want 200", status, body) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 || boxes[0].Status != "detached" { |
| + t.Fatalf("registry boxes = %+v, want status detached", boxes) |
| + } |
| + |
| + // The list door reflects it too. |
| + _, listBody := getJSON(t, fleetsURL(fx, fx.sqid), token) |
| + fleets := listBody["fleets"].([]any) |
| + f := fleets[0].(map[string]any) |
| + bx := f["boxes"].([]any)[0].(map[string]any) |
| + if bx["status"] != "detached" { |
| + t.Fatalf("listed box status = %v, want detached", bx["status"]) |
| + } |
| +} |
| + |
| +// TestFleetCrossAccountAccess404: a fleet named under the caller's OWN |
| +// account segment but owned by a DIFFERENT account resolves the exact same |
| +// bytes an outright nonexistent fleet name does — fleet names are one flat |
| +// namespace (fleets.go's package doc), so this is the probe surface that |
| +// matters here, distinct from (and in addition to) apiAccount's ordinary |
| +// unknown/foreign-account 404. |
| +func TestFleetCrossAccountAccess404(t *testing.T) { |
| + fx, ownerToken := fleetFixture(t, "code-fleet-cross-owner") |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, ownerToken); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bobToken := cliToken(t, fx, "bob@keymail.dev", "code-fleet-cross-bob") |
| + |
| + want := canonicalNotFound(t) |
| + cases := []struct { |
| + what, method, url, body string |
| + }{ |
| + {"list boxes via add-box on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes", `{"label":"x"}`}, |
| + {"add-box on a genuinely nonexistent fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/ghost/boxes", `{"label":"x"}`}, |
| + {"rotate on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes/pi-1/rotate", `{}`}, |
| + {"detach on a foreign-owned fleet", http.MethodPost, fleetsURL(fx, "cdef") + "/acme/boxes/pi-1/detach", `{}`}, |
| + } |
| + for _, c := range cases { |
| + status, h, out := rawReq(t, c.method, c.url, bobToken, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| + |
| + // Bob's own account is genuinely untouched: an honest fleet of his own |
| + // still works, proving the refusal above is about OWNERSHIP, not a |
| + // broken door. |
| + if status, body := postJSON(t, fleetsURL(fx, "cdef")+"/new", map[string]string{"name": "widget-fleet", "bucket": "w-data"}, bobToken); status != http.StatusOK { |
| + t.Fatalf("bob's own fleet create = %d %v, want 200", status, body) |
| + } |
| +} |
| + |
| +// TestFleetMissingBearerIs401: every fleet door, and place, answer the same |
| +// 401 with no credential at all. |
| +func TestFleetMissingBearerIs401(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleet-401") |
| + postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "b"}, token) |
| + postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", map[string]string{"label": "pi-1"}, token) |
| + |
| + for _, c := range []struct{ method, url, body string }{ |
| + {http.MethodPost, fleetsURL(fx, fx.sqid) + "/new", `{"name":"x","bucket":"b"}`}, |
| + {http.MethodGet, fleetsURL(fx, fx.sqid), ""}, |
| + {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes", `{"label":"x"}`}, |
| + {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes/pi-1/rotate", `{}`}, |
| + {http.MethodPost, fleetsURL(fx, fx.sqid) + "/acme/boxes/pi-1/detach", `{}`}, |
| + {http.MethodPost, fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/place", `{"target":"fleet/acme"}`}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized { |
| + t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out) |
| + } |
| + } |
| +} |
| + |
| +// placeURL is the place door for one app under the fixture's own account. |
| +func placeURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + "/place" |
| +} |
| + |
| +// TestPlaceStampsRoutesAndInstances is the tripwire test the brief calls out |
| +// by name: placing an app stamps BOTH its routes' Region (walked from the |
| +// registry, SetPlacement per host) AND its already-provisioned instance |
| +// records' Region (provision.SetRecordRegion) — the second half is what |
| +// makes placement real for instances that already exist, not only future |
| +// ones. Clearing (target "") repoints both back to "". |
| +func TestPlaceStampsRoutesAndInstances(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-place-tripwire") |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Seed two routes for the app, owned by the fixture account, BEFORE any |
| + // console fleets/registry call — see openTestReg's doc comment for why |
| + // that ordering matters. |
| + reg := openTestReg(t, fx) |
| + for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // A route for a DIFFERENT app, same account — must be left alone. |
| + if err := reg.Upsert(registry.Route{Host: "other.go.tito.io", Kind: "instance", App: "otherapp", Account: fx.sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The fleet target must exist and be owned by the caller. |
| + if status, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "pi", "bucket": "pi-fleet-data"}, token); status != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", status, body) |
| + } |
| + |
| + // One already-provisioned instance record for the app. |
| + ctx := context.Background() |
| + rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo", Account: fx.sqid, |
| + Region: "ie", Channel: "stable", CreatedAt: now.UTC()} |
| + if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "fleet/pi"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("place = %d %v, want 200", status, body) |
| + } |
| + |
| + for _, host := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil { |
| + t.Fatalf("route %s: %v", host, err) |
| + } |
| + if rt.Region != "fleet/pi" { |
| + t.Errorf("route %s Region = %q, want fleet/pi", host, rt.Region) |
| + } |
| + } |
| + other, err := reg.Get("other.go.tito.io") |
| + if err != nil || other == nil { |
| + t.Fatalf("other-app route: %v", err) |
| + } |
| + if other.Region != "" { |
| + t.Fatalf("a route belonging to a DIFFERENT app was stamped: Region = %q", other.Region) |
| + } |
| + |
| + got, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != "fleet/pi" { |
| + t.Fatalf("instance record Region = %q, want fleet/pi — the tripwire: routes-only stamping is not enough", got.Region) |
| + } |
| + // Nothing else about the record moved. |
| + if got.Channel != "stable" || got.Slug != "acme" { |
| + t.Fatalf("place touched more than Region: %+v", got) |
| + } |
| + |
| + // Clearing repoints both back to "". |
| + if status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": ""}, token); status != http.StatusOK { |
| + t.Fatalf("clear place = %d %v, want 200", status, body) |
| + } |
| + rt, err := reg.Get("acme.go.tito.io") |
| + if err != nil || rt == nil || rt.Region != "" { |
| + t.Fatalf("route after clearing: %+v, %v, want Region \"\"", rt, err) |
| + } |
| + got, err = provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io") |
| + if err != nil || got.Region != "" { |
| + t.Fatalf("instance record after clearing: %+v, %v, want Region \"\"", got, err) |
| + } |
| +} |
| + |
| +// TestPlaceRefusesForeignFleet: placing onto a fleet owned by a DIFFERENT |
| +// account is refused (the same byte-identical 404 apiFleet gives elsewhere), |
| +// and nothing about the app's routes or instances is touched. |
| +func TestPlaceRefusesForeignFleet(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-place-foreign") |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := openTestReg(t, fx) |
| + if err := reg.Upsert(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "titogo", Account: fx.sqid}); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + rec := provision.Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "titogo", Account: fx.sqid, |
| + Channel: "stable", CreatedAt: now.UTC()} |
| + if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // A second account with its OWN fleet, "widgets". |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bobToken := cliToken(t, fx, "bob@keymail.dev", "code-place-foreign-bob") |
| + if status, body := postJSON(t, fleetsURL(fx, "cdef")+"/new", |
| + map[string]string{"name": "acme", "bucket": "bob-data"}, bobToken); status != http.StatusOK { |
| + t.Fatalf("bob's fleet create = %d %v", status, body) |
| + } |
| + |
| + want := canonicalNotFound(t) |
| + status, h, out := rawReq(t, http.MethodPost, placeURL(fx, "titogo"), token, `{"target":"fleet/acme"}`) |
| + assertNotFound(t, "place onto a foreign-owned fleet", want, status, h, out) |
| + |
| + // Nothing moved: not the route, not the record. |
| + rt, err := reg.Get("acme.go.tito.io") |
| + if err != nil || rt == nil || rt.Region != "" { |
| + t.Fatalf("route after a refused place: %+v, %v, want untouched (Region \"\")", rt, err) |
| + } |
| + got, err := provision.GetRecord(ctx, fx.rel, fx.sqid, "titogo", "acme.go.tito.io") |
| + if err != nil || got.Region != "" { |
| + t.Fatalf("instance record after a refused place: %+v, %v, want untouched (Region \"\")", got, err) |
| + } |
| +} |
| + |
| +// TestPlaceInvalidTargetIs400: a target that doesn't parse as "" or |
| +// "fleet/<name>" is a plain 400, not a 404 — it never reaches the |
| +// fleet-ownership question at all. |
| +func TestPlaceInvalidTargetIs400(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-place-badtarget") |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, body := postJSON(t, placeURL(fx, "titogo"), map[string]string{"target": "eu"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("place with a bare region code = %d %v, want 400", status, body) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 8f62e5810..e3859e57c 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -128,9 +128,13 @@ const ( |
| // Config is everything the server reads its world through. |
| type Config struct { |
| - Origin string // the console's public origin — the OAuth client_id |
| - Keymail string // keymail base URL |
| - Registry string // path to the platform registry DB (read-only here) |
| + Origin string // the console's public origin — the OAuth client_id |
| + Keymail string // keymail base URL |
| + // Registry is the path to the platform registry DB. Read-mostly (routes, |
| + // domains, alerts), but fleets.go's fleet/box/placement doors (customer |
| + // fleets plan, Task 7) genuinely write through it via openRegistry |
| + // (view.go) — the same cached handle every reader here already uses. |
| + Registry string |
| Releases release.Store // the deployment bucket |
| ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 2c3cb0838..df7d05767 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -209,6 +209,54 @@ func CreateRecord(ctx context.Context, s release.Store, rec Record) error { |
| return err |
| } |
| +// SetRecordRegion repoints an existing instance record's Region field — the |
| +// one field a record is expected to change AFTER creation. Unlike |
| +// CreateRecord (create-only: a lost race is a permanent 409) this is a |
| +// narrow read-modify-write CAS — etag from Get, Put, one retry on a lost |
| +// race — mirroring PutManifest's own retry shape, because unlike a record's |
| +// identity (Host/App/Account, checked at CREATE and never touched again) |
| +// Region is meant to be repointed in place. |
| +// |
| +// This is placement's (console/fleets.go, customer-fleets plan) write onto |
| +// instances that already exist: "place" walks an app's already-provisioned |
| +// records and calls this for each, so a fleet or platform placement change |
| +// governs the NEXT reconcile pass for every resident instance, not only the |
| +// next one created. Without it, `carlos apps place` would only ever change |
| +// where FUTURE instances land — the app's existing, already-parked ones |
| +// would keep the region they were created under forever, silently exempt |
| +// from the placement a caller was just told had taken effect. |
| +// |
| +// Not-found is returned verbatim (os.ErrNotExist-wrapped): a caller walking |
| +// a key list that has gone stale between listing and this call should see |
| +// the record is gone, not a manufactured success for a write that touched |
| +// nothing. |
| +func SetRecordRegion(ctx context.Context, s release.Store, acct, app, host, region string) error { |
| + key := RecordKey(acct, app, host) |
| + update := func() error { |
| + raw, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return err |
| + } |
| + var rec Record |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err) |
| + } |
| + rec.Region = region |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := update(); errors.Is(err, release.ErrCASConflict) { |
| + return update() // one retry on a lost race; a second loss surfaces |
| + } else if err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| // DeleteRecord retires an instance's route authorization. Object deletion |
| // only — the parked database and blobs under the regional bucket's prefix |
| // are deliberately untouched (design §3.2); Store's Delete is idempotent, |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 30f94c15b..73f4284ba 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -145,6 +145,54 @@ func TestCreateRecordCreateOnly(t *testing.T) { |
| } |
| } |
| +// TestSetRecordRegion: an existing record's Region repoints in place — the |
| +// rest of the record (identity, CreatedAt/CreatedBy, Pool, Channel) is |
| +// untouched — and it can be called again to repoint a second time, unlike |
| +// CreateRecord's create-only contract. A host with no record answers |
| +// os.ErrNotExist verbatim. |
| +func TestSetRecordRegion(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad", |
| + Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), CreatedBy: "alice", |
| + } |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatalf("CreateRecord: %v", err) |
| + } |
| + |
| + if err := SetRecordRegion(ctx, s, "bad", "tito", "acme.go.tito.io", "fleet/pi"); err != nil { |
| + t.Fatalf("SetRecordRegion: %v", err) |
| + } |
| + got, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatalf("GetRecord: %v", err) |
| + } |
| + if got.Region != "fleet/pi" { |
| + t.Fatalf("Region = %q, want fleet/pi", got.Region) |
| + } |
| + if got.Pool != rec.Pool || got.Channel != rec.Channel || got.CreatedBy != rec.CreatedBy || !got.CreatedAt.Equal(rec.CreatedAt) { |
| + t.Fatalf("SetRecordRegion touched more than Region: got %+v, want the rest unchanged from %+v", got, rec) |
| + } |
| + |
| + // A second repoint (e.g. clearing placement back to unconstrained) is not |
| + // a create-only conflict. |
| + if err := SetRecordRegion(ctx, s, "bad", "tito", "acme.go.tito.io", ""); err != nil { |
| + t.Fatalf("second SetRecordRegion: %v", err) |
| + } |
| + got, err = GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Region != "" { |
| + t.Fatalf("Region after clearing = %q, want empty", got.Region) |
| + } |
| + |
| + if err := SetRecordRegion(ctx, s, "bad", "tito", "missing.go.tito.io", "fleet/pi"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("SetRecordRegion on a missing record: err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| func TestManifestRoundTrip(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |