console: publish a hostname's prefix map; show path routes in carlos routes
PUT .../domains/{host}/paths replaces the whole map, reusing the domains
family's auth, account scoping and byte-identical 404 discipline. Publish
scope only — deciding where an account's traffic goes is not an
instance-scoped decision.
No write verb on the CLI: the tenant's platform is the source of truth, and a
second writer would drift from it the first time anyone used it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+633
−22
apimode.go+23 −7internal/console/apidomainpaths_test.go+242 −0internal/console/apidomains.go+141 −9internal/console/apiroutes.go+43 −1internal/console/apiroutes_test.go+46 −0internal/console/authcli.go+14 −0internal/domains/claims.go+8 −1routes_cli.go+26 −1routes_cli_test.go+90 −3
diff --git a/apimode.go b/apimode.go| index 59556c81b..54af7d3c4 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2438,22 +2438,38 @@ type apiRouteRow struct { |
| Provisioned bool `json:"provisioned"` |
| } |
| -// routeList reads one app's routes on the console's own box (issue #64). |
| -func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error) { |
| +// apiRoutePathRow is one path route of the same answer: a leading URL |
| +// segment on Host served by the route at Target rather than by Host's own |
| +// route. Additive like every field above — a console that predates path |
| +// routing sends no `paths` key and this decodes as an empty list, which is |
| +// the same thing it means on a console that has one and no rows in it. |
| +type apiRoutePathRow struct { |
| + Host string `json:"host"` |
| + Prefix string `json:"prefix"` |
| + Target string `json:"target"` |
| +} |
| + |
| +// routeList reads one app's routes on the console's own box (issue #64), and |
| +// the path routes hanging off them. Two returns rather than one joined |
| +// structure: a path row is a pointer at a route, not a kind of route, and |
| +// flattening them into one list would make every existing caller's loop |
| +// start having to ask which it was looking at. |
| +func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, []apiRoutePathRow, error) { |
| status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes")) |
| if err != nil { |
| - return nil, err |
| + return nil, nil, err |
| } |
| if status != http.StatusOK { |
| - return nil, apiErrorFromBody(status, body) |
| + return nil, nil, apiErrorFromBody(status, body) |
| } |
| var res struct { |
| - Routes []apiRouteRow `json:"routes"` |
| + Routes []apiRouteRow `json:"routes"` |
| + Paths []apiRoutePathRow `json:"paths"` |
| } |
| if err := json.Unmarshal(body, &res); err != nil { |
| - return nil, fmt.Errorf("routes: unreadable response: %w", err) |
| + return nil, nil, fmt.Errorf("routes: unreadable response: %w", err) |
| } |
| - return res.Routes, nil |
| + return res.Routes, res.Paths, nil |
| } |
| // apiLogsResponse is the console's logs answer (internal/console/apilogs.go) |
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go| new file mode 100644 |
| index 000000000..ffb39de4a |
| --- /dev/null |
| +++ b/internal/console/apidomainpaths_test.go |
| @@ -0,0 +1,242 @@ |
| +package console |
| + |
| +// The prefix-map door (apidomains.go): PUT .../domains/{host}/paths, the one |
| +// way a claim's Paths map is written from outside a box. It is the door the |
| +// TENANT'S OWN PLATFORM calls — that platform regenerates the whole map from |
| +// its own database and publishes the result — so the properties under test |
| +// are, in the order they'd hurt if lost: the write is WHOLESALE (a second |
| +// publish replaces rather than merges, which is the only way a rollback can |
| +// be expressed); a caller who does not hold the claim gets the family's |
| +// byte-identical 404 rather than anything that distinguishes "someone else |
| +// holds it" from "nobody does"; the two shape refusals are 422s that leave |
| +// the stored map untouched, never a silently truncated write; and the door is |
| +// Publish-scoped, so an instance credential cannot decide where an account's |
| +// traffic goes. |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func pathsURL(fx *consoleFixture, acct, app, host string) string { |
| + return domainsURL(fx, acct, app) + "/" + host + "/paths" |
| +} |
| + |
| +// pathsBody is the door's request shape. Always an OBJECT with a paths field |
| +// rather than a bare map, so the body can grow a field later without every |
| +// existing caller's body changing meaning. |
| +func pathsBody(t *testing.T, paths map[string]string) string { |
| + t.Helper() |
| + b, err := json.Marshal(map[string]any{"paths": paths}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// domainPathsResp is the answer: the NORMALIZED host (the one field the |
| +// caller could not already know), how many prefixes were stored, and whether |
| +// this call is what changed them. |
| +type domainPathsResp struct { |
| + Host string `json:"host"` |
| + Prefixes int `json:"prefixes"` |
| + Changed bool `json:"changed"` |
| +} |
| + |
| +// publishPaths PUTs one map and returns the decoded answer, failing the test |
| +// on any status but 200. |
| +func publishPaths(t *testing.T, fx *consoleFixture, token, host string, paths map[string]string) domainPathsResp { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), token, pathsBody(t, paths)) |
| + if code != http.StatusOK { |
| + t.Fatalf("publish paths on %s = %d %s, want 200", host, code, out) |
| + } |
| + var got domainPathsResp |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("paths body %s: %v", out, err) |
| + } |
| + return got |
| +} |
| + |
| +// claimedHost attaches the hostname a prefix map hangs off. Every test below |
| +// starts from a claim this app actually holds, because this door writes no |
| +// claim of its own — it only ever replaces one field of an existing one. |
| +func claimedHost(t *testing.T, fx *consoleFixture, member, host string) { |
| + t.Helper() |
| + attachDomain(t, fx, member, host, "go.tito.io") |
| +} |
| + |
| +// TestAPIDomainPathsPublishes: the door writes the map onto the claim, and |
| +// the answer names the normalized host it wrote to. |
| +func TestAPIDomainPathsPublishes(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-publish") |
| + claimedHost(t, fx, member, "ti.to") |
| + got := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + if got != (domainPathsResp{Host: "ti.to", Prefixes: 1, Changed: true}) { |
| + t.Fatalf("publish answer = %+v, want ti.to with one prefix, changed", got) |
| + } |
| + c := claimOf(t, fx, "ti.to") |
| + if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("claim paths = %v, want paulca -> go.tito.io", c.Paths) |
| + } |
| + // Republishing the identical map is the COMMON case — the caller |
| + // republishes wholesale as a repair — and must not read as a change, or |
| + // every repair would bump the serial and reload every edge in the fleet. |
| + if again := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}); again.Changed { |
| + t.Fatalf("republishing the same map = %+v, want changed false", again) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsReplaces is the wholesale contract: the caller |
| +// regenerates its whole map and publishes it, so an entry it no longer lists |
| +// must be GONE. Merging would make a rollback impossible to express. |
| +func TestAPIDomainPathsReplaces(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-replace") |
| + claimedHost(t, fx, member, "ti.to") |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"bigcorp": "go.tito.io"}) |
| + c := claimOf(t, fx, "ti.to") |
| + if _, still := c.Paths["paulca"]; still { |
| + t.Fatalf("claim paths = %v, want paulca gone — the second publish replaces, it does not merge", c.Paths) |
| + } |
| + if len(c.Paths) != 1 || c.Paths["bigcorp"] != "go.tito.io" { |
| + t.Fatalf("claim paths = %v, want bigcorp alone", c.Paths) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsEmptyBodyClears: an empty map is a legitimate published |
| +// state — every account back on the origin — and is how a whole migration is |
| +// rolled back in one call. |
| +func TestAPIDomainPathsEmptyBodyClears(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-clear") |
| + claimedHost(t, fx, member, "ti.to") |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + got := publishPaths(t, fx, member, "ti.to", map[string]string{}) |
| + if got != (domainPathsResp{Host: "ti.to", Prefixes: 0, Changed: true}) { |
| + t.Fatalf("clear answer = %+v, want ti.to with no prefixes, changed", got) |
| + } |
| + if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none", c.Paths) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsRequiresThePathsField: this write is WHOLESALE, so a body |
| +// that names no map at all cannot be read as an empty one — a misspelled |
| +// field would otherwise clear every prefix on the hostname and answer 200. |
| +// An empty map still means "clear it"; it just has to be said. |
| +func TestAPIDomainPathsRequiresThePathsField(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-nofield") |
| + claimedHost(t, fx, member, "ti.to") |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + |
| + for _, body := range []string{`{}`, `{"pathz":{"bigcorp":"go.tito.io"}}`, `{"paths":null}`} { |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, body) |
| + if code != http.StatusBadRequest { |
| + t.Fatalf("publish %s = %d %s, want 400", body, code, out) |
| + } |
| + c := claimOf(t, fx, "ti.to") |
| + if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("claim paths after %s = %v, want the published map intact", body, c.Paths) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsRejectsOtherAccount: a hostname another account holds |
| +// answers with the bytes http.NotFound writes and nothing else. Not a 403, |
| +// which would confirm the hostname exists; not a distinct JSON shape, which |
| +// would itself be the tell. The caller here is an honest owner of its OWN |
| +// account and app, so what refuses it is the claim's ownership check rather |
| +// than the membership gate above it. |
| +func TestAPIDomainPathsRejectsOtherAccount(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-other") |
| + claimedHost(t, fx, member, "ti.to") |
| + // The positive control, and it is not optional: an UNREGISTERED route |
| + // answers the byte-identical 404 too, so without a publish that works |
| + // this test would pass against a console that has no such door at all. |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + |
| + // A second account with an app of its own, and its owner's token. |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(42, "shop", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + bob := cliToken(t, fx, "bob@keymail.dev", "code-dom-paths-bob") |
| + |
| + want := canonicalNotFound(t) |
| + code, h, body := rawReq(t, http.MethodPut, |
| + fx.ts.URL+"/api/cli/apps/cdef/shop/domains/ti.to/paths", bob, |
| + pathsBody(t, map[string]string{"bigcorp": "evil.example"})) |
| + assertNotFound(t, "publishing paths onto another account's hostname", want, code, h, body) |
| + // And nothing was written through it: the holder's own map is intact. |
| + c := claimOf(t, fx, "ti.to") |
| + if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("claim paths = %v, want the holder's own map — a stranger's publish must not land", c.Paths) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsRejectsOversize: a map above the cap is refused WHOLE. A |
| +// silently truncated map would route a subset of accounts and still look |
| +// successful, which is the worst failure this feature can have. |
| +func TestAPIDomainPathsRejectsOversize(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-oversize") |
| + claimedHost(t, fx, member, "ti.to") |
| + publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| + |
| + big := make(map[string]string, domains.MaxPathsPerClaim+1) |
| + for i := 0; i <= domains.MaxPathsPerClaim; i++ { |
| + big[fmt.Sprintf("acct%d", i)] = "go.tito.io" |
| + } |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, pathsBody(t, big)) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("oversize publish = %d %s, want 422", code, out) |
| + } |
| + c := claimOf(t, fx, "ti.to") |
| + if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("claim paths = %v, want the previous map untouched", c.Paths) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsRejectsBadPrefix: the edge matches a request's FIRST path |
| +// segment by equality, so a key with a slash in it could never match and must |
| +// never be stored. "/paulca" is the shape a caller reaches for first, so it |
| +// is the one worth pinning. |
| +func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-badprefix") |
| + claimedHost(t, fx, member, "ti.to") |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, |
| + pathsBody(t, map[string]string{"/paulca": "go.tito.io"})) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("leading-slash prefix = %d %s, want 422", code, out) |
| + } |
| + if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none — a refused map must not partially land", c.Paths) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsIsPublishOnly: publishing a prefix map decides where an |
| +// account's traffic goes, which is a deployment-shaped act. The instance |
| +// credential an app's own router holds reaches the attach and detach doors |
| +// beside this one for compatibility; it does not reach this one. |
| +func TestAPIDomainPathsIsPublishOnly(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-roles") |
| + claimedHost(t, fx, member, "ti.to") |
| + body := pathsBody(t, map[string]string{"paulca": "go.tito.io"}) |
| + |
| + inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusForbidden { |
| + t.Fatalf("instance-role publish = %d %s, want 403", code, out) |
| + } |
| + pub := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), pub, body); code != http.StatusOK { |
| + t.Fatalf("publish-role publish = %d %s, want 200", code, out) |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 6991285a0..4551c4e28 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -5,15 +5,15 @@ |
| // the whole authorization; the agent's domains pass turns it into an alias |
| // row, on the box that actually owns the route. |
| // |
| -// Attach and detach go through domainsGate, below — the instance family's |
| -// principal, binding and readiness checks, but with the instance-verb |
| -// WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling (domainsCeilingBurst, |
| -// spec §4a): a Pages hub with wildcard DNS makes no per-repo hostname call at |
| -// all, so gating attach/detach on the per-minute instance budget would uplift |
| -// a call that isn't the workload that budget was sized for. The exemption |
| -// applies to every principal, person and service alike — it was never about |
| -// who is calling, only about what the door is. A debugging session chasing a |
| -// 429 here wants domainsCeilingBurst, not cliInstanceBurst. |
| +// Attach, detach and the prefix-map publish go through domainsGate, below — |
| +// the instance family's principal, binding and readiness checks, but with the |
| +// instance-verb WORKLOAD budget swapped for a CONVERGENCE-RATE ceiling |
| +// (domainsCeilingBurst, spec §4a): a Pages hub with wildcard DNS makes no |
| +// per-repo hostname call at all, so gating these on the per-minute instance |
| +// budget would uplift a call that isn't the workload that budget was sized |
| +// for. The exemption applies to every principal, person and service alike — |
| +// it was never about who is calling, only about what the door is. A debugging |
| +// session chasing a 429 here wants domainsCeilingBurst, not cliInstanceBurst. |
| // |
| // The rest of the gate IS the instance family's, verbatim: the same bearer |
| // shapes reach it, and every refusal before the ceiling is the same |
| @@ -529,6 +529,138 @@ func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool |
| return strings.EqualFold(strings.TrimSuffix(got, "."), strings.TrimSuffix(want, ".")) |
| } |
| +// maxPathsBody is the byte cap on a prefix-map publish, and it is the ONE |
| +// door in this family that cannot live inside readJSON's 8KiB: the ceiling is |
| +// MaxPathsPerClaim entries, each a segment of up to 255 bytes pointing at a |
| +// hostname of up to 253, which is a legitimate ~5MB body. Sized with headroom |
| +// above that rather than to it, so the refusal a caller at the ceiling meets |
| +// is the 422 that NAMES the cap — never a 413 whose fix ("split the request") |
| +// is wrong advice for a map that is wholesale by contract. |
| +const maxPathsBody = 8 << 20 |
| + |
| +// handleAPIDomainPaths answers `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`: |
| +// publish the WHOLE prefix map for a hostname the app already claims. |
| +// |
| +// Wholesale by contract. The caller regenerates the map from its own database |
| +// and publishes the result, so this replaces rather than merges — that is what |
| +// makes a republish a full repair and lets a removal express a rollback. There |
| +// is deliberately no per-prefix add or delete verb: two ways to write the same |
| +// state is two ways for it to drift from the caller's own. |
| +// |
| +// It writes ONE field of an existing claim and creates nothing, which is what |
| +// separates it from attach: a hostname this app does not already hold has no |
| +// prefix map to publish, and says so with the family's neutral 404. |
| +func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| + p, acct, app, ok := s.domainsGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + // Straight after domainsGate, exactly as the attach door places its own: |
| + // after the family's byte-identical 404, before any read or write. A |
| + // prefix map on the control plane's own hostname hands a slice of the |
| + // console's URL space to another route — /admin served by whatever the |
| + // account points it at — and that is not something a CI credential |
| + // should be able to decide. SERVICE-ONLY, and the split is attach's: a |
| + // person doing it is an ordinary operator act, a machine doing it is the |
| + // console's own address being carved up by something with no standing. |
| + if s.refuseServiceControlPlane(w, p, acct.ID, app, "publish path routes for it") { |
| + return |
| + } |
| + principal := auditActor(p, acct) |
| + // Shape-checked BEFORE the key builder, exactly as the detach and get |
| + // doors do: a {host} segment arrives percent-decoded, so |
| + // "..%2f..%2finstances.json" would reach ClaimKey as a path with |
| + // separators in it. There is no path through this handler on which an |
| + // unnormalized host reaches the store. |
| + // |
| + // And before the BODY, unlike attach — whose hostname arrives in the body |
| + // and cannot be checked any earlier. Here the host is in the path, so |
| + // checking it first means a request naming a malformed one is refused |
| + // without first buffering up to maxPathsBody of it. No neutrality cost: |
| + // this check reads the URL and nothing else, so its answer cannot depend |
| + // on who holds the claim. |
| + host, ok := domains.NormalizeAliasHost(r.PathValue("host")) |
| + if !ok { |
| + jsonError(w, http.StatusBadRequest, "invalid hostname") |
| + return |
| + } |
| + // A POINTER to the map, so that an ABSENT paths field is refused rather |
| + // than read as an empty one. The distinction matters here in a way it |
| + // does not on a merging door: this write is wholesale, so a body whose |
| + // field name is misspelled would otherwise be an empty publish — a silent |
| + // clearing of every prefix on the hostname, answered with 200. An empty |
| + // map is a legitimate instruction and still means exactly that; it just |
| + // has to be said. |
| + var in struct { |
| + Paths *map[string]string `json:"paths"` |
| + } |
| + if !readJSONLimit(w, r, &in, maxPathsBody) { |
| + return |
| + } |
| + if in.Paths == nil { |
| + jsonError(w, http.StatusBadRequest, |
| + "send the whole prefix map as {\"paths\": {...}} — {\"paths\": {}} is how you clear it") |
| + return |
| + } |
| + paths := *in.Paths |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, paths) |
| + if errors.Is(err, domains.ErrTooManyPaths) { |
| + jsonError(w, http.StatusUnprocessableEntity, |
| + fmt.Sprintf("a hostname may carry at most %d path prefixes", domains.MaxPathsPerClaim)) |
| + return |
| + } else if errors.Is(err, domains.ErrBadPathPrefix) { |
| + jsonError(w, http.StatusUnprocessableEntity, |
| + "each path prefix must be a single bare segment: \"paulca\", not \"/paulca\"") |
| + return |
| + } else if errors.Is(err, domains.ErrBadPathTarget) { |
| + // The caller's error, not ours, so it must not land in the 502 below |
| + // telling them to retry a body that will fail identically forever. |
| + // The wrapped message names the offending entry — the one thing that |
| + // makes a 10,000-entry map fixable. |
| + jsonError(w, http.StatusUnprocessableEntity, err.Error()) |
| + return |
| + } else if errors.Is(err, domains.ErrHostUnavailable) { |
| + // The family's byte-identical 404 discipline, copied verbatim from |
| + // setDomainCapabilities (not jsonError — a distinct shape here would |
| + // itself be a tell): a caller who does not hold this claim cannot |
| + // tell "somebody else holds it" from "nobody has claimed it". |
| + http.NotFound(w, r) |
| + return |
| + } else if err != nil { |
| + log.Printf("console: api domains paths %s/%s %s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update the hostname's path routes — try again") |
| + return |
| + } |
| + if changed { |
| + // The COUNT, never the map: a published map runs to MaxPathsPerClaim |
| + // entries, and an audit row is a sentence an operator reads, not a |
| + // copy of the payload. What the trail has to answer is "who moved |
| + // this hostname's routing, and when". |
| + s.audit(acct, app, principal, "domain-paths", |
| + fmt.Sprintf("published %d path prefixes for %s (cli)", len(paths), host)) |
| + // A box-consumed write like every other claim write here: the |
| + // reconcile pass derives route_paths from this field, so announce it |
| + // and the flipped accounts are serving seconds after the call |
| + // instead of on the hour. Gated on `changed` for the reason |
| + // SetClaimPaths has a no-op check at all — the caller republishes |
| + // wholesale as a repair, and a bump per republish would keep every |
| + // box converging onto nothing. |
| + s.bumpSerial(ctx) |
| + } |
| + // The normalized host is the one thing the caller could not already |
| + // know; the map it just sent is not echoed back, both because it is |
| + // megabytes of the caller's own bytes and because nothing here derives |
| + // or alters it. `changed` is the same answer `created` and `removed` |
| + // give on the doors either side of this one. |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "host": host, |
| + "prefixes": len(paths), |
| + "changed": changed, |
| + }) |
| +} |
| + |
| // handleAPIDomainDetach releases a hostname. Always 200, whatever was there: |
| // `removed` reports whether this call is what removed it, and the three ways |
| // of removing nothing — already gone, never claimed, claimed by someone else |
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go| index 489397f96..042c9b49b 100644 |
| --- a/internal/console/apiroutes.go |
| +++ b/internal/console/apiroutes.go |
| @@ -63,6 +63,18 @@ type apiRoute struct { |
| Provisioned bool `json:"provisioned"` |
| } |
| +// apiRoutePath is one path route: a leading URL segment on Host that is |
| +// served by the route at Target instead of by Host's own route. It carries |
| +// no upstream, channel or environment of its own — it is a POINTER, and |
| +// everything about how that traffic is served comes from Target's ordinary |
| +// row above. Additive on the wire: a CLI that predates it decodes nothing |
| +// and lists routes exactly as it always did. |
| +type apiRoutePath struct { |
| + Host string `json:"host"` |
| + Prefix string `json:"prefix"` |
| + Target string `json:"target"` |
| +} |
| + |
| // routeUpstream renders the one cell answering "where does this route send |
| // traffic": a socket wins over addresses (registry.Route's own |
| // precedence), a multi-upstream set is shown whole rather than reduced to |
| @@ -138,5 +150,35 @@ func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) { |
| // Sorted by host: registry order is not stable across passes, and a |
| // listing that reshuffles between calls is one nobody can diff. |
| sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| - writeJSON(w, http.StatusOK, map[string]any{"routes": rows}) |
| + // The path routes hanging off those hosts. A flipped prefix set has to be |
| + // visible from the command operators already use, or a migration is |
| + // invisible until something breaks. |
| + // |
| + // Best-effort, and NOT a reason to fail the listing: the routes are the |
| + // answer, the prefixes are a detail of some of them, and a console whose |
| + // route_paths table cannot be read should still say what routes exist. |
| + // Same tenant boundary as the rows above, read off the row's own stamped |
| + // columns — ListPaths returns every path route on this box. |
| + var paths []apiRoutePath |
| + if all, err := reg.ListPaths(); err != nil { |
| + log.Printf("console: api routes: registry list paths: %v", err) |
| + } else { |
| + for _, p := range all { |
| + // The host filter is what keeps this from listing a prefix whose |
| + // hostname is not in the answer above: the reconcile pass only |
| + // derives a row when the claim's host is a local route of the |
| + // same app and account, so in practice every row passes — but a |
| + // listing must not depend on another package's invariant. |
| + if p.App != app || p.Account != acct.Sqid { |
| + continue |
| + } |
| + if _, shown := routeFor[p.Host]; !shown { |
| + continue |
| + } |
| + paths = append(paths, apiRoutePath{Host: p.Host, Prefix: p.Prefix, Target: p.Target}) |
| + } |
| + } |
| + // ListPaths already orders by host then prefix, which is the order the |
| + // CLI prints them in under each host. |
| + writeJSON(w, http.StatusOK, map[string]any{"routes": rows, "paths": paths}) |
| } |
diff --git a/internal/console/apiroutes_test.go b/internal/console/apiroutes_test.go| index 576c9e02c..785ebab1f 100644 |
| --- a/internal/console/apiroutes_test.go |
| +++ b/internal/console/apiroutes_test.go |
| @@ -207,3 +207,49 @@ func TestAPIRoutesAppTokenIsRefused(t *testing.T) { |
| t.Fatal("an app token reached the routes door — it must not") |
| } |
| } |
| + |
| +// TestAPIRoutesListsPathRoutes: a hostname whose traffic is split by leading |
| +// path segment reports those prefixes, so an operator can read a migration's |
| +// flipped set from the command they already use. Same tenant boundary as the |
| +// routes beside them — a path row stamped to another account or another app |
| +// is another tenant's routing table. |
| +func TestAPIRoutesListsPathRoutes(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-paths") |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range []registry.RoutePath{ |
| + {Host: "connect.go.tito.io", Prefix: "paulca", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, |
| + {Host: "connect.go.tito.io", Prefix: "bigcorp", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, |
| + // A neighbouring app's row, and another account's: the answer must |
| + // carry neither, exactly as it carries neither of their routes. |
| + {Host: "neighbour.example.com", Prefix: "paulca", Target: "connect.go.tito.io", Account: "bab", App: "kass"}, |
| + {Host: "foreign.example.com", Prefix: "paulca", Target: "foreign.example.com", Account: "zzzz", App: "titogo"}, |
| + } { |
| + if err := reg.UpsertPath(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET routes = %d %v", status, out) |
| + } |
| + rows, ok := out["paths"].([]any) |
| + if !ok { |
| + t.Fatalf("response has no paths array: %v", out) |
| + } |
| + if len(rows) != 2 { |
| + t.Fatalf("paths = %v, want exactly titogo's two rows in this account", out["paths"]) |
| + } |
| + // ListPaths orders by host then prefix, and the CLI prints them in the |
| + // order they arrive — so the order is part of the answer. |
| + first, _ := rows[0].(map[string]any) |
| + second, _ := rows[1].(map[string]any) |
| + if str(t, first, "prefix") != "bigcorp" || str(t, second, "prefix") != "paulca" { |
| + t.Fatalf("paths = %v, want them ordered by host then prefix", out["paths"]) |
| + } |
| + if str(t, first, "host") != "connect.go.tito.io" || str(t, first, "target") != "router-a.go.tito.io" { |
| + t.Fatalf("path row = %v, want the host it splits and the route it points at", first) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index cd8e3f795..08cf3c055 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -622,6 +622,20 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach) |
| + // The prefix-map publish (path-based routing): replace the WHOLE map of |
| + // leading path segments a claimed hostname splits its traffic by. The |
| + // tenant's own platform is the writer and the source of truth — it |
| + // regenerates the map from its database and republishes it — which is |
| + // why there is no per-prefix verb and no CLI writer at all. |
| + // |
| + // `Publish` ONLY, and deliberately narrower than the three doors above |
| + // it: those carry `Instance` for compatibility with a router that has |
| + // always re-attached its hostnames, and this door has no such history to |
| + // preserve. Deciding which accounts' traffic leaves the legacy app is a |
| + // deployment-shaped act, and an instance-scoped token has no business |
| + // making it. Being new, it is not in TestInstanceRoleMatchesTodaysAppTokenReach's |
| + // legacy map either — nothing to exempt, because nothing reached it before. |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths) |
| // App feature flags (apifeatures.go, #347). The read carries Instance |
| // because the app IS the consumer — its router reads its own switches |
| // with the CARLOS_INSTANCE_TOKEN, the same credential and the same |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index e8fbda706..935a8b062 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -351,6 +351,13 @@ var ErrTooManyPaths = errors.New("too many path prefixes") |
| // single path segment. |
| var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment") |
| +// ErrBadPathTarget is returned by SetClaimPaths for a VALUE that is not a |
| +// normalised hostname. A sentinel rather than a bare fmt.Errorf so the API |
| +// door above can answer it as the caller's error (422, naming the entry) — |
| +// without it, the only way to tell a malformed target from a store failure |
| +// would be to match on the message text. |
| +var ErrBadPathTarget = errors.New("path target is not a normalised hostname") |
| + |
| // validPathPrefix reports whether p is a bare first path segment: non-empty, |
| // no slashes, and nothing that would not survive a URL path intact. The edge |
| // matches the request's first segment by EQUALITY, so anything that cannot be |
| @@ -400,7 +407,7 @@ func SetClaimPaths(ctx context.Context, s release.Store, host, account, app stri |
| // account owns is decided by the reconcile pass, which — unlike this |
| // door — can see the registry. |
| if n, ok := NormalizeAliasHost(target); !ok || n != target { |
| - return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target) |
| + return false, fmt.Errorf("domains: path prefix %q: target %q: %w", prefix, target, ErrBadPathTarget) |
| } |
| } |
| for attempt := 0; ; attempt++ { |
diff --git a/routes_cli.go b/routes_cli.go| index f1082b0fd..209d3adb9 100644 |
| --- a/routes_cli.go |
| +++ b/routes_cli.go |
| @@ -32,6 +32,11 @@ traffic, which release channel it follows, and which named config |
| environment it is bound to — the binding that decides which secrets the |
| route's process is delivered (see carlos env, carlos secrets). |
| +A hostname whose traffic is split by leading path segment lists those |
| +prefixes indented beneath it, naming the route each one is served by. |
| +Read-only: prefix maps are published wholesale by the platform that owns |
| +them, so there is no carlos verb that writes one. |
| + |
| Signed-in only (carlos auth login). On a box, the local listing is |
| "carlos instances".` |
| @@ -47,7 +52,7 @@ func runRoutes(args []string) { |
| sess := requireAPISession("routes", *consoleFlag) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, routesUsage) |
| - rows, err := sess.routeList(context.Background(), acct, app2) |
| + rows, paths, err := sess.routeList(context.Background(), acct, app2) |
| if err != nil { |
| log.Fatalf("routes: %v", err) |
| } |
| @@ -65,6 +70,26 @@ func runRoutes(args []string) { |
| // app's default bundle — so it prints as a dash rather than a |
| // blank cell a reader would take for missing data. |
| dashIfEmpty(r.Environment), dashIfEmpty(r.Status), dashIfEmpty(r.Health)) |
| + // Path routes indented under the hostname whose traffic they split. |
| + // A flipped prefix set has to be visible from the command operators |
| + // already use, or a migration is invisible until something breaks. |
| + // |
| + // KIND says "path" and UPSTREAM names the TARGET ROUTE rather than |
| + // an address, because that is all a path row is: a pointer, whose |
| + // channel, environment and health are the target's own row's, listed |
| + // wherever that route appears. Dashes rather than blanks in the |
| + // remaining columns, for the reason ENVIRONMENT prints one above — |
| + // a blank cell reads as data this client failed to fetch. |
| + // |
| + // Read-only, here and everywhere in the CLI: the tenant's own |
| + // platform publishes these wholesale and is their source of truth, |
| + // and a second writer would drift from it the first time it was used. |
| + for _, p := range paths { |
| + if p.Host != r.Host { |
| + continue |
| + } |
| + fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target)) |
| + } |
| } |
| w.Flush() |
| if shown == 0 { |
diff --git a/routes_cli_test.go b/routes_cli_test.go| index 34fc47e6f..fcd6504b2 100644 |
| --- a/routes_cli_test.go |
| +++ b/routes_cli_test.go |
| @@ -11,7 +11,18 @@ import ( |
| // routesConsole plants a credential for an httptest console that answers |
| // GET /api/cli/apps (account resolution) and the routes door for titogo. |
| +// No `paths` key at all, which is also what a console predating path routing |
| +// sends — so every test using it is exercising that older shape too. |
| func routesConsole(t *testing.T, rows []map[string]any) string { |
| + t.Helper() |
| + return routesConsolePaths(t, rows, nil) |
| +} |
| + |
| +// routesConsolePaths is routesConsole with the path routes hanging off those |
| +// hosts. Split out rather than folded into every caller: the prefix map is |
| +// additive, and the tests that predate it must keep sending the answer that |
| +// predates it. |
| +func routesConsolePaths(t *testing.T, rows, paths []map[string]any) string { |
| t.Helper() |
| return signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { |
| switch { |
| @@ -20,7 +31,11 @@ func routesConsole(t *testing.T, rows []map[string]any) string { |
| {"sqid": "bae", "name": "tom@keymail.dev", "role": "owner", "apps": []string{"titogo"}}, |
| }}) |
| case strings.HasSuffix(r.URL.Path, "/titogo/routes"): |
| - json.NewEncoder(w).Encode(map[string]any{"routes": rows}) |
| + body := map[string]any{"routes": rows} |
| + if paths != nil { |
| + body["paths"] = paths |
| + } |
| + json.NewEncoder(w).Encode(body) |
| default: |
| http.NotFound(w, r) |
| } |
| @@ -39,10 +54,15 @@ func TestRouteListReportsTheEnvironmentBinding(t *testing.T) { |
| if err != nil { |
| t.Fatalf("session: %v", err) |
| } |
| - rows, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo") |
| + rows, paths, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo") |
| if err != nil { |
| t.Fatalf("routeList: %v", err) |
| } |
| + // A console predating path routing sends no `paths` key at all; that |
| + // must decode as "no path routes", never as an error. |
| + if len(paths) != 0 { |
| + t.Fatalf("paths = %v, want none from a console that sends no paths key", paths) |
| + } |
| if len(rows) != 1 { |
| t.Fatalf("rows = %v, want one", rows) |
| } |
| @@ -67,7 +87,7 @@ func TestRouteListOldConsoleIsNamed(t *testing.T) { |
| if err != nil { |
| t.Fatalf("session: %v", err) |
| } |
| - if _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil { |
| + if _, _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil { |
| t.Fatal("a 404 from the routes door must surface as an error, never as an empty list") |
| } |
| } |
| @@ -127,6 +147,73 @@ func TestRoutesTableCarriesEveryColumn(t *testing.T) { |
| } |
| } |
| +// TestRoutesShowsPathRoutesUnderTheirHost: a hostname whose traffic is split |
| +// by leading path segment prints those prefixes indented beneath it, naming |
| +// the route each one is served by. Without this the flipped set of a |
| +// migration is invisible from the command operators already use, and stays |
| +// invisible until something breaks. |
| +func TestRoutesShowsPathRoutesUnderTheirHost(t *testing.T) { |
| + useTempCredentials(t) |
| + origin := routesConsolePaths(t, |
| + []map[string]any{ |
| + {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001", |
| + "channel": "stable", "status": "ready", "health": "Running"}, |
| + {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002", |
| + "channel": "stable", "status": "ready", "health": "Running"}, |
| + }, |
| + []map[string]any{ |
| + {"host": "connect.go.tito.io", "prefix": "bigcorp", "target": "router-a.go.tito.io"}, |
| + {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"}, |
| + }) |
| + var out bytes.Buffer |
| + old := stdout |
| + stdout = &out |
| + t.Cleanup(func() { stdout = old }) |
| + |
| + runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin}) |
| + |
| + lines := strings.Split(out.String(), "\n") |
| + hostAt, prefixAt := -1, map[string]int{} |
| + for i, l := range lines { |
| + switch { |
| + case strings.HasPrefix(l, "connect.go.tito.io"): |
| + hostAt = i |
| + case strings.HasPrefix(strings.TrimSpace(l), "/bigcorp"): |
| + prefixAt["bigcorp"] = i |
| + case strings.HasPrefix(strings.TrimSpace(l), "/paulca"): |
| + prefixAt["paulca"] = i |
| + } |
| + } |
| + if hostAt < 0 || len(prefixAt) != 2 { |
| + t.Fatalf("routes output has no host row with its two prefixes under it:\n%s", out.String()) |
| + } |
| + // UNDER their own host, not appended at the end of the table: which |
| + // hostname a prefix splits is the whole fact being reported. |
| + for p, at := range prefixAt { |
| + if at != hostAt+1 && at != hostAt+2 { |
| + t.Fatalf("/%s printed at line %d, want it directly under connect.go.tito.io at %d:\n%s", |
| + p, at, hostAt, out.String()) |
| + } |
| + if !strings.HasPrefix(lines[at], " ") { |
| + t.Fatalf("/%s row = %q, want it indented under its hostname", p, lines[at]) |
| + } |
| + if !strings.Contains(lines[at], "router-a.go.tito.io") { |
| + t.Fatalf("/%s row = %q, want the route it is served by named", p, lines[at]) |
| + } |
| + } |
| + // A path row is a POINTER at another route: it has no upstream, channel, |
| + // environment or health of its own, and those cells must read as answers |
| + // rather than as data this client failed to fetch. |
| + if !strings.Contains(lines[prefixAt["paulca"]], "\u2014") { |
| + t.Fatalf("path row = %q, want em dashes in the columns a pointer has none of", lines[prefixAt["paulca"]]) |
| + } |
| + // And they are not routes: a host with only path rows under it must not |
| + // make the "no routes" line disappear, nor inflate the count of what is. |
| + if strings.Contains(out.String(), "no routes for titogo") { |
| + t.Fatalf("routes output claims there are none:\n%s", out.String()) |
| + } |
| +} |
| + |
| // TestRoutesEmptyListSaysSo: an empty answer prints a line, not a bare |
| // header an operator has to interpret. |
| func TestRoutesEmptyListSaysSo(t *testing.T) { |