carlos / platform Public

registry: fleet placement helpers + fleet_box route binding

Paul Campbell 703eb0921bd303f0afda08e2efee27df6897d31b parent ee3c397
4 files changed, +345 −17
  • internal/registry/fleets.go +28 −0
  • internal/registry/fleets_test.go +37 −0
  • internal/registry/registry.go +85 −17
  • internal/registry/registry_test.go +195 −0
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go
index 56d201f08..9d2de77b6 100644
--- a/internal/registry/fleets.go
+++ b/internal/registry/fleets.go
@@ -11,6 +11,7 @@ import (
"database/sql"
"fmt"
"regexp"
+ "strings"
"time"
)
@@ -319,6 +320,33 @@ func (r *Registry) ListFleetBoxes(fleet string) ([]FleetBox, error) {
return out, rows.Err()
}
+// fleetPlacementPrefix is the Region-column prefix that marks a route as
+// bound to a customer fleet rather than a residency code — see
+// FleetPlacement/PlacementTarget below.
+const fleetPlacementPrefix = "fleet/"
+
+// FleetPlacement parses a Route.Region value as a fleet placement target:
+// "fleet/pi" -> ("pi", true). Anything else answers ("", false) — "" (no
+// placement), a bare region code like "eu" (a FUTURE placement form this v1
+// does not parse — SetPlacement refuses it too), and "fleet/<name>" where
+// name fails ValidTunnelName (a syntactically-right prefix is not enough;
+// the name half must be one Registry would itself accept as a fleet name).
+func FleetPlacement(region string) (fleet string, ok bool) {
+ name, hasPrefix := strings.CutPrefix(region, fleetPlacementPrefix)
+ if !hasPrefix || !ValidTunnelName(name) {
+ return "", false
+ }
+ return name, true
+}
+
+// PlacementTarget renders a fleet name as the Region-column value that binds
+// a route to it — the inverse of FleetPlacement. It does not validate name;
+// callers that need "is this fleet name legal" should ask ValidTunnelName
+// (or round-trip through FleetPlacement) themselves.
+func PlacementTarget(fleet string) string {
+ return fleetPlacementPrefix + fleet
+}
+
// FleetChannelAuth returns every ACTIVE box's token hash, keyed by its
// channel name "<fleet>/<label>" — the edge's auth table for an incoming
// remote-host-agent dial-in. Fresh connection: this is exactly the
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go
index 9009b2cab..3946abb9a 100644
--- a/internal/registry/fleets_test.go
+++ b/internal/registry/fleets_test.go
@@ -310,3 +310,40 @@ func TestFingerprintChangesOnTokenRotation(t *testing.T) {
t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after)
}
}
+
+// TestFleetPlacement pins the "fleet/<name>" grammar Route.Region carries
+// when a route is bound to a customer fleet: the prefix alone is not enough,
+// the name half must also pass ValidTunnelName, and a bare region code (a
+// FUTURE placement form this v1 does not parse) must answer false rather
+// than partially matching.
+func TestFleetPlacement(t *testing.T) {
+ cases := []struct {
+ region string
+ wantFleet string
+ wantOK bool
+ }{
+ {"fleet/pi", "pi", true},
+ {"", "", false},
+ {"eu", "", false},
+ {"fleet/UPPER", "", false},
+ {"fleet/", "", false},
+ {"fleet", "", false},
+ }
+ for _, c := range cases {
+ fleet, ok := FleetPlacement(c.region)
+ if fleet != c.wantFleet || ok != c.wantOK {
+ t.Errorf("FleetPlacement(%q) = (%q, %v), want (%q, %v)", c.region, fleet, ok, c.wantFleet, c.wantOK)
+ }
+ }
+}
+
+// TestPlacementTarget pins the inverse rendering FleetPlacement parses back.
+func TestPlacementTarget(t *testing.T) {
+ if got := PlacementTarget("pi"); got != "fleet/pi" {
+ t.Errorf("PlacementTarget(%q) = %q, want %q", "pi", got, "fleet/pi")
+ }
+ fleet, ok := FleetPlacement(PlacementTarget("pi-box"))
+ if !ok || fleet != "pi-box" {
+ t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box")
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 6e8f3a4f0..c7095cc10 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -42,9 +42,12 @@ type Route struct {
Version string
PrevVersion string
- App string // which app's releases this route runs
- Pool string // placement pool ("" = shared)
- Region string // residency region code ("" = unconstrained)
+ App string // which app's releases this route runs
+ Pool string // placement pool ("" = shared)
+ Region string // residency region code ("" = unconstrained); ALSO doubles
+ // as a route's fleet placement target ("fleet/<name>" — see
+ // FleetPlacement/PlacementTarget, fleets.go) written by SetPlacement, not
+ // this comment's original meaning alone.
Channel string // release channel this route follows ("" = stable)
// Environment is the named config bundle this route is wired to ("" =
// the app's default bundle, config.json, alone — see
@@ -74,6 +77,13 @@ type Route struct {
// today). Written only by SetTunnel / carlos route -tunnel; Upsert
// preserves non-empty values — see Environment's comment for why.
Tunnel string
+ // FleetBox is the label of the fleet box CURRENTLY bound to serve this
+ // route ("" = no binding) — distinct from Region, which carries the
+ // placement TARGET (see FleetPlacement/PlacementTarget, fleets.go); a
+ // route can want "fleet/pi" and not yet have a box assigned. Written only
+ // by SetFleetBox; Upsert preserves non-empty values — see Environment's
+ // comment for why.
+ FleetBox string
// Caps is the OPERATOR-granted capability tokens for this route ON THIS
// BOX: comma-separated and canonically sorted, so "port:25,host-role"
@@ -256,6 +266,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN tunnel TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN provisioned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN seed TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -422,7 +433,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 29 names, in this exact order. scanRoute's destinations, Upsert's
+// share — 30 names, in this exact order. scanRoute's destinations, Upsert's
// placeholder row and Upsert's argument list all count from here: adding a
// column means adding it in FOUR places, and a miscount is a silent column
// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
@@ -431,7 +442,7 @@ const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
-provisioned, seed, created_at`
+provisioned, seed, fleet_box, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -440,7 +451,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool,
&t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
&sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
- &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.CreatedAt)
+ &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.FleetBox, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -501,12 +512,12 @@ func (r *Registry) Delete(host string) error {
return err
}
-// Upsert inserts or updates a route by host. Five columns are deliberately
-// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned` and
-// `seed` all keep the existing row's value when the incoming Route carries
-// the zero one.
+// Upsert inserts or updates a route by host. Six columns are deliberately
+// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned`,
+// `seed` and `fleet_box` all keep the existing row's value when the incoming
+// Route carries the zero one.
//
-// The reason is the same for all five, and it is structural: a full-row
+// The reason is the same for all six, and it is structural: a full-row
// Upsert caller (`carlos add`, the domains reconciler's two mint paths, the
// provision pass's mint) builds its Route from what IT knows, and none of
// them knows about the columns another feature owns. `environment` was the
@@ -520,13 +531,16 @@ func (r *Registry) Delete(host string) error {
// the stakes again: `carlos add` on a provisioned host would write
// provisioned=0, and the reconcile pass would then lose removal authority
// over that route FOREVER — deleting the bucket record could no longer
-// retire it, and only a human noticing would.
+// retire it, and only a human noticing would. `fleet_box` is the same shape
+// as `tunnel`: a placement pass stamps which box is CURRENTLY serving a
+// route, and a full-row Upsert from provisioning (which does not know about
+// fleet placement at all) must not un-stamp it mid-lifecycle.
//
// An explicit non-zero value still wins in every case; clearing keeps
// exactly one door per column, none of which goes through Upsert:
// SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route
-// -revoke`), SetTunnel (`carlos route -tunnel -`), SetSeed, and for
-// `provisioned` the row's own deletion.
+// -revoke`), SetTunnel (`carlos route -tunnel -`), SetSeed, SetFleetBox, and
+// for `provisioned` the row's own deletion.
func (r *Registry) Upsert(t Route) error {
if t.CreatedAt == 0 {
t.CreatedAt = time.Now().Unix()
@@ -545,7 +559,7 @@ func (r *Registry) Upsert(t Route) error {
}
_, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
@@ -562,13 +576,14 @@ ON CONFLICT(host) DO UPDATE SET
caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END,
tunnel=CASE WHEN excluded.tunnel = '' THEN routes.tunnel ELSE excluded.tunnel END,
provisioned=CASE WHEN excluded.provisioned = 0 THEN routes.provisioned ELSE excluded.provisioned END,
- seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END`,
+ seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END,
+ fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel,
- b2i(t.Provisioned), t.Seed, t.CreatedAt)
+ b2i(t.Provisioned), t.Seed, t.FleetBox, t.CreatedAt)
return err
}
@@ -820,6 +835,59 @@ func (r *Registry) SetTunnel(host, tunnel string) error {
return nil
}
+// SetFleetBox stamps the label of the fleet box CURRENTLY serving this
+// route as a narrow UPDATE — same rationale and same "error on an unmatched
+// host" contract as SetTunnel. label == "" clears the binding; this is a
+// placement pass's door, distinct from SetPlacement (which writes the
+// placement TARGET, not the box currently fulfilling it).
+func (r *Registry) SetFleetBox(host, label string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET fleet_box = ? WHERE host = ?`, label, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
+// SetPlacement repoints a route's placement TARGET — stored in the Region
+// column, which already carries a route's residency constraint and, as of
+// this task, doubles as where a fleet-bound route wants to run (see
+// FleetPlacement/PlacementTarget, fleets.go, for the grammar that
+// distinguishes the two). target == "" clears the binding. Any other value
+// must parse as "fleet/<name>" via FleetPlacement — a bare region code like
+// "eu" is a FUTURE placement form this v1 does not accept here (readers use
+// FleetPlacement to tell the two apart; it simply answers ("", false) for a
+// bare code, same as for anything else this setter refuses).
+//
+// Same "error on an unmatched host" contract as SetTunnel/SetFleetBox: a
+// human or a placement pass typing a hostname here should get a refusal on a
+// typo, not a silent no-op.
+func (r *Registry) SetPlacement(host, target string) error {
+ if target != "" {
+ if _, ok := FleetPlacement(target); !ok {
+ return fmt.Errorf("invalid placement target %q (want \"\" or \"fleet/<name>\")", target)
+ }
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET region = ? WHERE host = ?`, target, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetEnvironments is SetEnvironment's bulk sibling, mirroring SetChannels:
// one transaction across every host in hosts, so `carlos route -app
// -environment` can never leave an app split across two environments the
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index b7f120211..f3793bb27 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1347,3 +1347,198 @@ func TestValidTunnelNameLengthBoundary(t *testing.T) {
t.Errorf("ValidTunnelName(33 chars) = true, want false")
}
}
+
+// TestSetFleetBoxRoundTrips mirrors TestSetTunnel's shape: SetFleetBox is a
+// narrow UPDATE on the new fleet_box column, which records which fleet box
+// is CURRENTLY serving a placed route (distinct from Region/placement
+// target, which says where the route WANTS to run).
+func TestSetFleetBoxRoundTrips(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.FleetBox != "pi-1" {
+ t.Fatalf("FleetBox = %q, want pi-1", got.FleetBox)
+ }
+ if err := reg.SetFleetBox("demo.example", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.FleetBox != "" {
+ t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox)
+ }
+}
+
+// Same contract as TestSetTunnelUnknownHostErrors: a human/reconciler typo'd
+// host must get a refusal, not a silent no-op.
+func TestSetFleetBoxUnknownHostErrors(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetFleetBox("nope.example.com", "pi-1"); err == nil {
+ t.Fatal("SetFleetBox on an unknown host: got nil error, want one")
+ }
+}
+
+// Same rationale as TestUpsertPreservesTunnel: a full-row Upsert caller that
+// does not know about fleet placement must not silently unbind a box another
+// pass stamped.
+func TestUpsertPreservesFleetBox(t *testing.T) {
+ reg := openTestRegistry(t)
+ base := Route{Host: "demo.example", Kind: "instance", Status: "ready"}
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil {
+ t.Fatal(err)
+ }
+ // A full-row re-Upsert with zero-value FleetBox must not unbind.
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.FleetBox != "pi-1" {
+ t.Fatalf("Upsert clobbered FleetBox: %q", got.FleetBox)
+ }
+ // An explicit non-empty value still wins.
+ base.FleetBox = "pi-2"
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.FleetBox != "pi-2" {
+ t.Fatalf("FleetBox = %q, want an explicit Upsert value to win", got.FleetBox)
+ }
+ // Clearing has exactly one door: SetFleetBox(host, "").
+ if err := reg.SetFleetBox("demo.example", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.FleetBox != "" {
+ t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox)
+ }
+}
+
+// A registry file written BEFORE the fleet_box column existed must still
+// open, and its rows must read as "no box bound" — the same additive-
+// migration contract as TestProvisionedSeedMigratesFromPreChangeDB.
+func TestFleetBoxMigratesFromPreChangeDB(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+CREATE TABLE routes (
+ host TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ socket TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'ready',
+ last_error TEXT NOT NULL DEFAULT '',
+ db_path TEXT NOT NULL DEFAULT '',
+ version TEXT NOT NULL DEFAULT '',
+ prev_version TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO routes (host, kind, created_at) VALUES (?, ?, ?)`,
+ "old.example.com", "instance", 1234); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ r, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open over a pre-change DB: %v", err)
+ }
+ defer r.Close()
+ got, err := r.Get("old.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatal("pre-existing row vanished on migrate")
+ }
+ if got.FleetBox != "" {
+ t.Fatalf("pre-existing row should default fleet_box=\"\": %+v", got)
+ }
+}
+
+// TestSetPlacementRoundTrips pins SetPlacement's grammar: "" clears,
+// "fleet/<name>" sets Region to the fleet placement target, and anything
+// else — a bare region code, an invalid fleet name — is refused rather than
+// silently written. Bare region codes are a FUTURE placement form; v1 only
+// accepts "" or "fleet/<name>" here (FleetPlacement, fleets.go, is what
+// distinguishes the two forms on read).
+func TestSetPlacementRoundTrips(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetPlacement("demo.example", "fleet/pi"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Region != "fleet/pi" {
+ t.Fatalf("Region = %q, want fleet/pi", got.Region)
+ }
+ if err := reg.SetPlacement("demo.example", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Region != "" {
+ t.Fatalf("SetPlacement(\"\") did not clear Region: %q", got.Region)
+ }
+}
+
+func TestSetPlacementRejectsBadGrammar(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"bogus", "eu", "fleet/UPPER", "fleet/", "fleet"} {
+ if err := reg.SetPlacement("demo.example", bad); err == nil {
+ t.Errorf("SetPlacement(%q): got nil error, want one", bad)
+ }
+ }
+ got, err := reg.Get("demo.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Region != "" {
+ t.Fatalf("a rejected SetPlacement wrote Region anyway: %q", got.Region)
+ }
+}
+
+func TestSetPlacementUnknownHostErrors(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetPlacement("nope.example.com", "fleet/pi"); err == nil {
+ t.Fatal("SetPlacement on an unknown host: got nil error, want one")
+ }
+}