carlos / platform Public

console: fleets API — create, boxes, rotate, detach, place

Paul Campbell e61d4fbf75fdcb65f8b09e51ee99ce1a087ebc68 parent 88848d1
6 files changed, +1015 −3
  • internal/console/authcli.go +13 −0
  • internal/console/fleets.go +469 −0
  • internal/console/fleets_test.go +430 −0
  • internal/console/server.go +7 −3
  • internal/provision/provision.go +48 −0
  • internal/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())