provision+registry: the steering field — additive, 4-place, converge-owned
Adds Record.Steering ("" or "latency", ValidSteering-gated) and its
registry mirror, Route.Steering — the 36th routeCols column, touching
the four hand-maintained lists (ALTER, routeCols, scanRoute, Upsert)
the same way #152's addrs/health columns did.
SetSteering is a narrow single-column UPDATE. routeFor mints the
column straight from the record; shapeMismatch refuses adoption on a
disagreement (steering has no out-of-band writer, so unlike
Channel/Environment it isn't excluded from the adoption gate);
convergeOwned converges it unconditionally through SetSteering, never
a full-row Upsert.
Fingerprint's hand-typed column subset is extended explicitly to hash
steering (a reload signal, not a consumer); SnapshotFingerprint rides
routeCols automatically per its existing contract. Both are pinned by
dedicated tests per the plan's own demand, not assumed.
Single-box inert: nothing reads this column yet. The console door
(Task 3), the desired-state builder and the Route53 converger (Tasks
4-6) are what give a non-empty value any effect, gated further behind
CARLOS_STEERING_ZONE_ID.
Wire-additivity proven by a hand-typed pre-plan JSON fixture (current
shape fields present, no "steering" key) that parses with Steering ==
"" and round-trips without sprouting the key.
6 files changed,
+363
−18
internal/provision/provision.go+37 −0internal/provision/provision_test.go+57 −1internal/provision/reconcile.go+26 −6internal/provision/reconcile_test.go+78 −0internal/registry/registry.go+79 −11internal/registry/registry_test.go+86 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 0081145c1..2108074b1 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -96,6 +96,25 @@ type Record struct { |
| // instanceListRecord.Health) that a deployed CLI parses — that surface |
| // is shipped and keeps the name, this field is new and yields it. |
| Health *Health `json:"probe,omitempty"` |
| + |
| + // --- steering fields (2026-08-23, multi-region ingress design Part 4) -- |
| + |
| + // Steering is this route's DNS steering opt-in: "" (default — today's |
| + // static per-host answer, and what every record written before this |
| + // field existed reads as) or "latency" (a Route53 latency record per |
| + // armed edge, converged by the steering housekeeper duty — Tasks 4-6 of |
| + // the multi-region-steering plan). A STRING, deliberately, not a bool: |
| + // Part 4 names other steering policies as future work, and a string |
| + // leaves room for a new value to land without another wire-shape break. |
| + // ValidSteering is the door. |
| + // |
| + // SINGLE-BOX INERT as of this field's introduction (plan Task 2): |
| + // nothing reads it yet. It reaches the registry row (routeFor, |
| + // convergeOwned) and nothing further — the console door (Task 3) is |
| + // what lets a member set it, and the desired-state builder and Route53 |
| + // converger (Tasks 4-6) are what give a non-empty value any effect, and |
| + // even then only once a deployment arms CARLOS_STEERING_ZONE_ID. |
| + Steering string `json:"steering,omitempty"` |
| } |
| // Health is the per-route probe config (HA spec Part II). Zero value = |
| @@ -323,6 +342,21 @@ func ValidKind(kind string) bool { |
| return false |
| } |
| +// ValidSteering pins the record's DNS-steering vocabulary (multi-region |
| +// ingress design Part 4): "" (no opinion — a route's host resolves exactly |
| +// as it does today) or "latency" (opt into a Route53 latency record per |
| +// armed edge). Case-sensitive and closed, the same discipline ValidKind |
| +// applies to its own vocabulary — "Latency" or "geo" are refused at this |
| +// door rather than discovered as a steering record the Route53 converger |
| +// (Tasks 4-6) silently ignores. |
| +func ValidSteering(s string) bool { |
| + switch s { |
| + case "", "latency": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| // MaxUpstreams caps the Addrs set one record may name. The prober probes a |
| // host's upstreams SEQUENTIALLY once per interval (internal/edge/prober.go), |
| // and every scale figure the multi-upstream design rests on — probe traffic, |
| @@ -341,6 +375,9 @@ func ValidateShape(rec Record) error { |
| if !ValidKind(rec.Kind) { |
| return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind) |
| } |
| + if !ValidSteering(rec.Steering) { |
| + return fmt.Errorf("steering %q: want \"\" or \"latency\"", rec.Steering) |
| + } |
| if rec.Addr != "" { |
| if err := validAddr(rec.Addr); err != nil { |
| return err |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 8a3c6942d..c92266f39 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -71,13 +71,54 @@ func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} { |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering"} { |
| if strings.Contains(string(body), `"`+key+`"`) { |
| t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| } |
| } |
| } |
| +// TestRecordSteeringPrePlanFixtureParses is the plan's own wire-additivity |
| +// demand: a record HAND-TYPED as a binary that predates the steering field |
| +// would have written it — a current production-shaped record (schema-1 plus |
| +// the shape and multi-upstream fields, the vocabulary provision's own test |
| +// fixtures already exercise), verbatim bytes, never round-tripped through |
| +// this build's Record first. It must still parse, with Steering == "" — the |
| +// only value that reproduces what routeFor already mints for every record |
| +// that predates this plan: unsteered, today's static per-host answer. |
| +func TestRecordSteeringPrePlanFixtureParses(t *testing.T) { |
| + const preplan = `{"schema":1,"host":"ha.go.tito.io","slug":"ha","app":"tito",` + |
| + `"account":"bab","pool":"tito","region":"ie","channel":"stable",` + |
| + `"created_at":"2026-08-12T09:00:00Z","kind":"service",` + |
| + `"addrs":["127.0.0.1:7001","127.0.0.1:7002"],` + |
| + `"probe":{"path":"/up","status":200},"environment":"demo",` + |
| + `"status_named":true}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(preplan), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Steering != "" { |
| + t.Errorf("Steering = %q, want empty — a pre-plan record names no opinion", rec.Steering) |
| + } |
| + if !ValidSteering(rec.Steering) { |
| + t.Error("the zero value must itself be valid — every record ever written must pass this door unchanged") |
| + } |
| + // Confirm nothing ELSE about the record's own established shape was |
| + // disturbed by adding the new field. |
| + if rec.Host != "ha.go.tito.io" || rec.Pool != "tito" || len(rec.Addrs) != 2 || rec.Environment != "demo" { |
| + t.Fatalf("pre-plan fixture's own fields mis-parsed: %+v", rec) |
| + } |
| + // Round trip: re-marshaling must not sprout a "steering" key for a |
| + // record that never carried one. |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(body), `"steering"`) { |
| + t.Errorf("re-marshaled pre-plan record carries steering: %s", body) |
| + } |
| +} |
| + |
| func TestValidHost(t *testing.T) { |
| cases := []struct { |
| host, domain string |
| @@ -557,6 +598,19 @@ func TestValidKind(t *testing.T) { |
| } |
| } |
| +func TestValidSteering(t *testing.T) { |
| + for _, s := range []string{"", "latency"} { |
| + if !ValidSteering(s) { |
| + t.Errorf("ValidSteering(%q) = false, want true", s) |
| + } |
| + } |
| + for _, s := range []string{"Latency", "geo", "LATENCY", " latency", "latency "} { |
| + if ValidSteering(s) { |
| + t.Errorf("ValidSteering(%q) = true, want false", s) |
| + } |
| + } |
| +} |
| + |
| func TestValidateShape(t *testing.T) { |
| yes := true |
| base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| @@ -576,6 +630,7 @@ func TestValidateShape(t *testing.T) { |
| {"service with addr", with(func(r *Record) { r.Kind = "service"; r.Addr = "127.0.0.1:8090" })}, |
| {"static", with(func(r *Record) { r.Kind = "static" })}, |
| {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })}, |
| + {"steered instance", with(func(r *Record) { r.Steering = "latency" })}, |
| } |
| for _, c := range ok { |
| if err := ValidateShape(c.rec); err != nil { |
| @@ -592,6 +647,7 @@ func TestValidateShape(t *testing.T) { |
| {"addr with garbage port", with(func(r *Record) { r.Addr = "127.0.0.1:abc" })}, |
| {"unit typo", with(func(r *Record) { r.Unit = "console" })}, |
| {"bad environment name", with(func(r *Record) { r.Environment = "Demo" })}, |
| + {"bad steering value", with(func(r *Record) { r.Steering = "geo" })}, |
| {"service with no addr", with(func(r *Record) { r.Kind = "service" })}, |
| {"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })}, |
| {"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })}, |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 8b532196a..3a47a42dd 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -243,6 +243,7 @@ func routeFor(rec Record, dataDir, seed string) registry.Route { |
| App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| Channel: rec.Channel, Environment: rec.Environment, |
| Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(), |
| + Steering: rec.Steering, |
| Provisioned: true, Seed: seed, Status: "ready", |
| } |
| if rec.DerivesPaths() { |
| @@ -387,6 +388,8 @@ func shapeMismatch(existing, want registry.Route) string { |
| return fmt.Sprintf("addrs %q/%q", existing.Addrs, want.Addrs) |
| case existing.Health != want.Health: |
| return fmt.Sprintf("health %q/%q", existing.Health, want.Health) |
| + case existing.Steering != want.Steering: |
| + return fmt.Sprintf("steering %q/%q", existing.Steering, want.Steering) |
| case existing.Unit != want.Unit: |
| return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit) |
| case existing.Slug != want.Slug: |
| @@ -404,12 +407,13 @@ func shapeMismatch(existing, want registry.Route) string { |
| } |
| } |
| -// convergeOwned applies the record's Channel, Environment, StatusNamed and |
| -// UPSTREAM SET onto a route it owns — the columns the record is authoritative |
| -// for once it owns the row, whether ownership was just granted by adoption or |
| -// was already held by an earlier pass. Both the owned-route converge branch and |
| -// the adoption branch call this, so there is exactly one place these columns get |
| -// written from a record, never two copies drifting apart. |
| +// convergeOwned applies the record's Channel, Environment, StatusNamed, |
| +// UPSTREAM SET and Steering onto a route it owns — the columns the record is |
| +// authoritative for once it owns the row, whether ownership was just granted |
| +// by adoption or was already held by an earlier pass. Both the owned-route |
| +// converge branch and the adoption branch call this, so there is exactly one |
| +// place these columns get written from a record, never two copies drifting |
| +// apart. |
| // |
| // Addrs/Health converge UNCONDITIONALLY on divergence — no "only when the |
| // record states one" guard of the kind Unit carries — and that difference is |
| @@ -422,6 +426,13 @@ func shapeMismatch(existing, want registry.Route) string { |
| // derivation the mint uses, so a route minted from a record cannot be |
| // "converged" onto anything but what it already carries. |
| // |
| +// Steering converges the same way, for the same reason: it has no supported |
| +// out-of-band writer either (Task 3's console door and CLI both write the |
| +// RECORD, never the row directly), so "" is as much an answer as "latency" — |
| +// a record that clears its steering opt-in must reach the row, or the |
| +// console's PATCH door would move the bucket and nothing else, the exact |
| +// silent-stomp shape the upstream-set paragraph above already documents. |
| +// |
| // StatusNamed converges only when the record STATES it (design §3, the same |
| // tri-state Hibernate carries): absent means "this record does not speak to |
| // the status page", and stamping a derived false over an operator's `carlos |
| @@ -487,6 +498,15 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, |
| rec.Host, existing.Addr, addr, existing.Addrs, addrs, existing.Health, health) |
| } |
| } |
| + if existing.Steering != rec.Steering { |
| + if dryRun { |
| + log.Printf("provision: dry-run: would converge %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering) |
| + } else if err := reg.SetSteering(rec.Host, rec.Steering); err != nil { |
| + log.Printf("provision: converge steering on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering) |
| + } |
| + } |
| } |
| // Reconcile materializes bucket instance records into registry routes for |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 039ba4b19..2e0478cd8 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -489,6 +489,56 @@ func TestReconcileConvergesEnvironment(t *testing.T) { |
| } |
| } |
| +// TestReconcileConvergesSteering: a record's steering opt-in changing after |
| +// mint converges onto the owned row within one pass, exactly as Channel and |
| +// Environment do — the console's PATCH door (Task 3) writes the record, and |
| +// this is the pass that has to notice. |
| +func TestReconcileConvergesSteering(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.Steering != "" { |
| + t.Fatalf("minted Steering = %q, want empty", before.Steering) |
| + } |
| + |
| + rec.Steering = "latency" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Steering != "latency" { |
| + t.Errorf("steering = %q, want latency", after.Steering) |
| + } |
| + // The self-review question this test also answers: an edited record |
| + // converges ONLY its steering column, through SetSteering's narrow |
| + // UPDATE — not a full-row Upsert that could disturb anything else. |
| + after.Steering = before.Steering |
| + if after != before { |
| + t.Errorf("converging steering touched another column:\n before %+v\n after %+v", before, after) |
| + } |
| + |
| + // Clearing must reach the row too — same shape as Environment's clear: |
| + // Upsert never runs on the owned branch, so only the narrow setter can |
| + // move this column back to "". |
| + rec.Steering = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Steering; got != "" { |
| + t.Errorf("steering = %q, want it cleared", got) |
| + } |
| +} |
| + |
| // An explicit Hibernate converges; an absent one never does. Absent means |
| // "the record does not speak to this", and a pass that stamped its |
| // derivation over an operator's deliberate SetHibernate would be the silent |
| @@ -2184,3 +2234,31 @@ func TestShapeMismatchComparesAddrsAndHealth(t *testing.T) { |
| t.Fatal("equal shapes must match") |
| } |
| } |
| + |
| +// TestRouteForCarriesSteering: the mint renders the record's steering opt-in |
| +// onto the route, byte for byte — no derivation, unlike Hibernate/StatusNamed. |
| +func TestRouteForCarriesSteering(t *testing.T) { |
| + rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", Steering: "latency"} |
| + if got := routeFor(rec, "/data", "seed").Steering; got != "latency" { |
| + t.Fatalf("Steering = %q, want latency", got) |
| + } |
| + unsteered := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable"} |
| + if got := routeFor(unsteered, "/data", "seed").Steering; got != "" { |
| + t.Fatalf("Steering = %q, want empty for a record that never mentioned it", got) |
| + } |
| +} |
| + |
| +// TestShapeMismatchComparesSteering is the adopt-side proof for Steering, the |
| +// same guard TestShapeMismatchComparesAddrsAndHealth already applies to the |
| +// upstream columns: a hand-added route whose steering disagrees with what |
| +// the record describes must not be silently adopted. |
| +func TestShapeMismatchComparesSteering(t *testing.T) { |
| + a := registry.Route{Kind: "instance", Steering: "latency"} |
| + b := registry.Route{Kind: "instance", Steering: ""} |
| + if shapeMismatch(a, b) == "" { |
| + t.Fatal("differing steering adopted silently") |
| + } |
| + if shapeMismatch(a, registry.Route{Kind: "instance", Steering: "latency"}) != "" { |
| + t.Fatal("equal steering must match") |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 14b7be821..218d89c63 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -204,6 +204,20 @@ type Route struct { |
| // the built-in "ops" account. |
| Account string |
| + // Steering is this route's DNS steering opt-in (multi-region ingress |
| + // design Part 4): "" (default — today's static per-host answer) or |
| + // "latency" (a Route53 latency record per armed edge, converged by the |
| + // steering housekeeper duty). A route's own value, set from |
| + // provision.Record.Steering via routeFor's mint and SetSteering's |
| + // converge — provision.ValidSteering is the rail; this column validates |
| + // nothing, the same discipline every other provision-owned column |
| + // follows (SetCaps's rule: the rails live at the door). |
| + // |
| + // SINGLE-BOX INERT as of this column's introduction: nothing reads it |
| + // yet. See provision.Record.Steering's doc comment for the task-by-task |
| + // rollout that gives it effect. |
| + Steering string |
| + |
| CreatedAt int64 |
| } |
| @@ -362,6 +376,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN addrs TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN health TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN steering TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -557,7 +572,7 @@ func (r *Registry) Accounts() ([]Account, error) { |
| } |
| // routeCols is the column list every route read and the one route write |
| -// share — 35 names, in this exact order. scanRoute's destinations, Upsert's |
| +// share — 36 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 |
| @@ -566,7 +581,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, unit, fleet_box, addrs, health, created_at` |
| +provisioned, seed, unit, fleet_box, addrs, health, steering, created_at` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -576,7 +591,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &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.Unit, &t.FleetBox, |
| - &t.Addrs, &t.Health, &t.CreatedAt) |
| + &t.Addrs, &t.Health, &t.Steering, &t.CreatedAt) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -694,6 +709,14 @@ func (r *Registry) Delete(host string) error { |
| // own) are copied onto the literal before this call, the same |
| // preserve-what-you-don't-manage shape as the CASE columns above, just done |
| // by the caller instead of the ON CONFLICT clause. |
| +// |
| +// `steering` (multi-region ingress design Part 4) joins `addrs`/`health` as a |
| +// PLAIN overwrite, for the identical reason: provision.routeFor now carries |
| +// Record.Steering onto every route it mints, the owned-route branch converges |
| +// it through SetSteering rather than a full-row Upsert, and `carlos add` |
| +// carries no `-steering` flag at all — there is no caller that could reach |
| +// this Upsert with an unrelated Route literal and a stale zero value for a |
| +// column an operator meant to keep. |
| func (r *Registry) Upsert(t Route) error { |
| if t.CreatedAt == 0 { |
| t.CreatedAt = time.Now().Unix() |
| @@ -712,7 +735,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, |
| @@ -732,13 +755,14 @@ ON CONFLICT(host) DO UPDATE SET |
| seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END, |
| unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END, |
| fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END, |
| - addrs=excluded.addrs, health=excluded.health`, |
| + addrs=excluded.addrs, health=excluded.health, steering=excluded.steering`, |
| 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.Unit, t.FleetBox, t.Addrs, t.Health, t.CreatedAt) |
| + b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, |
| + t.Steering, t.CreatedAt) |
| return err |
| } |
| @@ -992,6 +1016,38 @@ func (r *Registry) SetUpstreams(host, addr, addrs, health string) error { |
| return nil |
| } |
| +// SetSteering repoints a route's DNS steering opt-in ("" or "latency") as a |
| +// narrow UPDATE — same rationale as SetUpstreams: a read-modify-write Upsert |
| +// racing a deploy's SetVersions or the activator's SetNextDue would clobber |
| +// other columns, and this is how the provision reconcile pass converges |
| +// steering independently of everything else a full-row Upsert would touch. |
| +// Unlike SetUpstreams this column is single — steering is one fact, not |
| +// three columns describing one — so there is nothing here for a second |
| +// argument to move together with. |
| +// |
| +// Value validation is NOT this door's job: the rail is |
| +// provision.ValidSteering, run before a value ever reaches a record, the |
| +// same discipline SetUpstreams follows for its own columns (the rails live |
| +// at the provision pass's door, per SetCaps's rule). |
| +// |
| +// Errors on an unmatched host, the SetChannel/SetUpstreams contract: a |
| +// reconcile pass that has just read the row must notice it vanishing |
| +// underneath, not silently do nothing. |
| +func (r *Registry) SetSteering(host, steering string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET steering = ? WHERE host = ?`, steering, 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 |
| +} |
| + |
| // SetChannels repoints every host in hosts to channel as ONE transaction — |
| // every row moves or none do. `carlos route -app` uses this for its bulk |
| // form: the caller runs every route's safety-rail check to completion FIRST |
| @@ -1524,6 +1580,18 @@ func (r *Registry) DeleteWildcardDomain(host string) error { |
| // "box-a"/"box-b"), which is precisely the blind spot hashing the values |
| // closes. |
| // |
| +// `steering` is hashed too (multi-region ingress design Part 4, plan Task 2): |
| +// it is a route's own member-facing routing opt-in, set through the same |
| +// console PATCH door channel/environment go through, and it must move the |
| +// digest for the identical reason those two do — a value that reaches the |
| +// bucket and the registry row but never trips the edge's own reload poll |
| +// stays invisible until an unrelated column happens to change too, exactly |
| +// the failure class db_path (#69) and addrs/health (#152) above were added |
| +// to close. Task 2 wires no edge consumer of this column yet — the field is |
| +// SINGLE-BOX INERT until Tasks 3-6 — but the reload signal is cheap to carry |
| +// now rather than teach Fingerprint about a column after something depends |
| +// on it moving. |
| +// |
| // The fleets and fleet_boxes tables are hashed into the SAME digest, each |
| // under its own section marker so a value can never migrate across sections |
| // and cancel a change out. That is what lets the edge's Watch poll |
| @@ -1561,7 +1629,7 @@ func (r *Registry) Fingerprint() (string, error) { |
| } |
| rows, err := db.Query(` |
| SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel, |
| - db_path, region, fleet_box, addrs, health |
| + db_path, region, fleet_box, addrs, health, steering |
| FROM routes ORDER BY rowid`) |
| if err != nil { |
| return "", err |
| @@ -1571,14 +1639,14 @@ FROM routes ORDER BY rowid`) |
| n := 0 |
| for rows.Next() { |
| var rowid, hibernate int64 |
| - var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health string |
| + var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, steering string |
| if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, |
| - &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health); err != nil { |
| + &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health, &steering); err != nil { |
| return "", err |
| } |
| - fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", |
| + fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", |
| rowid, status, socket, addr, version, hibernate, channel, backing, tunnel, |
| - dbPath, region, fleetBox, addrs, health) |
| + dbPath, region, fleetBox, addrs, health, steering) |
| n++ |
| } |
| if err := rows.Err(); err != nil { |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index fdef66c1a..dbf68f355 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -76,6 +76,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| Environment: "demo", Caps: "host-role,port:25", |
| Hibernate: true, Canary: true, Replication: "batched", |
| NextDue: 4200, Backing: BackingUnit, Unit: "console.service", |
| + Steering: "latency", |
| CreatedAt: 1700000000, |
| } |
| if err := reg.Upsert(want); err != nil { |
| @@ -205,6 +206,91 @@ func TestSetUpstreamsErrorsOnUnmatchedHost(t *testing.T) { |
| } |
| } |
| +// TestSetSteeringRoundTrips: the setter writes exactly the value given and |
| +// nothing else — a single-column UPDATE, unlike SetUpstreams' three. |
| +func TestSetSteeringRoundTrips(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetSteering("m.example.com", "latency"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("m.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Steering != "latency" { |
| + t.Fatalf("Steering = %q, want latency", got.Steering) |
| + } |
| + // And clearing: "" is a legitimate write, the column's only "off" value. |
| + if err := reg.SetSteering("m.example.com", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err = reg.Get("m.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Steering != "" { |
| + t.Fatalf("Steering = %q after clearing, want empty", got.Steering) |
| + } |
| +} |
| + |
| +func TestSetSteeringErrorsOnUnmatchedHost(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.SetSteering("nope.example.com", "latency"); err == nil { |
| + t.Fatal("SetSteering on an unmatched host: want error, got nil") |
| + } |
| +} |
| + |
| +// TestFingerprintSeesSteeringChange and TestSnapshotFingerprintSeesSteeringChange |
| +// are the plan's own demand: "Fingerprint AND SnapshotFingerprint change when |
| +// steering changes (pin, don't assume routeCols coverage)". SnapshotFingerprint |
| +// hashes routeCols generically (its own doc comment: "a column added to |
| +// routeCols is covered here automatically"), but Fingerprint's SELECT is a |
| +// hand-typed subset — nothing enforces that steering ever lands in it besides |
| +// this test. |
| +func TestFingerprintSeesSteeringChange(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp1, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetSteering("m.example.com", "latency"); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp2, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp1 == fp2 { |
| + t.Fatal("steering change invisible to Fingerprint — the edge would never notice a steering opt-in move") |
| + } |
| +} |
| + |
| +func TestSnapshotFingerprintSeesSteeringChange(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp1, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetSteering("m.example.com", "latency"); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp2, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp1 == fp2 { |
| + t.Fatal("steering change invisible to SnapshotFingerprint — a cold-standby restore would silently drop it") |
| + } |
| +} |
| + |
| func TestGetMissingIsNilNil(t *testing.T) { |
| reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| if err != nil { |