carlos / platform Public

console+cli: steering opt-in — member-settable, pool-railed

Task 3 of the multi-region-steering plan: handleAPIInstanceUpdate's PATCH
grows a third independently-optional field, steering ("" or "latency"),
alongside addrs/probe — same CAS-guarded write-back, same
byte-identical-404 gate ahead of it. Two rails of its own: an invalid
value is ValidateShape's own 400 (ValidSteering was already one of its
checks, so no new code was needed for that half), and turning latency ON
for a record whose Pool != "" is a 409 naming the v1 constraint verbatim
("steering requires the shared pool — pool-scoped steering is a recorded
future"). The rail reads the FETCHED RECORD's Pool, not the app's
manifest, which may have moved to a different pool since create.

CLI: a new top-level `carlos steering -app X -host H latency|off` verb
(instances_cli.go, apimode.go's instanceSetSteering client), following
set-upstreams' shape but with its own requireSteeringAPISession — it is
not an "instances" sub-verb, so the family's hardcoded "carlos instances
%s" refusal text does not apply.

Single-box inert: nothing acts on this field yet. It reaches the record
and the registry row and stops there until a deployment arms the Route53
steering converger (Tasks 4-6, CARLOS_STEERING_ZONE_ID).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 02222c516029a3c6397c302407f00f9eb2d1aeac parent 15377fb
7 files changed, +548 −19
  • apimode.go +29 −0
  • apimode_test.go +69 −0
  • instances_cli.go +104 −0
  • instances_e2e_test.go +43 −0
  • internal/console/apiinstances.go +73 −19
  • internal/console/apiinstances_test.go +226 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index fdec85bdb..318805753 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1734,6 +1734,11 @@ type apiInstanceRecord struct {
// list-only best-effort read of the fleet's own snapshots, not something
// either write path computes.
Upstreams []apiUpstreamState `json:"upstreams,omitempty"`
+ // Steering is the record's DNS steering opt-in (multi-region-steering
+ // plan, Task 3): "" (today's static per-host answer) or "latency". Empty
+ // on a pre-steering console's response too — the field predates it, not
+ // a proof steering is off.
+ Steering string `json:"steering"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
@@ -1827,6 +1832,30 @@ func (s *apiSession) instanceSetUpstreams(ctx context.Context, a apiAcct, app, h
return out, nil
}
+// instanceSetSteering asks the console to repoint one instance's DNS
+// steering opt-in — `carlos steering`, the CLI front over
+// handleAPIInstanceUpdate's PATCH (Task 3, multi-region-steering plan).
+// steering is always sent (never omitted): unlike addrs/probe there is no
+// "leave it alone" spelling this verb needs — every invocation states the
+// value the caller wants, "latency" or "" (off).
+func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, host, steering string) (apiInstanceRecord, error) {
+ in := struct {
+ Steering string `json:"steering"`
+ }{steering}
+ status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status != http.StatusOK {
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("steering: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// instanceList reads the app's instance records through the console, honest
// about the server's 500-record cap. match and health are the server-side
// filters Task 9 added (q and health query params) — empty strings send
diff --git a/apimode_test.go b/apimode_test.go
index e0bfd941a..40edd2dad 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -838,6 +838,75 @@ func TestInstanceSetUpstreamsSurfacesRefusalVerbatim(t *testing.T) {
}
}
+// TestInstanceSetSteeringPatches pins instanceSetSteering's whole contract
+// (Task 3, multi-region-steering plan): the HTTP method, the path (the same
+// /instances/<host> door set-upstreams uses), and — unlike addrs/probe —
+// that steering is ALWAYS sent on the wire, "" included, since this verb has
+// no "leave it alone" spelling: every invocation states the value the
+// caller wants.
+func TestInstanceSetSteeringPatches(t *testing.T) {
+ var gotMethod, gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod, gotPath = r.Method, r.URL.Path
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"host": "a.example.com", "steering": "latency"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ rec, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency")
+ if err != nil {
+ t.Fatalf("instanceSetSteering: %v", err)
+ }
+ if rec.Host != "a.example.com" || rec.Steering != "latency" {
+ t.Fatalf("rec = %+v", rec)
+ }
+ if gotMethod != http.MethodPatch {
+ t.Fatalf("method = %s, want PATCH", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/bah/app/instances/a.example.com" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if gotBody != `{"steering":"latency"}` {
+ t.Fatalf("request body = %q, want a bare steering key", gotBody)
+ }
+
+ // Turning it off: the wire must still carry the key, `"steering":""`, not
+ // omit it — an omitted key means "leave it alone" to the door, which
+ // would silently do nothing for `carlos steering off`.
+ gotBody = ""
+ if _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", ""); err != nil {
+ t.Fatalf("instanceSetSteering (off): %v", err)
+ }
+ if gotBody != `{"steering":""}` {
+ t.Fatalf("request body = %q, want an explicit empty steering key", gotBody)
+ }
+}
+
+// TestInstanceSetSteeringSurfacesRefusalVerbatim pins the same self-review
+// rule set-upstreams carries: the door's own refusal — the pool 409 or
+// ValidateShape's 400 — reaches the caller byte-for-byte, since it names
+// the only thing an operator can act on and paraphrasing it here would
+// throw that away.
+func TestInstanceSetSteeringSurfacesRefusalVerbatim(t *testing.T) {
+ const wantMsg = "steering requires the shared pool — pool-scoped steering is a recorded future"
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]any{"error": wantMsg})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.instanceSetSteering(context.Background(), apiAcct{Seg: "bah"}, "app", "a.example.com", "latency")
+ if err == nil {
+ t.Fatal("instanceSetSteering: want an error, got nil")
+ }
+ if err.Error() != wantMsg {
+ t.Fatalf("error = %q, want the door's own message %q verbatim", err.Error(), wantMsg)
+ }
+}
+
// ---- Task 5: the four verbs wired to resolveMode --------------------------
// loginTo saves one live credential for origin/address and returns it, so a
diff --git a/instances_cli.go b/instances_cli.go
index b7a1f63f1..7882c100f 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -478,3 +478,107 @@ func runInstancesSetUpstreams(args []string) {
fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("updated upstreams for"), rec.Host)
mutedf(stdout, " the change lands on its box within seconds of the next converge tick")
}
+
+// requireSteeringAPISession resolves signed-in mode for `carlos steering`,
+// refusing direct-bucket mode with THIS verb's own name in the message — the
+// requireRestartAPISession precedent (restart.go), not requireAPISession's:
+// steering is a top-level verb ("carlos steering", not "carlos instances
+// steering"), so requireAPISession's hardcoded "carlos instances %s" would
+// name a command that does not exist.
+func requireSteeringAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos steering works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Steering is a console-authorized\n" +
+ "write, the same family instance records belong to; unset the store env and run:\n" +
+ "carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+const steeringUsage = `usage: carlos steering -app <a> -host <h> latency|off
+
+Sets an instance's DNS steering opt-in — a top-level verb, not an
+"instances" sub-verb, because unlike set-upstreams this is not repointing a
+route's upstreams: it decides whether the host's DNS answer varies by
+requester at all.
+
+ latency opt into a Route53 latency record per armed edge — the nearest
+ edge answers, instead of today's single static answer
+ off clear the opt-in — back to today's static per-host answer
+
+flags:
+ -app <a> app the instance belongs to
+ -host <h> instance host to steer
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+v1 constraint: steering requires the shared pool (-pool "" on the app's
+instances manifest). A pool-scoped route is refused with a 409 naming this —
+pool-scoped steering is a recorded future, not v1.
+
+Signed-in only (carlos auth login): steering is a console-authorized write,
+the same family create/list/delete/set-upstreams belong to.
+
+SINGLE-BOX INERT: setting this field changes nothing on its own. It reaches
+the record and the registry row and stops there until a deployment arms the
+Route53 steering converger (CARLOS_STEERING_ZONE_ID) — this verb states the
+opt-in, it does not turn steering ON for the fleet.
+`
+
+// runSteering is `carlos steering` — the CLI front over
+// handleAPIInstanceUpdate's PATCH steering field (Task 3,
+// multi-region-steering plan). It follows set-upstreams' shape (signed-in
+// only, the same -app/-host/-account/-console flags) but resolves its
+// session through requireSteeringAPISession rather than requireAPISession,
+// since it is a top-level verb, not an "instances" sub-verb — see that
+// function's own comment. The one other difference: the value is a bare
+// trailing word, "latency" or "off", rather than a flag — this door has no
+// "leave it alone" spelling to protect with a flag/no-flag distinction, so a
+// positional argument reads plainer at the terminal than an always-required
+// -steering flag would.
+//
+// The refusal text — the pool 409 or ValidateShape's 400 — is surfaced
+// verbatim, exactly as set-upstreams' refusals are: it names the only thing
+// an operator can act on (the constraint, or the bad value), and paraphrasing
+// it here would throw that away.
+func runSteering(args []string) {
+ fs := flag.NewFlagSet("steering", flag.ExitOnError)
+ app := fs.String("app", "", "app the instance belongs to")
+ host := fs.String("host", "", "instance host to steer")
+ consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *host == "" || fs.NArg() != 1 {
+ log.Fatal(steeringUsage)
+ }
+ var steering string
+ switch fs.Arg(0) {
+ case "latency":
+ steering = "latency"
+ case "off":
+ steering = ""
+ default:
+ log.Fatal(steeringUsage)
+ }
+ sess := requireSteeringAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, steeringUsage)
+ rec, err := sess.instanceSetSteering(context.Background(), acct, app2, strings.TrimSpace(*host), steering)
+ if err != nil {
+ log.Fatalf("steering: %v", err)
+ }
+ if steering == "" {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering turned off for"), rec.Host)
+ } else {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("steering set to latency for"), rec.Host)
+ }
+ mutedf(stdout, " the record and its registry row update within seconds of the next converge tick;")
+ mutedf(stdout, " the fleet only ACTS on this once a deployment arms its Route53 steering converger")
+}
diff --git a/instances_e2e_test.go b/instances_e2e_test.go
index 57c13f0f5..12968ff74 100644
--- a/instances_e2e_test.go
+++ b/instances_e2e_test.go
@@ -195,6 +195,49 @@ func TestE2EInstancesThroughConsole(t *testing.T) {
// TestInstanceSetUpstreamsSurfacesRefusalVerbatim (apimode_test.go)
// proves instanceSetUpstreams returns the door's error text unmodified.
+ // --- steering (Task 3): a top-level verb, not an "instances" sub-verb --
+ // haHost's record lives under this manifest's shared pool (Pool == ""
+ // per the assertion at line 103-105 above), so it may opt into latency
+ // steering and back off.
+ buf.Reset()
+ runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "latency"})
+ if want := "steering set to latency for " + haHost; !strings.Contains(buf.String(), want) {
+ t.Fatalf("steering stdout = %q, want %q", buf.String(), want)
+ }
+ haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
+ if err != nil {
+ t.Fatalf("record after steering latency: %v", err)
+ }
+ if haRec.Steering != "latency" {
+ t.Fatalf("record.Steering after steering latency = %q, want latency", haRec.Steering)
+ }
+ // Everything set-upstreams already wrote onto this record is undisturbed
+ // by a steering-only patch.
+ if len(haRec.Addrs) != 1 || haRec.Addrs[0] != "10.0.0.3:8090" {
+ t.Fatalf("steering patch moved addrs: %v", haRec.Addrs)
+ }
+
+ buf.Reset()
+ runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "off"})
+ if want := "steering turned off for " + haHost; !strings.Contains(buf.String(), want) {
+ t.Fatalf("steering off stdout = %q, want %q", buf.String(), want)
+ }
+ haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
+ if err != nil {
+ t.Fatalf("record after steering off: %v", err)
+ }
+ if haRec.Steering != "" {
+ t.Fatalf("record.Steering after steering off = %q, want empty", haRec.Steering)
+ }
+
+ // The pool-conflict 409 (a record whose Pool != "") is a log.Fatal path
+ // through this verb, same as set-upstreams' own refusal above — no argv
+ // test harness exists here for os.Exit paths. It is proven at the console
+ // door (TestAPIInstanceUpdateSteeringPoolConflict, internal/console) and
+ // at this client's own layer (TestInstanceSetSteeringSurfacesRefusalVerbatim,
+ // apimode_test.go), which is where the request/response round trip is
+ // actually observable.
+
// The whole flow ran through the console: nothing here may have touched
// a local registry.
checkNoRegistry()
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 76d078ae9..637b9e586 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -632,24 +632,41 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, rec)
}
-// handleAPIInstanceUpdate repoints a live instance's UPSTREAM SET and probe
-// config — the two fields of a record that are meant to change while the
-// instance keeps serving (HA design, issue #152). Everything else about a
-// record stays where it is: identity (host/app/account) is checked at create
-// and never touched again, policy (pool, domain) is the manifest's, and
-// region has its own writer in placement.
+// handleAPIInstanceUpdate repoints a live instance's UPSTREAM SET, probe
+// config and (Task 3, multi-region-steering plan) DNS STEERING opt-in — the
+// fields of a record that are meant to change while the instance keeps
+// serving (HA design, issue #152; steering, 2026-08-20 multi-region ingress
+// design Part 4). Everything else about a record stays where it is: identity
+// (host/app/account) is checked at create and never touched again, policy
+// (pool, domain) is the manifest's, and region has its own writer in
+// placement.
//
-// It is a PATCH, and the body's two fields are independently optional, because
+// It is a PATCH, and the body's fields are independently optional, because
// "leave the probe alone while I add an upstream" is the ordinary request. A
-// body carrying NEITHER is refused rather than treated as a no-op write: it
-// would cost a bucket round trip and a fleet-wide serial bump to change
+// body carrying NONE of them is refused rather than treated as a no-op write:
+// it would cost a bucket round trip and a fleet-wide serial bump to change
// nothing.
//
// The write is guarded by the etag the record was READ under
-// (provision.UpdateRecord): the caller decided this upstream set given the
-// record they were shown, so a concurrent writer means the decision was made
-// about bytes that no longer exist, and the honest answer is 409 — the same
-// answer the enable door gives for the same reason.
+// (provision.UpdateRecord): the caller decided this upstream set (or this
+// steering opt-in) given the record they were shown, so a concurrent writer
+// means the decision was made about bytes that no longer exist, and the
+// honest answer is 409 — the same answer the enable door gives for the same
+// reason.
+//
+// Steering carries its own two rails, beyond the CAS guard every field here
+// shares: an invalid value is ValidateShape's own 400 (ValidSteering is
+// already one of its checks, so a bad vocabulary word needs no extra code
+// here), and asking to turn latency ON for a record whose Pool != "" is a
+// 409 naming the v1 constraint verbatim (plan-2 ledger: a steered
+// pool-scoped route would get latency records pointing at edges that
+// structurally cannot serve it — the per-pool-record-scoping alternative is
+// a recorded future, not v1). The rail reads the FETCHED RECORD's Pool —
+// stamped once at create and never touched by this door — not the app's
+// manifest, which may have moved to a different pool since; the record is
+// the source of truth this door edits, and the manifest's current value is
+// not it. Turning steering OFF is never blocked by this rail: only the
+// latency-on request needs the shared pool.
//
// Every refusal before the principal is proven is the family's byte-identical
// 404 (instanceGate), including a host that names no record: a record's
@@ -684,16 +701,23 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
// and therefore means "leave the upstream set alone". Clearing the set is
// spelled `"addrs":[]`, which is also the only spelling that says what it
// means.
+ //
+ // Steering is a *string, plainer than either: it is one scalar fact, not
+ // a set or a block, so there is no empty-vs-null distinction to carry.
+ // Absent (nil) leaves the record's steering alone; `"steering":""` turns
+ // it off; `"steering":"latency"` turns it on (subject to the pool rail
+ // below).
var req struct {
- Addrs *[]string `json:"addrs"`
- Probe json.RawMessage `json:"probe"`
+ Addrs *[]string `json:"addrs"`
+ Probe json.RawMessage `json:"probe"`
+ Steering *string `json:"steering"`
}
if !readJSON(w, r, &req) {
return
}
- if req.Addrs == nil && req.Probe == nil {
+ if req.Addrs == nil && req.Probe == nil && req.Steering == nil {
jsonError(w, http.StatusBadRequest,
- "nothing to update — send addrs, probe, or both")
+ "nothing to update — send addrs, probe, steering, or a combination")
return
}
var probe *provision.Health
@@ -734,14 +758,41 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if req.Probe != nil {
rec.Health = probe
}
+ if req.Steering != nil {
+ rec.Steering = *req.Steering
+ }
// The same door the create path and the box's reconcile pass check
// against, so a shape that could not have been created cannot be patched
// into existence either — and the caller gets ValidateShape's own
- // sentence, which names the offending upstream.
+ // sentence, which names the offending upstream. It is also this
+ // door's own invalid-value 400 for steering: ValidSteering is already
+ // one of ValidateShape's checks, so "geo" or "Latency" is refused here
+ // with no code of this handler's own.
if err := provision.ValidateShape(*rec); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
+ // v1's pool guardrail (multi-region-steering plan, plan-2 ledger):
+ // latency steering needs the SHARED pool, because a pool's own edges are
+ // the only ones that can serve a pool-scoped route — a steered
+ // non-shared-pool route would advertise latency records pointing at
+ // edges that structurally cannot answer for it. The per-pool-record
+ // alternative is a recorded future, not v1.
+ //
+ // rec.Pool is read straight off the bytes GetRecordETag fetched moments
+ // ago — this door never writes Pool and never re-derives it from the
+ // app's manifest, which may have moved to a different pool since the
+ // record was created. The record is the source of truth this door
+ // edits; the manifest's CURRENT value is not it, and using it here would
+ // let an operator's later `instances enable` silently flip what an
+ // already-provisioned route is allowed to do. Turning steering OFF is
+ // never blocked: only a request that leaves this record at "latency"
+ // needs the shared pool.
+ if rec.Steering == "latency" && rec.Pool != "" {
+ jsonError(w, http.StatusConflict,
+ "steering requires the shared pool — pool-scoped steering is a recorded future")
+ return
+ }
// The rail this door needs beyond ValidateShape: a patch may repoint a
// route's upstreams, never change what KIND of thing the route is.
// DerivesPaths is that line exactly — it decides whether the route owns a
@@ -775,7 +826,10 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadGateway, "could not update the instance — try again")
return
}
- s.audit(acct, app, principal, "instance-update", "updated instance "+host+" upstreams (cli)")
+ // "upstreams" no longer names the whole of what this door can change
+ // (steering joined it in Task 3), so the audit line stays field-agnostic
+ // rather than claiming a change this particular patch may not have made.
+ s.audit(acct, app, principal, "instance-update", "updated instance "+host+" (cli)")
// Strictly after the write landed, and exactly once: the record is what a
// box's reconcile leg converges upstreams from (provision.convergeOwned),
// so this announcement is what makes the new set live in seconds instead
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 63a8b0939..e99be750d 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -1322,6 +1322,232 @@ func TestAPIInstanceUpdateLostRace(t *testing.T) {
}
}
+// sharedPoolManifest is titoManifest with Pool cleared — the shared-pool
+// shape steering requires (v1's pool guardrail), so a test needing a record
+// that MAY steer starts here rather than mutating titoManifest in place.
+func sharedPoolManifest() provision.Manifest {
+ m := titoManifest()
+ m.Pool = ""
+ return m
+}
+
+// TestAPIInstanceUpdateSteeringRoundTrip: the PATCH door's third PATCHable
+// field (Task 3) — a shared-pool record may opt into "latency" and back out
+// to "", each write bumping the serial exactly once, and neither write
+// disturbs the addrs/probe fields this same door already owns.
+func TestAPIInstanceUpdateSteeringRoundTrip(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-steer-round")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"steer.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ afterCreate := serial(t, fx)
+
+ pu := updateURL(fx, "bab", "titogo", "steer.go.tito.io")
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":"latency"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch steering on = %d %s, want 200", status, out)
+ }
+ var echoed provision.Record
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatalf("patch body %s: %v", out, err)
+ }
+ if echoed.Steering != "latency" {
+ t.Fatalf("echoed steering = %q, want latency", echoed.Steering)
+ }
+ if !slices.Equal(echoed.Addrs, []string{"127.0.0.1:7001"}) {
+ t.Fatalf("steering-only patch moved addrs: %v", echoed.Addrs)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "steer.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Steering != "latency" {
+ t.Fatalf("stored steering = %q, want latency", stored.Steering)
+ }
+ if got := serial(t, fx); got != afterCreate+1 {
+ t.Fatalf("serial after patch on = %d, want exactly one bump past %d", got, afterCreate)
+ }
+ afterOn := serial(t, fx)
+
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"steering":""}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch steering off = %d %s, want 200", status, out)
+ }
+ stored, err = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "steer.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Steering != "" {
+ t.Fatalf("stored steering after clearing = %q, want empty", stored.Steering)
+ }
+ if !slices.Equal(stored.Addrs, []string{"127.0.0.1:7001"}) {
+ t.Fatalf("steering-only patch moved addrs: %v", stored.Addrs)
+ }
+ if got := serial(t, fx); got != afterOn+1 {
+ t.Fatalf("serial after patch off = %d, want exactly one bump past %d", got, afterOn)
+ }
+
+ // A body naming neither addrs, probe nor steering is still refused — the
+ // third field joins the existing "nothing to update" rail rather than
+ // making it optional.
+ before := serial(t, fx)
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("patch with neither field = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(out, "steering") {
+ t.Errorf("empty-body refusal = %s, want it to name steering among the patchable fields", out)
+ }
+ if got := serial(t, fx); got != before {
+ t.Errorf("serial after a refused empty patch = %d, want it unmoved from %d", got, before)
+ }
+}
+
+// TestAPIInstanceUpdateSteeringInvalidValue: the door's own vocabulary rail
+// (provision.ValidateShape -> ValidSteering), surfaced as a 400 with the
+// door's own sentence — a caller sending anything but "" or "latency" is
+// told exactly that, and nothing is written or announced.
+func TestAPIInstanceUpdateSteeringInvalidValue(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-steer-invalid")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"badsteer.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ before := serial(t, fx)
+
+ status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "titogo", "badsteer.go.tito.io"),
+ token, `{"steering":"geo"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("patch with invalid steering = %d %s, want 400", status, out)
+ }
+ var errBody struct {
+ Error string `json:"error"`
+ }
+ if err := json.Unmarshal([]byte(out), &errBody); err != nil {
+ t.Fatalf("refusal body %s: %v", out, err)
+ }
+ if want := `steering "geo": want "" or "latency"`; errBody.Error != want {
+ t.Errorf("refusal = %q, want ValidateShape's own sentence %q", errBody.Error, want)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "badsteer.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Steering != "" {
+ t.Fatalf("a refused patch reached the record: steering = %q", stored.Steering)
+ }
+ if got := serial(t, fx); got != before {
+ t.Fatalf("serial after a refused patch = %d, want it unmoved from %d", got, before)
+ }
+}
+
+// TestAPIInstanceUpdateSteeringPoolConflict: v1's pool guardrail (plan-2
+// ledger) — a route whose Pool != "" cannot opt into latency steering,
+// because the latency records would point at edges that structurally
+// cannot serve a pool-scoped route. The refusal is a 409 naming the v1
+// constraint verbatim, not a 400: the request is well-formed, the record
+// merely cannot honour it TODAY.
+//
+// The self-review point this test is built to settle: the rail reads the
+// STORED RECORD's Pool — the field the door itself stamped at create and
+// the only copy this handler ever reads — not the app's manifest, which a
+// later `instances enable` may have since repointed to a different pool.
+// The second half of this test proves that: the record keeps the pool it
+// was created with even after the manifest changes underneath it, and the
+// 409 keeps firing on that account.
+func TestAPIInstanceUpdateSteeringPoolConflict(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-steer-pool")
+ enableInstances(t, fx, "titogo", titoManifest()) // Pool: "tito"
+ u := instancesURL(fx, "bab", "titogo")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"pooled.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ created, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pooled.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Pool != "tito" {
+ t.Fatalf("created record pool = %q, want tito (test setup)", created.Pool)
+ }
+ before := serial(t, fx)
+
+ pu := updateURL(fx, "bab", "titogo", "pooled.go.tito.io")
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":"latency"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("patch steering on a pooled route = %d %s, want 409", status, out)
+ }
+ const wantMsg = "steering requires the shared pool — pool-scoped steering is a recorded future"
+ if out != `{"error":"`+wantMsg+`"}`+"\n" && !strings.Contains(out, wantMsg) {
+ t.Errorf("refusal = %s, want the v1 constraint named verbatim: %q", out, wantMsg)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pooled.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Steering != "" {
+ t.Fatalf("a refused patch reached the record: steering = %q", stored.Steering)
+ }
+ if got := serial(t, fx); got != before {
+ t.Fatalf("serial after a refused patch = %d, want it unmoved from %d", got, before)
+ }
+
+ // Clearing steering (turning it off) is never blocked by the pool rail —
+ // only an attempt to TURN latency ON is.
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":""}`); status != http.StatusOK {
+ t.Fatalf("patch steering off on a pooled route = %d %s, want 200", status, out)
+ }
+
+ // The manifest changes to the shared pool AFTER the record was created —
+ // the record itself is immutable on this axis (Pool is stamped at
+ // create and this door never touches it), so the rail must keep reading
+ // the record it fetched, not re-derive the pool from the manifest that
+ // happens to be live right now.
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"steering":"latency"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("patch steering after the MANIFEST moved to the shared pool = %d %s, want 409 still "+
+ "(the RECORD's own pool, stamped at create, is what the rail reads)", status, out)
+ }
+}
+
+// TestAPIInstanceUpdateSteeringLostRace: the steering field goes through
+// the exact same CAS write-back as addrs/probe — a concurrent writer means
+// the decision to steer was made about bytes that no longer exist, and the
+// honest answer is 409, not a silent clobber.
+func TestAPIInstanceUpdateSteeringLostRace(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-steer-race")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"steerrace.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ before := serial(t, fx)
+
+ fx.srv.cfg.Releases = staleETagStore{fx.rel}
+ status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "titogo", "steerrace.go.tito.io"),
+ token, `{"steering":"latency"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("patch steering losing the race = %d %s, want 409", status, out)
+ }
+ fx.srv.cfg.Releases = fx.rel
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "steerrace.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Steering != "" {
+ t.Fatalf("the lost race still overwrote the record: steering = %q", rec.Steering)
+ }
+ if got := serial(t, fx); got != before {
+ t.Fatalf("serial after a failed write = %d, want it unmoved from %d", got, before)
+ }
+}
+
// TestAPIInstanceListJoinsUpstreams: the list's third join (beside
// staged_version and health) — per-upstream probe state, read from the box
// snapshots the same way and with the same staleness rule, so `carlos
diff --git a/main.go b/main.go
index 0029f3a87..63e1f35ae 100644
--- a/main.go
+++ b/main.go
@@ -65,6 +65,8 @@ func main() {
runDeploy(args)
case "restart":
runRestart(args)
+ case "steering":
+ runSteering(args)
case "economics":
runEconomics(args)
case "store":
@@ -162,6 +164,8 @@ Your apps — ship and run software on a CARLOS deployment
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete |
set-upstreams
+ steering opt an instance into DNS latency steering, or back off —
+ -app <a> -host <h> latency|off (shared pool only, v1)
routes an app's routes and what each is bound to: upstream,
channel, config environment, status
domains a customer's own hostname attached to an app's route: