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>
7 files changed,
+548
−19
apimode.go+29 −0apimode_test.go+69 −0instances_cli.go+104 −0instances_e2e_test.go+43 −0internal/console/apiinstances.go+73 −19internal/console/apiinstances_test.go+226 −0main.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: |