Merge pull request #386 from carlosframework/path-based-routing
Path-based routing: one hostname, many upstreams
25 files changed,
+5579
−51
apimode.go+51 −7docs/superpowers/plans/2026-09-03-path-based-routing.md+1929 −0docs/superpowers/specs/2026-09-02-path-based-routing-design.md+597 −0domains_cli.go+69 −1domains_cli_test.go+95 −0internal/console/apidomainpaths_test.go+348 −0internal/console/apidomains.go+169 −9internal/console/apiroutes.go+56 −1internal/console/apiroutes_test.go+77 −0internal/console/authcli.go+24 −0internal/console/controlplane_test.go+95 −0internal/console/svcroutes_test.go+13 −0internal/domains/aliases.go+158 −14internal/domains/aliases_paths_test.go+269 −0internal/domains/claimcache_test.go+2 −1internal/domains/claims.go+145 −0internal/domains/claims_test.go+3 −2internal/domains/paths_test.go+254 −0internal/edge/edge.go+246 −10internal/edge/path_e2e_test.go+117 −0internal/edge/path_test.go+471 −0internal/registry/paths_test.go+132 −0internal/registry/registry.go+115 −2routes_cli.go+26 −1routes_cli_test.go+118 −3
CI passed — run details
diff --git a/apimode.go b/apimode.go| index 5b0e1764e..91d190316 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2481,22 +2481,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) |
| @@ -2711,6 +2727,34 @@ func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]a |
| return res.Domains, nil |
| } |
| +// domainPaths reads ONE hostname's published prefix map off the per-host |
| +// claim door. Published, not derived: this is the map the owning platform |
| +// PUT, which is a different fact from the route_paths rows `carlos routes` |
| +// prints. The reconcile pass drops an entry whose target is not a |
| +// same-account route on the box, so a prefix present here and absent there is |
| +// a refused flip — the one thing an operator debugging a migration needs to |
| +// see, and invisible everywhere else. |
| +// |
| +// A console that predates path routing sends no `paths` key, which decodes as |
| +// an empty map: "this hostname splits no traffic" is the same answer such a |
| +// deployment would give if asked, so there is nothing to distinguish. |
| +func (s *apiSession) domainPaths(ctx context.Context, a apiAcct, app, host string) (map[string]string, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host))) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Paths map[string]string `json:"paths"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("domains paths: unreadable response: %w", err) |
| + } |
| + return res.Paths, nil |
| +} |
| + |
| // shipTimeout bounds ship's own context — generous on purpose. The console |
| // gives itself up to 10 minutes to read the request body and 2 minutes to |
| // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
diff --git a/docs/superpowers/plans/2026-09-03-path-based-routing.md b/docs/superpowers/plans/2026-09-03-path-based-routing.md| new file mode 100644 |
| index 000000000..0c034aeed |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-03-path-based-routing.md |
| @@ -0,0 +1,1929 @@ |
| +# Path-Based Routing Implementation Plan |
| + |
| +> **HISTORICAL ARTIFACT — superseded by the code that shipped.** This plan was |
| +> written before implementation and is kept for the reasoning, not as a |
| +> description of the system. Its code blocks are NOT what shipped: implementers |
| +> found at least three defects in them (a missing serial bump on the write |
| +> door, a validation ordering in `SetClaimPaths`, and a wire shape the door |
| +> tightened to `{"paths": {...}}`). Read the design spec |
| +> (`docs/superpowers/specs/2026-09-02-path-based-routing-design.md`) for intent |
| +> and the source for behaviour; do not copy anything from here. |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Let one hostname route to different upstreams by leading URL path segment, so Tito can migrate accounts from classic to Tito Go one at a time and reverse any of them. |
| + |
| +**Architecture:** A prefix *selects an existing route* rather than defining a backend — the same pointer shape `route_aliases` already uses. Tito publishes the whole prefix map for a hostname as an additive `paths` field on that hostname's existing claim object (`domains/<host>.json`); `ReconcileAliases` folds it into a new pass-owned `route_paths` table; `Fingerprint()` gains a section for that table so the edge's 2s watcher notices; the edge resolves the host exactly as today and then, only for hosts that have a prefix map, does one more lookup. |
| + |
| +**Tech Stack:** Go, SQLite (modernc `sqlite` driver), `net/http`, `net/http/httptest`. No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-02-path-based-routing-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **Additive only.** Every existing deployment has zero `route_paths` rows and must behave byte-for-byte as it does today. No migration step, no behaviour change until a row exists. |
| +- **Prefixes are bare segments** — `paulca`, never `/paulca` — on the wire, in the table, and in the lookup. Surfaces may render a slash; nothing else adds one. |
| +- **No prefix stripping.** The upstream receives the original path unchanged. |
| +- **`maxPathsPerClaim = 10000`.** Deterministic drops in sorted key order, logged, never silent. |
| +- **Tito is the only writer.** All CARLOS-side surfaces are read-only. |
| +- **CARLOS never learns Tito's account model.** No reserved-prefix list, no account validation, no URL-shape assumptions beyond "first segment". |
| +- **AGENTS.md rules apply:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` all clean before every commit. Every change lands through a PR — never a direct commit or local merge to `main`. |
| +- **Failure direction:** when anything is missing or refused, traffic falls through to the host's own route (classic). A broken flip must never 404; it must un-flip. |
| + |
| +--- |
| + |
| +### Task 1: `route_paths` table, accessors, and the fingerprint section |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` (schema in `migrate()` ~line 395; new `RoutePath` type and accessors after `DeleteAlias` ~line 1533; `Fingerprint()` ~line 1676) |
| +- Test: `internal/registry/paths_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing (first task). |
| +- Produces: `registry.RoutePath{Host, Prefix, Target, Account, App string}`; `(*Registry).ListPaths() ([]RoutePath, error)`; `(*Registry).UpsertPath(RoutePath) error`; `(*Registry).DeletePath(host, prefix string) error`. Task 3 writes through these, Task 4 reads through `ListPaths`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/registry/paths_test.go`: |
| + |
| +```go |
| +package registry |
| + |
| +import "testing" |
| + |
| +// TestPathRoundTrip: a written prefix row comes back, and a repoint of the |
| +// same (host, prefix) replaces rather than duplicates. |
| +func TestPathRoundTrip(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListPaths returned %d rows, want 1", len(got)) |
| + } |
| + if got[0].Target != "go.tito.io" { |
| + t.Fatalf("target = %q, want go.tito.io", got[0].Target) |
| + } |
| + |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got)) |
| + } |
| + if got[0].Target != "beta.tito.io" { |
| + t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target) |
| + } |
| +} |
| + |
| +// TestDeletePathIsIdempotent: deleting a row that is already gone is a |
| +// success, the same contract DeleteAlias carries. The reconcile pass deletes |
| +// unconditionally, so this must not error. |
| +func TestDeletePathIsIdempotent(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.DeletePath("ti.to", "nobody"); err != nil { |
| + t.Fatalf("deleting an absent path row: %v", err) |
| + } |
| +} |
| + |
| +// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without |
| +// colliding — the key is the pair, not the prefix. |
| +func TestPathsAreScopedToTheirHost(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + for _, h := range []string{"ti.to", "other.example"} { |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: h, Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 2 { |
| + t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got)) |
| + } |
| +} |
| + |
| +// TestFingerprintChangesOnPathMutation is the load-bearing test of this |
| +// task. Fingerprint() is the 2s content hash the edge polls; a route_paths |
| +// change it cannot see is a flip that reconciles perfectly and never reaches |
| +// the live map — a silent false negative, the failure class Fingerprint's own |
| +// header names as the one to engineer against. |
| +func TestFingerprintChangesOnPathMutation(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after) |
| + } |
| +} |
| + |
| +// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a |
| +// row disappearing, and it must reach the edge on the same 2s bound as the |
| +// flip did. |
| +func TestFingerprintChangesOnPathRemoval(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.DeletePath("ti.to", "paulca"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/registry/ -run 'TestPath|TestDeletePath|TestFingerprintChangesOnPath' -count=1` |
| +Expected: FAIL — `undefined: RoutePath`, `reg.UpsertPath undefined`. |
| + |
| +- [ ] **Step 3: Add the table to `migrate()`** |
| + |
| +In `internal/registry/registry.go`, immediately after the `wildcard_domains` `CREATE TABLE IF NOT EXISTS` block: |
| + |
| +```go |
| + // route_paths is a derived table (the domains reconcile pass owns every |
| + // row, materializing a claim's Paths map into it) — a plain CREATE TABLE |
| + // IF NOT EXISTS, the same additive shape as route_aliases and |
| + // wildcard_domains above, so existing DBs migrate on open with zero rows |
| + // and no behaviour change. |
| + // |
| + // The key is the PAIR: `routes` has host as its primary key and must |
| + // stay one row per hostname (that row owns the cert, the home box, the |
| + // DNS state and the aliases), so a hostname's prefixes live here instead. |
| + // prefix is a BARE segment — "paulca", never "/paulca". |
| + if _, err := r.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS route_paths ( |
| + host TEXT NOT NULL, |
| + prefix TEXT NOT NULL, |
| + target TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + PRIMARY KEY (host, prefix) |
| +);`); err != nil { |
| + return err |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Add the type and accessors** |
| + |
| +In `internal/registry/registry.go`, after `DeleteAlias` (~line 1533): |
| + |
| +```go |
| +// RoutePath is one derived row of the route_paths table: a leading URL path |
| +// segment on Host that is served by the route at Target instead of by Host's |
| +// own route. Wholly pass-owned, like route_aliases and wildcard_domains: |
| +// every row traces to a claim's Paths entry, so removal needs no ownership |
| +// flag. |
| +// |
| +// A path row deliberately carries NO upstream, channel or environment of its |
| +// own. It is a pointer; everything about how the traffic is served comes from |
| +// Target's ordinary route row. That is what makes pointing a subset of |
| +// prefixes at a second route a working canary rather than a special case. |
| +// |
| +// Prefix is a bare segment — "paulca", never "/paulca". |
| +type RoutePath struct { |
| + Host string |
| + Prefix string |
| + Target string |
| + Account string |
| + App string |
| +} |
| + |
| +// ListPaths returns every path route, ordered by host then prefix. |
| +func (r *Registry) ListPaths() ([]RoutePath, error) { |
| + rows, err := r.sql.Query(`SELECT host, prefix, target, account, app |
| +FROM route_paths ORDER BY host, prefix`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []RoutePath |
| + for rows.Next() { |
| + var p RoutePath |
| + if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, p) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// UpsertPath inserts or repoints one path route by its (host, prefix) key. |
| +// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this |
| +// table is maintained on a separate cadence — so every column is overwritten. |
| +func (r *Registry) UpsertPath(p RoutePath) error { |
| + _, err := r.sql.Exec(` |
| +INSERT INTO route_paths (host, prefix, target, account, app) |
| +VALUES (?, ?, ?, ?, ?) |
| +ON CONFLICT(host, prefix) DO UPDATE SET |
| + target=excluded.target, account=excluded.account, app=excluded.app`, |
| + p.Host, p.Prefix, p.Target, p.Account, p.App) |
| + return err |
| +} |
| + |
| +// DeletePath removes one path route. Idempotent: a row already gone is a |
| +// success, the same contract as DeleteAlias — the reconcile pass deletes |
| +// unconditionally from its own view of what is no longer desired. |
| +func (r *Registry) DeletePath(host, prefix string) error { |
| + _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix) |
| + return err |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Add the fingerprint section** |
| + |
| +In `Fingerprint()`, after the `wildcard_domains` section and before the final digest is returned: |
| + |
| +```go |
| + // route_paths: which prefix on which hostname reaches which route is a |
| + // routing decision in exactly the sense route_aliases' alias/host is, so |
| + // it hashes here or a flip never reaches the live map. All five columns |
| + // hash — unlike route_aliases there is no box-stamped, cadence-driven |
| + // column on this table to exclude, so there is nothing here that could |
| + // force a pointless fleet-wide reload. |
| + fmt.Fprint(h, "\x1droute_paths\x1e") |
| + prows, err := db.Query(`SELECT host, prefix, target, account, app |
| +FROM route_paths ORDER BY host, prefix`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer prows.Close() |
| + for prows.Next() { |
| + var host, prefix, target, account, app string |
| + if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app) |
| + } |
| + if err := prows.Err(); err != nil { |
| + return "", err |
| + } |
| +``` |
| + |
| +Also extend the `Fingerprint` doc comment, after the `wildcard_domains` paragraph: |
| + |
| +```go |
| +// route_paths is hashed for the same reload-latency reason, and it is the |
| +// section most worth stating explicitly: a path flip is the ONLY routing |
| +// change in the system whose whole observable effect is in this table. A |
| +// missing section here would not degrade a flip, it would make flips do |
| +// nothing at all — reconciled correctly, stored correctly, never served — |
| +// until some unrelated route write happened to move the digest. |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/registry/ -count=1` |
| +Expected: PASS, including the four new tests and every existing registry test. |
| + |
| +- [ ] **Step 7: Verify the whole build is clean** |
| + |
| +Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| +Expected: no output from `gofmt -l .`, all tests pass. Zero `route_paths` rows exist anywhere, so nothing else can have changed behaviour. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/registry/registry.go internal/registry/paths_test.go |
| +git commit -m "registry: route_paths table, accessors, and fingerprint section |
| + |
| +A hostname's routes table row is its identity — cert, home box, DNS state, |
| +aliases — so per-prefix routing lives in its own pass-owned table keyed on |
| +(host, prefix), mirroring route_aliases. |
| + |
| +The fingerprint section is the load-bearing half: a path flip's entire |
| +observable effect is in this table, so without it flips would reconcile |
| +correctly and never be served. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `Claim.Paths` and `SetClaimPaths` |
| + |
| +**Files:** |
| +- Modify: `internal/domains/claims.go` (`Claim` struct ~line 34; new `SetClaimPaths` after `SetClaimCapabilities` ~line 317) |
| +- Test: `internal/domains/paths_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from Task 1 (independent; may be built in parallel). |
| +- Produces: `Claim.Paths map[string]string`; `domains.SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error)`; `domains.ErrTooManyPaths`; `domains.MaxPathsPerClaim`. Task 3 reads `Claim.Paths`; Task 5 calls `SetClaimPaths`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/domains/paths_test.go`. Use whatever in-memory `release.Store` fake the existing `internal/domains` tests use — check `internal/domains/claims_test.go` for its constructor and reuse it verbatim rather than writing a second one. |
| + |
| +```go |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +// TestSetClaimPathsRoundTrip: the map written is the map read back. |
| +func TestSetClaimPathsRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) // see claims_test.go for the existing fake |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !changed { |
| + t.Fatal("changed = false on the first write of a paths map") |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito |
| +// regenerates the whole map from its own database, so an account it no longer |
| +// lists must disappear — that is how a rollback reaches the edge. |
| +func TestSetClaimPathsIsWholesale(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, still := c.Paths["bigcorp"]; still { |
| + t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account |
| +// is across, Tito repoints the host's own route and publishes an empty map. |
| +func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.Paths) != 0 { |
| + t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets |
| +// the same neutral ErrHostUnavailable the attach door uses — no existence |
| +// oracle, byte-identical to the answer for a claim that does not exist. |
| +func TestSetClaimPathsIsOwnerGated(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic", |
| + map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic", |
| + map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not |
| +// burn a bucket write. Tito republishes wholesale as a repair, so the no-op |
| +// case is the common one. |
| +func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := map[string]string{"paulca": "go.tito.io"} |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed { |
| + t.Fatal("changed = true for an identical republish — a no-op must not write") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an |
| +// oversize map is refused whole rather than silently truncated into the |
| +// bucket. (The reconcile pass caps again, defensively, for hand-written |
| +// claims — see Task 3.) |
| +func TestSetClaimPathsRefusesOversizeMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + big := make(map[string]string, MaxPathsPerClaim+1) |
| + for i := 0; i <= MaxPathsPerClaim; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) { |
| + t.Fatalf("err = %v, want ErrTooManyPaths", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A |
| +// leading slash, an embedded slash, or an empty key would each produce a row |
| +// the edge could never match, so they are refused at the door rather than |
| +// stored as dead weight. |
| +func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} { |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) { |
| + t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/domains/ -run TestSetClaimPaths -count=1` |
| +Expected: FAIL — `undefined: SetClaimPaths`, `undefined: MaxPathsPerClaim`. |
| + |
| +- [ ] **Step 3: Add the `Paths` field to `Claim`** |
| + |
| +In `internal/domains/claims.go`, inside the `Claim` struct, after `Catchall`: |
| + |
| +```go |
| + // Paths splits this hostname's traffic by leading URL segment: a bare |
| + // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the |
| + // route that serves it. Absent or empty means this host routes by |
| + // hostname alone — the behaviour of every host today, and the reason this |
| + // field is additive in the strongest sense: no row, no map, no change. |
| + // |
| + // This is a THIRD claim capability alongside Wildcard/Catchall, and it is |
| + // declared here for the reason stated above them — converge preserves only |
| + // DECLARED fields, so an undeclared field would be stripped by any binary |
| + // that rewrote the claim. |
| + // |
| + // The writer is the tenant's own platform, publishing the whole map |
| + // regenerated from its own database. Deltas were rejected deliberately: a |
| + // lost delta desyncs the two systems silently, whereas a wholesale |
| + // republish is always a full repair. |
| + Paths map[string]string `json:"paths,omitempty"` |
| +``` |
| + |
| +- [ ] **Step 4: Add the errors, the cap, and `SetClaimPaths`** |
| + |
| +In `internal/domains/claims.go`, after `SetClaimCapabilities`: |
| + |
| +```go |
| +// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains |
| +// this defends no external budget — there is no ACME equivalent for a path |
| +// row — it bounds the claim object's size and the per-box fold that reads it |
| +// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB |
| +// object refolded every couple of seconds, which is comfortable; sharding a |
| +// host's map across objects is deliberately not built until a real host |
| +// approaches this number. |
| +const MaxPathsPerClaim = 10000 |
| + |
| +// ErrTooManyPaths is returned by SetClaimPaths for a map above |
| +// MaxPathsPerClaim. Refused whole: a silently truncated map would route a |
| +// subset of accounts and look successful. |
| +var ErrTooManyPaths = errors.New("too many path prefixes") |
| + |
| +// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare |
| +// single path segment. |
| +var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment") |
| + |
| +// 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 |
| +// a first segment could never match and must not be stored. |
| +func validPathPrefix(p string) bool { |
| + if p == "" || len(p) > 255 { |
| + return false |
| + } |
| + for i := 0; i < len(p); i++ { |
| + c := p[i] |
| + switch { |
| + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': |
| + case c == '-' || c == '_' || c == '.' || c == '~': |
| + default: |
| + return false |
| + } |
| + } |
| + // "." and ".." would be normalised away by any path cleaner between the |
| + // browser and here, so a row keyed on either is unreachable by |
| + // construction. |
| + return p != "." && p != ".." |
| +} |
| + |
| +// SetClaimPaths is the ONLY writer of a claim's Paths field, the same |
| +// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall |
| +// and for the same reason: AttachClaim's converge repoints Route and nothing |
| +// else, so it can preserve this map but must never invent or strip it. |
| +// |
| +// The write is WHOLESALE — paths replaces the stored map rather than merging |
| +// into it. That is the contract the caller's platform relies on: it |
| +// regenerates the map from its own database and publishes the result, so an |
| +// entry it no longer lists must disappear. Merging would make rollback |
| +// impossible to express. |
| +// |
| +// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold |
| +// the claim gets the neutral ErrHostUnavailable, and a missing claim answers |
| +// identically, so this door is not an existence oracle. |
| +func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) { |
| + if len(paths) > MaxPathsPerClaim { |
| + return false, ErrTooManyPaths |
| + } |
| + for prefix, target := range paths { |
| + if !validPathPrefix(prefix) { |
| + return false, ErrBadPathPrefix |
| + } |
| + // The target must be a plausible hostname. Whether it is a route this |
| + // 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) |
| + } |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, ErrHostUnavailable |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != account || cur.App != app { |
| + return false, ErrHostUnavailable |
| + } |
| + if samePaths(cur.Paths, paths) { |
| + return false, nil |
| + } |
| + next := *cur |
| + if len(paths) == 0 { |
| + next.Paths = nil // omitempty: an empty map and no map are one state |
| + } else { |
| + next.Paths = make(map[string]string, len(paths)) |
| + for k, v := range paths { |
| + next.Paths[k] = v |
| + } |
| + } |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 { |
| + continue |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| + |
| +// samePaths reports whether two prefix maps are equal, treating nil and empty |
| +// as the same state. Republishing an unchanged map is the COMMON case — the |
| +// caller republishes wholesale as a repair — so this is what keeps a repair |
| +// from costing a bucket write and a fleet-wide edge reload. |
| +func samePaths(a, b map[string]string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k, v := range a { |
| + if b[k] != v { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +Confirm `fmt` is imported in `claims.go` (it is — `getClaimETag` uses it). |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/domains/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Verify converge round-trips the new field** |
| + |
| +`AttachClaim`'s converge rewrites a claim; the `Paths` map must survive it. Confirm by reading `AttachClaim`'s converge branch (`internal/domains/claims.go:105`) — it constructs its next value from the current claim, so a declared field is preserved. If it instead builds a fresh `Claim` literal, add `Paths` to that literal and add this test: |
| + |
| +```go |
| +// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map. |
| +func TestAttachConvergePreservesPaths(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths) |
| + } |
| +} |
| +``` |
| + |
| +Run: `go test ./internal/domains/ -count=1` — PASS. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/domains/claims.go internal/domains/paths_test.go |
| +git commit -m "domains: claims carry a prefix map, written only by SetClaimPaths |
| + |
| +domains/<host>.json is already one CAS-written object per hostname carrying |
| +account and app — exactly the granularity and write semantics a per-host |
| +prefix map needs, so path routing is a third claim capability rather than a |
| +new control-plane object type. |
| + |
| +Writes are wholesale: the tenant's platform regenerates the map from its own |
| +database and publishes the result, so a republish is always a full repair and |
| +a lost update can never desync the two systems. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Derive `route_paths` in the reconcile pass |
| + |
| +**Files:** |
| +- Modify: `internal/domains/aliases.go` (`ReconcileAliases`, line 48; the desired-set loop and the pass-owned deletion tail ~line 190-222) |
| +- Test: `internal/domains/aliases_paths_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.RoutePath`, `(*Registry).ListPaths/UpsertPath/DeletePath` (Task 1); `Claim.Paths`, `MaxPathsPerClaim` (Task 2). |
| +- Produces: no new exported symbols. `route_paths` rows now exist in the registry for Task 4 to read. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/domains/aliases_paths_test.go`. Reuse the store fake and the registry helper the existing `internal/domains` alias tests use — read `internal/domains/aliases_test.go` and copy its setup verbatim. |
| + |
| +```go |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "strconv" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// pathsByPrefix collapses ListPaths into a prefix -> target map for one host. |
| +func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string { |
| + t.Helper() |
| + rows, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for _, p := range rows { |
| + if p.Host == host { |
| + out[p.Prefix] = p.Target |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes |
| +// route_paths rows. |
| +func TestReconcileDerivesPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "ti.to", "tito", "classic") |
| + mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| + map[string]string{"paulca": "go.tito.io"}) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got) |
| + } |
| +} |
| + |
| +// TestReconcileRemovesUndesiredPathRows is the rollback path and the |
| +// pass-ownership property: a prefix dropped from the published map disappears |
| +// from the table on the next pass, with no delete verb anywhere. |
| +func TestReconcileRemovesUndesiredPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "ti.to", "tito", "classic") |
| + mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| + map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}) |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := pathsByPrefix(t, reg, "ti.to") |
| + if _, still := got["bigcorp"]; still { |
| + t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got) |
| + } |
| + if got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca still routed", got) |
| + } |
| +} |
| + |
| +// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization |
| +// rule in this feature: a prefix must never point another tenant's traffic at |
| +// your backend. |
| +func TestReconcileRefusesCrossTenantTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "ti.to", "tito", "classic") |
| + mustRoute(t, reg, "victim.example", "someone-else", "their-app") |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| + map[string]string{"paulca": "victim.example"}) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsUnknownTarget: a target that is not a local route yields |
| +// no row, so the prefix falls through to the host's own route. That is the |
| +// correct failure direction for a migration — a missing Go route serves |
| +// classic rather than 404ing. |
| +func TestReconcileSkipsUnknownTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "ti.to", "tito", "classic") |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| + map[string]string{"paulca": "not-deployed-here.example"}) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to |
| +// the hostname's own route. If that route lives on another box, this box |
| +// derives nothing — the same per-box rule the alias half already follows. |
| +func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", |
| + map[string]string{"paulca": "go.tito.io"}) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got) |
| + } |
| +} |
| + |
| +// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated |
| +// deterministically (sorted key order), never silently and never at random. |
| +func TestReconcileCapsPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := newTestStore(t) |
| + reg := newTestRegistry(t) |
| + mustRoute(t, reg, "ti.to", "tito", "classic") |
| + mustRoute(t, reg, "go.tito.io", "tito", "classic") |
| + big := make(map[string]string, MaxPathsPerClaim+10) |
| + for i := 0; i < MaxPathsPerClaim+10; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + mustClaimWithPaths(t, ctx, s, "ti.to", "tito", "classic", "ti.to", big) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim { |
| + t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim) |
| + } |
| +} |
| +``` |
| + |
| +Add these helpers to the same file (adapt the route/claim shapes to whatever the existing alias tests use): |
| + |
| +```go |
| +func mustRoute(t *testing.T, reg *registry.Registry, host, account, app string) { |
| + t.Helper() |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "service", Addr: "127.0.0.1:1", Status: "ready", |
| + Account: account, App: app, |
| + }); err != nil { |
| + t.Fatalf("upsert route %s: %v", host, err) |
| + } |
| +} |
| + |
| +func mustClaimWithPaths(t *testing.T, ctx context.Context, s release.Store, |
| + host, account, app, route string, paths map[string]string) { |
| + t.Helper() |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: host, Account: account, App: app, Route: route, |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatalf("attach claim %s: %v", host, err) |
| + } |
| + // Written directly rather than through SetClaimPaths so the cap test can |
| + // stage an oversize map the door would refuse. |
| + cur, etag, err := getClaimETag(ctx, s, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cur.Paths = paths |
| + body, err := json.Marshal(cur) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(host), body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +Import `encoding/json` and `github.com/carlosframework/platform/internal/release` in the test file. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/domains/ -run TestReconcile -count=1` |
| +Expected: FAIL — the new tests find zero derived rows. |
| + |
| +- [ ] **Step 3: Derive the desired path set** |
| + |
| +In `internal/domains/aliases.go`, add `"sort"` to the imports. Alongside `desired` and `desiredWild` near the top of `ReconcileAliases`, add: |
| + |
| +```go |
| + desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| +``` |
| + |
| +Then inside the `for _, c := range claims` loop, after the wildcard derivation, add: |
| + |
| +```go |
| + // Path derivation (prefix routing). A claim's Paths map splits its own |
| + // hostname's traffic by leading URL segment; each entry names the |
| + // hostname of the route that serves that segment. |
| + // |
| + // Gated on the claim's host being a LOCAL route whose tenancy matches |
| + // the claim — the same per-box rule the alias half above follows, and |
| + // for the same reason: the prefix map belongs to that route, so a box |
| + // that does not serve the route derives nothing. |
| + if len(c.Paths) > 0 { |
| + hostRt, ok := byHost[c.Host] |
| + if !ok { |
| + // The hostname's own route is not on this box; another box |
| + // derives these rows. |
| + } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| + log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s", |
| + c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App) |
| + } else { |
| + // Sorted so the cap below truncates DETERMINISTICALLY: every |
| + // box must derive the identical subset, or the same URL would |
| + // resolve differently depending on which edge answered. |
| + prefixes := make([]string, 0, len(c.Paths)) |
| + for p := range c.Paths { |
| + prefixes = append(prefixes, p) |
| + } |
| + sort.Strings(prefixes) |
| + if len(prefixes) > MaxPathsPerClaim { |
| + log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order", |
| + c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim) |
| + prefixes = prefixes[:MaxPathsPerClaim] |
| + } |
| + for _, prefix := range prefixes { |
| + target := c.Paths[prefix] |
| + if !validPathPrefix(prefix) { |
| + log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix) |
| + continue |
| + } |
| + tgtRt, ok := byHost[target] |
| + if !ok { |
| + // Target is not a route on this box. No row, so the |
| + // prefix falls through to the host's own route — the |
| + // right failure direction for a migration: a missing |
| + // destination serves the ORIGIN, never a 404. |
| + continue |
| + } |
| + // The authorization rule this feature adds: a prefix must |
| + // never point another tenant's traffic at your backend. |
| + // Account only — an account routing between its own apps |
| + // (classic and Go ARE two apps) is the whole use case. |
| + if release.RouteAccount(tgtRt) != c.Account { |
| + log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s", |
| + c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account) |
| + continue |
| + } |
| + desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{ |
| + Host: c.Host, Prefix: prefix, Target: target, |
| + Account: c.Account, App: c.App, |
| + } |
| + } |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Write and prune the table** |
| + |
| +At the end of `ReconcileAliases`, after the `wildcard_domains` deletion loop and before `return nil`: |
| + |
| +```go |
| + // route_paths is wholly pass-owned, like route_aliases and |
| + // wildcard_domains: every row traces to a claim's Paths entry, so a |
| + // prefix removed from a republished map disappears here with no delete |
| + // verb anywhere. That is exactly how a per-account rollback reaches the |
| + // edge. |
| + existingPaths, err := reg.ListPaths() |
| + if err != nil { |
| + return err |
| + } |
| + for _, p := range desiredPaths { |
| + if err := reg.UpsertPath(p); err != nil { |
| + log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + for _, p := range existingPaths { |
| + if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok { |
| + if err := reg.DeletePath(p.Host, p.Prefix); err != nil { |
| + log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + } |
| + return nil |
| +``` |
| + |
| +`UpsertPath` is unconditional rather than diffed-first (the shape the wildcard half uses) because the row has five short columns and no stamps to preserve — a redundant write costs one statement and cannot lose state. `Fingerprint` hashes content, not write counts, so this does not cause spurious reloads. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/domains/ -count=1` |
| +Expected: PASS, including every existing alias and wildcard test — the pass's existing behaviour must be untouched. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/domains/aliases.go internal/domains/aliases_paths_test.go |
| +git commit -m "domains: derive route_paths from claims, gated on same-account targets |
| + |
| +Extends the pass that already materializes route_aliases and |
| +wildcard_domains, keeping the same wholly-pass-owned property: a prefix |
| +dropped from a republished map disappears on the next tick, so rollback needs |
| +no delete verb. |
| + |
| +Adds one authorization rule — a prefix's target must belong to the claim's |
| +account — so a prefix can never point another tenant's traffic at your |
| +backend. A target that is not a local route yields no row, so the prefix falls |
| +through to the hostname's own route: a missing destination serves the origin, |
| +never a 404. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The edge resolves prefixes |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` (`upstream` struct line 244; `Reload`'s alias fold ~line 591-606; `lookupUpstream` line 866; `ServeHTTP` dispatch line 930) |
| +- Test: `internal/edge/path_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.RoutePath`, `(*Registry).ListPaths` (Task 1); `route_paths` rows (Task 3). |
| +- Produces: `upstream.paths map[string]string`; `(*Edge).lookupUpstreamPath(host, path string) (upstream, bool)`; `lookupIn(m map[string]upstream, host string) (upstream, bool)`. Nothing outside `internal/edge` consumes these. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/edge/path_test.go`: |
| + |
| +```go |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// upsertPath writes one route_paths row, the shape the domains reconcile pass |
| +// derives from a claim's Paths map. |
| +func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) { |
| + t.Helper() |
| + if err := reg.UpsertPath(registry.RoutePath{ |
| + Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app", |
| + }); err != nil { |
| + t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err) |
| + } |
| +} |
| + |
| +// splitHost stands up one hostname whose default route is "classic" and whose |
| +// "flipped" prefix reaches a second route, returning the live test server. |
| +func splitHost(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the |
| +// other route's backend while the same hostname's other traffic does not. |
| +func TestPathRouteReachesTarget(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, tc := range []struct{ path, want string }{ |
| + {"/flipped", "go"}, |
| + {"/flipped/awesomeconf", "go"}, |
| + {"/flipped/awesomeconf/admin", "go"}, |
| + {"/notflipped", "classic"}, |
| + {"/notflipped/event", "classic"}, |
| + {"/", "classic"}, |
| + {"", "classic"}, |
| + } { |
| + code, body := getHostPath(t, srv.URL, "ti.to", tc.path) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", tc.path, code) |
| + } |
| + if body != tc.want { |
| + t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be |
| +// segment-wise equality, never a string prefix — "flippedxyz" is a different |
| +// account from "flipped" and must not be dragged across the migration with it. |
| +func TestPathMatchIsSegmentBounded(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} { |
| + code, body := getHostPath(t, srv.URL, "ti.to", p) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", p, code) |
| + } |
| + if body != "classic" { |
| + t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body) |
| + } |
| + } |
| +} |
| + |
| +// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The |
| +// flipped and unflipped URL spaces must be identical, which is the whole |
| +// point of a seamless migration. |
| +func TestPathRouteDoesNotStripThePrefix(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(r.URL.Path)) |
| + })) |
| + t.Cleanup(golive.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf") |
| + if body != "/paulca/awesomeconf" { |
| + t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body) |
| + } |
| +} |
| + |
| +// TestPathPreservesInboundHost: the upstream sees the hostname the browser |
| +// asked for, matching the alias contract. Tito Go must be able to build |
| +// absolute URLs that keep the visitor on ti.to. |
| +func TestPathPreservesInboundHost(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, seenHost := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if *seenHost != "ti.to" { |
| + t.Fatalf("upstream saw Host %q, want ti.to", *seenHost) |
| + } |
| +} |
| + |
| +// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the |
| +// live map must serve the hostname's own route, never 404. This is the |
| +// failure direction the whole design depends on. |
| +func TestPathRowForMissingTargetFallsThrough(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "vanished.example") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + code, body := getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if code != http.StatusOK || body != "classic" { |
| + t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body) |
| + } |
| +} |
| + |
| +// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on |
| +// the platform today has no prefix map and must behave exactly as before. |
| +func TestHostsWithoutPathsAreUnaffected(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "plain") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + for _, p := range []string{"/", "/anything", "/deep/path/here"} { |
| + code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p) |
| + if code != http.StatusOK || body != "plain" { |
| + t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body) |
| + } |
| + } |
| +} |
| + |
| +// TestFirstSegment is the matcher itself, tested directly because every |
| +// routing decision in this feature rests on it. |
| +func TestFirstSegment(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {"/paulca", "paulca"}, |
| + {"/paulca/", "paulca"}, |
| + {"/paulca/awesomeconf", "paulca"}, |
| + {"/paulca/awesomeconf/admin", "paulca"}, |
| + {"/", ""}, |
| + {"", ""}, |
| + {"paulca", ""}, // not an absolute path; never matches |
| + {"//", ""}, |
| + } { |
| + if got := firstSegment(tc.in); got != tc.want { |
| + t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add the request helper to the same file if `getHost` in `alias_test.go` does not already take a path — check first and reuse it if it does: |
| + |
| +```go |
| +// getHostPath issues GET path against srv with an explicit Host header. |
| +func getHostPath(t *testing.T, srvURL, host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", srvURL+path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return resp.StatusCode, string(b) |
| +} |
| +``` |
| + |
| +(Import `io`.) |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/edge/ -run 'TestPath|TestFirstSegment|TestHostsWithoutPaths' -count=1` |
| +Expected: FAIL — `undefined: firstSegment`, and the routing tests serve "classic" for `/flipped`. |
| + |
| +- [ ] **Step 3: Add the `paths` field to `upstream`** |
| + |
| +In `internal/edge/edge.go`, inside the `upstream` struct after `alias`: |
| + |
| +```go |
| + // paths, when non-nil, splits this hostname's traffic by leading URL |
| + // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the |
| + // route that serves it, resolved against the same live map at request |
| + // time. Nil for every host that does not use prefix routing, which is |
| + // every host on the platform until a claim publishes a map — the nil |
| + // check in lookupUpstreamPath is the entire cost this feature imposes on |
| + // everyone else. |
| + // |
| + // Hostnames rather than resolved upstreams, deliberately: an upstream |
| + // captured here would be a stale copy of the target's entry the moment |
| + // the target's route changed within the same map build. |
| + paths map[string]string |
| +``` |
| + |
| +- [ ] **Step 4: Fold `route_paths` into `Reload`** |
| + |
| +In `Reload`, immediately BEFORE the `aliases, err := e.reg.ListAliases()` block (line 591): |
| + |
| +```go |
| + // Path routes, folded BEFORE aliases so an alias of a prefix-routed |
| + // hostname inherits its map along with everything else it copies — an |
| + // alias is meant to be indistinguishable from its primary, and a |
| + // hostname that routed by prefix under one name but not the other would |
| + // be a trap. |
| + // |
| + // Targets are recorded as hostnames, not resolved here: every route is |
| + // already in m by this point, but resolving now would freeze a copy of |
| + // the target's entry, and the target's own row may still be rewritten by |
| + // the alias fold below. |
| + paths, err := e.reg.ListPaths() |
| + if err != nil { |
| + return err |
| + } |
| + for _, p := range paths { |
| + up, ok := m[p.Host] |
| + if !ok { |
| + // The hostname has no route on this box. The reconcile pass |
| + // should not have produced this row; nothing to attach it to. |
| + continue |
| + } |
| + if up.paths == nil { |
| + up.paths = make(map[string]string) |
| + } |
| + up.paths[p.Prefix] = p.Target |
| + m[p.Host] = up |
| + } |
| +``` |
| + |
| +- [ ] **Step 5: Split the lookup and add the path-aware form** |
| + |
| +Replace `lookupUpstream` (line 866) with the pair below. The existing doc comment above it stays exactly as it is — it describes `lookupIn`'s wildcard rule, which is unchanged — so move it onto `lookupIn`. |
| + |
| +```go |
| +func lookupIn(m map[string]upstream, host string) (upstream, bool) { |
| + if up, ok := m[host]; ok { |
| + return up, true |
| + } |
| + if i := strings.IndexByte(host, '.'); i > 0 { |
| + if up, ok := m["*"+host[i:]]; ok { |
| + return up, true |
| + } |
| + } |
| + return upstream{}, false |
| +} |
| + |
| +// lookupUpstream resolves a hostname alone. Kept as the primitive because not |
| +// every caller is answering "where does this REQUEST go" — the routability |
| +// check in the ACME/HostPolicy path asks only whether this box serves the |
| +// hostname at all, and giving that question a path would make a hostname's |
| +// routability depend on which URL happened to ask. |
| +func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| + return lookupIn(e.current(), host) |
| +} |
| + |
| +// lookupUpstreamPath resolves a request: the hostname exactly as |
| +// lookupUpstream does, then — only for a hostname carrying a prefix map — the |
| +// first path segment. |
| +// |
| +// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then |
| +// current() again would let a reload land between them and resolve a prefix |
| +// against a different table than the host came from. |
| +// |
| +// Every miss falls through to the hostname's own route rather than failing: |
| +// an unlisted prefix, a dangling target, a request for "/". A prefix map is a |
| +// migration in progress, and the un-migrated answer is always the safe one. |
| +func (e *Edge) lookupUpstreamPath(host, path string) (upstream, bool) { |
| + m := e.current() |
| + up, ok := lookupIn(m, host) |
| + if !ok || up.paths == nil { |
| + return up, ok |
| + } |
| + seg := firstSegment(path) |
| + if seg == "" { |
| + return up, true |
| + } |
| + target, ok := up.paths[seg] |
| + if !ok { |
| + return up, true |
| + } |
| + tu, ok := m[target] |
| + if !ok { |
| + return up, true |
| + } |
| + return tu, true |
| +} |
| + |
| +// firstSegment returns the first path segment of an absolute URL path, |
| +// without slashes: "/paulca/awesomeconf" -> "paulca". "" for "/", for the |
| +// empty string, and for anything not starting with "/". |
| +// |
| +// Returning the segment rather than testing a string prefix is what makes |
| +// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not |
| +// equal to "paulca", so a longer account name can never be dragged across a |
| +// migration by a shorter one. |
| +func firstSegment(p string) string { |
| + if p == "" || p[0] != '/' { |
| + return "" |
| + } |
| + p = p[1:] |
| + if i := strings.IndexByte(p, '/'); i >= 0 { |
| + p = p[:i] |
| + } |
| + return p |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Use the path-aware form in `ServeHTTP`** |
| + |
| +At `internal/edge/edge.go:930`, change: |
| + |
| +```go |
| + up, ok := e.lookupUpstream(host) |
| +``` |
| + |
| +to: |
| + |
| +```go |
| + up, ok := e.lookupUpstreamPath(host, r.URL.Path) |
| +``` |
| + |
| +Leave the call at `edge.go:1455` alone — that is the routability check the doc comment above `lookupUpstream` describes. |
| + |
| +Do not move this call within `ServeHTTP`. Its position after `acceptHop` is load-bearing: a backhauled request must resolve its prefix exactly once, on the box that ultimately serves it. |
| + |
| +- [ ] **Step 7: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/edge/ -count=1` |
| +Expected: PASS, including every existing edge test — especially the alias tests, which prove the alias fold still behaves. |
| + |
| +- [ ] **Step 8: Verify the whole build is clean** |
| + |
| +Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| +Expected: all clean. |
| + |
| +- [ ] **Step 9: Commit** |
| + |
| +```bash |
| +git add internal/edge/edge.go internal/edge/path_test.go |
| +git commit -m "edge: resolve a leading path segment for hosts that carry a prefix map |
| + |
| +Host resolution is unchanged and stays the primitive: the routability check |
| +in the cert path asks only whether this box serves a hostname, and must not |
| +gain a path. Request dispatch gets a path-aware wrapper that reads one |
| +snapshot of the map for both lookups. |
| + |
| +Matching is segment-wise equality, so /paulcarson is never dragged across a |
| +migration by /paulca, and the prefix is not stripped, so the flipped and |
| +unflipped URL spaces stay identical. Every miss — unlisted prefix, dangling |
| +target, bare / — falls through to the hostname's own route. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: The write door and the read surfaces |
| + |
| +**Files:** |
| +- Modify: `internal/console/apidomains.go` (new handler, alongside the capabilities handler ~line 482) |
| +- Modify: `internal/console/authcli.go` (route registration, ~line 624) |
| +- Modify: `routes_cli.go` (path rows in `carlos routes`) |
| +- Test: `internal/console/apidomainpaths_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: `domains.SetClaimPaths`, `domains.ErrTooManyPaths`, `domains.ErrBadPathPrefix`, `domains.MaxPathsPerClaim` (Task 2). |
| +- Produces: `PUT /api/cli/apps/{account}/{app}/domains/{host}/paths`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `internal/console/apidomainpaths_test.go`. Read `internal/console/apidomains_test.go` first and reuse its server/auth harness verbatim — the exact helper names differ by file and must not be guessed. |
| + |
| +Cover, using that harness: |
| + |
| +1. **`TestAPIDomainPathsPublishes`** — `PUT` with body `{"paths":{"paulca":"go.tito.io"}}` returns 200, and `domains.GetClaim` shows the map. |
| +2. **`TestAPIDomainPathsReplaces`** — a second `PUT` with `{"paths":{"bigcorp":"go.tito.io"}}` leaves `bigcorp` only; `paulca` is gone. |
| +3. **`TestAPIDomainPathsEmptyBodyClears`** — `{"paths":{}}` clears the map and returns 200. |
| +4. **`TestAPIDomainPathsRejectsOtherAccount`** — a principal scoped to another account gets the family's byte-identical 404, not a 403. |
| +5. **`TestAPIDomainPathsRejectsOversize`** — a map above `domains.MaxPathsPerClaim` returns 422. |
| +6. **`TestAPIDomainPathsRejectsBadPrefix`** — `{"paths":{"/paulca":"go.tito.io"}}` returns 422. |
| + |
| +Each asserts the status code and, where it applies, reads the claim back to prove the effect. |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run TestAPIDomainPaths -count=1` |
| +Expected: FAIL — 404 from an unregistered route. |
| + |
| +- [ ] **Step 3: Add the handler** |
| + |
| +In `internal/console/apidomains.go`, following the shape of the capabilities handler: |
| + |
| +```go |
| +// 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. |
| +func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| + ctx := r.Context() |
| + acct, app, ok := s.apiAppScope(w, r) // the family's existing scope helper |
| + if !ok { |
| + return |
| + } |
| + host := r.PathValue("host") |
| + |
| + var body struct { |
| + Paths map[string]string `json:"paths"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&body); err != nil { |
| + jsonError(w, http.StatusBadRequest, "could not parse the request body as JSON") |
| + return |
| + } |
| + |
| + changed, err := domains.SetClaimPaths(ctx, s.cfg.Releases, host, acct.Sqid, app, body.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.ErrHostUnavailable) { |
| + // The family's byte-identical 404 discipline: a caller who does not |
| + // hold this claim learns nothing about whether it exists. |
| + 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 |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "host": host, |
| + "paths": body.Paths, |
| + "changed": changed, |
| + }) |
| +} |
| +``` |
| + |
| +Match `apiAppScope` and `writeJSON` to whatever the neighbouring handlers in this file actually call — read them and use the same names. The 8MB body cap is sized for the `MaxPathsPerClaim` ceiling with headroom. |
| + |
| +- [ ] **Step 4: Register the route** |
| + |
| +In `internal/console/authcli.go`, beside the other domain routes: |
| + |
| +```go |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish), s.handleAPIDomainPaths) |
| +``` |
| + |
| +`Publish` only, not `Instance`: publishing a prefix map is a deployment-shaped decision about where an account's traffic goes, and an instance-scoped token has no business making it. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Show path routes in `carlos routes`** |
| + |
| +In `routes_cli.go`, after each host's row, print its prefixes indented — read the file's existing table-building code and follow it exactly: |
| + |
| +```go |
| + // Path routes under their hostname: the flipped set has to be visible |
| + // from the command operators already use, or a migration is invisible |
| + // until something breaks. |
| + for _, p := range pathsFor(rt.Host) { |
| + fmt.Fprintf(tw, " /%s\t\t-> %s\t\t\t\t\n", p.Prefix, p.Target) |
| + } |
| +``` |
| + |
| +Source the rows from the same API response the command already reads; extend that response to carry them if it does not. Read-only — no write verb (see §8 of the spec). |
| + |
| +- [ ] **Step 7: Run the full suite** |
| + |
| +Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| +Expected: all clean. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/console/apidomains.go internal/console/authcli.go internal/console/apidomainpaths_test.go routes_cli.go |
| +git commit -m "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>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: End-to-end proof |
| + |
| +**Files:** |
| +- Test: `internal/edge/path_e2e_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Consumes: everything from Tasks 1-4. |
| +- Produces: nothing. This is the deliverable that says the machinery works. |
| + |
| +This is the acceptance bar from spec §10. With `ti.to` not moving, nothing else proves the pieces compose — each earlier task tested its own layer against a hand-written input. |
| + |
| +- [ ] **Step 1: Write the test** |
| + |
| +Create `internal/edge/path_e2e_test.go`: |
| + |
| +```go |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim |
| +// published, reconcile pass, fingerprint moves, edge reloads, traffic changes |
| +// — and then walks it backwards. Each earlier task proved one layer against a |
| +// hand-written input; this is the only test that proves they compose. |
| +// |
| +// The registry write and the edge reload are driven directly rather than |
| +// waited on: the 2s tick is a latency bound, not a correctness property, and |
| +// polling it would make this test slow and flaky. What must be true is that |
| +// the fingerprint MOVES, which is asserted rather than assumed. |
| +func TestPathFlipEndToEnd(t *testing.T) { |
| + ctx := context.Background() |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", Account: "tito", App: "classic", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", Account: "tito", App: "go", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + store := newTestStore(t) // the in-memory release.Store the domains tests use |
| + if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + // Before the flip: everything is classic. |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| + t.Fatalf("before the flip: body = %q, want classic", body) |
| + } |
| + |
| + fpBefore, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The flip: Tito publishes its map, the pass folds it, the edge reloads. |
| + if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + fpAfter, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fpBefore == fpAfter { |
| + t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload") |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // After the flip: that account is on Go, its neighbours are not. |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" { |
| + t.Fatalf("after the flip: body = %q, want go", body) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" { |
| + t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" { |
| + t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body) |
| + } |
| + |
| + // The rollback: republish without that account. |
| + if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| + t.Fatalf("after the rollback: body = %q, want classic", body) |
| + } |
| + rows, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 0 { |
| + t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows)) |
| + } |
| +} |
| +``` |
| + |
| +If `internal/edge` cannot import `internal/domains` without a cycle, put this test in a new `internal/pathrouting/e2e_test.go` package that imports both. Check with `go build ./...` before assuming either way. |
| + |
| +- [ ] **Step 2: Run it** |
| + |
| +Run: `go test ./internal/edge/ -run TestPathFlipEndToEnd -count=1 -v` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 3: Run the full suite** |
| + |
| +Run: `go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1` |
| +Expected: all clean. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add internal/edge/path_e2e_test.go |
| +git commit -m "test: end-to-end path flip and rollback |
| + |
| +Walks claim -> reconcile -> fingerprint -> reload -> traffic, then walks it |
| +back. With ti.to not moving behind the edge, this is the only thing that |
| +proves the four layers compose rather than each working against a |
| +hand-written input. |
| + |
| +Asserts the fingerprint actually moves: a flip that reconciles correctly and |
| +never reaches the live map is the failure this feature is most exposed to. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 5: Open the PR** |
| + |
| +```bash |
| +git push -u origin HEAD |
| +``` |
| + |
| +Then open a PR titled **"Path-based routing: one hostname, many upstreams"** whose body summarises the six tasks, links the spec, and carries the spec's §11 live checks as an explicit "not done, not claimed" list. |
| + |
| +Note: at time of writing this checkout has only `origin` → amadan.net, with no `amadan` remote and no GitHub remote configured, so the PR may need a remote added first. Per AGENTS.md, CI runs on a push to `amadan` — a branch pushed only to `origin` has been gated by nothing. |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**Spec coverage** |
| + |
| +| Spec section | Task | |
| +|---|---| |
| +| §2 prefix selects a route; bare segments | 1 (table), 4 (lookup) | |
| +| §3 `Paths` claim field, wholesale publish, cap | 2 | |
| +| §4 `route_paths`, pass-owned, tenancy gates, `Slug` untouched | 1, 3 | |
| +| §5 `Fingerprint()` section + a test that the digest moves | 1 (unit), 6 (e2e) | |
| +| §6 `lookupUpstream(host, path)`, no stripping, segment bounds, three call sites | 4 | |
| +| §7 write door, `SetClaimPaths` single writer | 2, 5 | |
| +| §8 read-only surfaces | 5 | |
| +| §9 exclusions | Nothing implements them, by construction | |
| +| §10 rollout stages 1-5 | Tasks 1-6 | |
| +| §11 live checks | Carried into the PR body, not implemented | |
| + |
| +Spec §4's ruling that `Route.Slug` stays untouched is honoured by no task mentioning it. |
| + |
| +**Type consistency** |
| + |
| +`RoutePath{Host, Prefix, Target, Account, App}` is used identically in Tasks 1, 3 and 4. `ListPaths`/`UpsertPath`/`DeletePath` keep their Task 1 signatures throughout. `SetClaimPaths(ctx, s, host, account, app, paths)` is called with that argument order in Tasks 2, 5 and 6. `firstSegment`, `lookupIn` and `lookupUpstreamPath` are defined once in Task 4 and used only there. `MaxPathsPerClaim` is the exported spelling everywhere (Tasks 2, 3, 5) — the spec's prose says `maxPathsPerClaim`, and exported wins because Task 5's console error message needs it across a package boundary. |
| + |
| +**Known soft spots, called out rather than papered over** |
| + |
| +- Task 5's tests and handler depend on `internal/console` helper names (`apiAppScope`, `writeJSON`) that must be read from the file rather than trusted from this plan. |
| +- Task 6 may hit an import cycle; the fallback package is named in the step. |
| +- Task 5 Step 6 needs `carlos routes`' existing response shape extended; the plan says to follow the file rather than prescribing a diff, because that command's plumbing was not read while writing this. |
diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md| new file mode 100644 |
| index 000000000..ea44193e0 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md |
| @@ -0,0 +1,597 @@ |
| +# Path-based routing — one hostname, many upstreams |
| + |
| +**Date:** 2026-09-02 · **Status:** draft, awaiting review · **Requested by:** Paul |
| + |
| +**Builds on:** #151 (the global claim, `route_aliases`, the `domains` door), |
| +2026-08-27-managed-custom-domains-design.md (the claim → pass-owned-table |
| +idiom this spec extends), 2026-08-20-multi-region-ingress-design.md (the 2s |
| +fingerprint poll this spec must not bypass). |
| + |
| +## 0. How this spec was arrived at |
| + |
| +Four rounds of clarification with Paul. The scope rulings that shaped it: |
| + |
| +- **No Rust rewrite.** The router question that started this is dead. The |
| + edge is not CPU-bound — a map lookup and an `io.Copy` per request — and the |
| + 32k lines in `internal/edge` are certsync, backhaul, tunnels, hibernation |
| + and purge, not proxy plumbing. A Rust edge would split one Go binary into |
| + two build systems, two artifacts and two release channels to buy nothing |
| + measurable. Path routing is a trie lookup layered on a map lookup; in Go |
| + the question dissolves. |
| +- **Tito's platform owns the flip, not CARLOS.** Paul: *"i think the 'flip' |
| + would happen on the tito platform side, no?"* Correct, and it is the |
| + load-bearing ruling in this document. Tito knows which accounts are |
| + migrated. CARLOS must not learn Tito's account model. CARLOS supplies a |
| + routing primitive and a write door; Tito is the writer and the source of |
| + truth. |
| +- **`ti.to` is NOT moving behind the CARLOS edge yet.** Paul: *"ti.to behind |
| + CARLOS edge is not something I'm ready for, but I'm happy to deploy the |
| + machinery."* So this spec builds and proves the machinery with no |
| + production consumer on day one. §10 is how that is proven honestly rather |
| + than by assertion. |
| +- **A route per prefix** as the routing model (Paul's explicit selection), |
| + refined by §2 into a prefix that *selects* a route rather than defining one. |
| + |
| +--- |
| + |
| +## 1. The two cases, and why only one needs code |
| + |
| +**Case 1 — Tito classic on `ti.to`, path-based.** Today `ti.to/paulca` is the |
| +account page, `ti.to/paulca/awesomeconf` the event, and |
| +`ti.to/paulca/awesomeconf/admin` the admin. The first path segment is the |
| +account, and it is the flip discriminator. Flipping one account must move |
| +that account's whole subtree to Tito Go, reversibly, without touching the |
| +other accounts. **This needs new code.** |
| + |
| +**Case 2 — Tito Pro on `*.tito.page`, host-based.** Pro customers each get a |
| +CNAME like `deutsche-filmakademie.tito.page`. Flipping one means pointing |
| +that hostname at Tito Go. |
| + |
| +Case 2 needs **no new routing code**. `lookupUpstream` (edge.go:866) tries an |
| +exact host match before the one-label wildcard fallback, and that ordering is |
| +a documented guarantee, not an accident: |
| + |
| +```go |
| +func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| + m := e.current() |
| + if up, ok := m[host]; ok { // exact |
| + return up, true |
| + } |
| + if i := strings.IndexByte(host, '.'); i > 0 { |
| + if up, ok := m["*"+host[i:]]; ok { // one-label wildcard |
| + return up, true |
| + } |
| + } |
| + return upstream{}, false |
| +} |
| +``` |
| + |
| +So a catch-all `*.tito.page` row pointing at classic, plus an exact |
| +`deutsche-filmakademie.tito.page` row pointing at Go, gives a per-customer |
| +flip today. Rollback is deleting the exact row. What is *not* yet proven is |
| +the operational path to that state — see §11. |
| + |
| +The rest of this spec is case 1. |
| + |
| +--- |
| + |
| +## 2. The model: a prefix selects a route, it does not define one |
| + |
| +The obvious design gives each prefix its own upstream, channel and |
| +environment. That is wrong: it duplicates a route's entire configuration per |
| +account, and Tito already has both backends as real routes. |
| + |
| +Instead, mirror `route_aliases`, which already works exactly this way — an |
| +alias is a *pointer* to a primary host, and the primary supplies everything |
| +else. A path route is that same pointer with a prefix attached: |
| + |
| +``` |
| +route_paths(host, prefix) -> target_host |
| + |
| + ti.to paulca -> go.tito.io |
| + ti.to bigcorp -> go.tito.io |
| + ti.to (default) -> ti.to's own route -> classic |
| +``` |
| + |
| +A prefix is stored as a **bare segment, no leading slash** — `paulca`, not |
| +`/paulca` — on the wire, in the table and in the lookup, so there is exactly |
| +one spelling and no normalisation step to get wrong. Surfaces may render it |
| +with a slash for readability; nothing else adds one. |
| + |
| +That is a statement about the **stored prefix**, and only about it. The |
| +**request path** is attacker-shaped and arrives in whatever spelling a client |
| +chooses, so the matcher does normalise it — see §6, which describes what it |
| +normalises and why one canonical form is not enough. The two are separate |
| +problems: one spelling on the write side, several on the read side. |
| + |
| +`go.tito.io` is already a route carrying its own channel, environment, health |
| +probe, hibernation policy and usage metering. The row that points at it |
| +carries no upstream, no channel and no environment of its own. Nothing is |
| +duplicated, and every existing subsystem keeps working because the thing |
| +being selected is an ordinary route. |
| + |
| +This also buys something that was not asked for and costs nothing: |
| +**canarying a subset of accounts.** Point most flipped accounts at the stable |
| +Go route and a handful at a second Go route bound to a beta channel. It falls |
| +out of the model; there is no special case for it. |
| + |
| +**Migration progress is the row count.** `route_paths` holds only flipped |
| +accounts. Rollback for one account is one row removed. When everyone is |
| +across, repoint `ti.to`'s own route at Go and drop the rows entirely. |
| + |
| +--- |
| + |
| +## 3. Wire: path routes are a third claim capability |
| + |
| +The control-plane object already exists. A claim (`internal/domains/claims.go:34`) |
| +lives at `domains/<host>.json` — **one object per hostname**, which is exactly |
| +the granularity a per-host prefix map wants. It already carries `Account` and |
| +`App` for tenancy, is written under a CAS etag, and is folded into the |
| +registry on the fast-converge leg every ~2s. |
| + |
| +It is also explicitly built to grow: |
| + |
| +> *"any future claim field must be added here in the same change that first |
| +> writes it"* — claims.go:50-55 |
| + |
| +So the wire change is one additive field: |
| + |
| +```go |
| +// Paths maps a leading URL path segment to the route that serves it, |
| +// for a host whose traffic is split by path rather than by hostname. |
| +// Keys are single segments WITHOUT slashes ("paulca"); values are |
| +// hostnames of routes in the same account. Absent or empty means this |
| +// host routes by hostname alone — the behaviour of every host today. |
| +Paths map[string]string `json:"paths,omitempty"` |
| +``` |
| + |
| +**Tito publishes this wholesale, regenerated from its own database.** Not |
| +deltas. A lost delta desyncs the two systems silently and nobody finds out |
| +until a customer reports a wrong page; a wholesale republish is a full |
| +repair, and republishing is always safe. One `PUT` per flip regardless of how |
| +many accounts are in the map. |
| + |
| +Deliberate departures, called out because they cut against neighbouring |
| +idioms: |
| + |
| +- **Replace-on-write, not create-only.** `provision.Record` is create-only |
| + (409 on race, never a silent overwrite) because an instance record is a |
| + one-shot fact. A migration toggle is neither one-shot nor one-way. The CAS |
| + etag `getClaimETag` already carries is what keeps concurrent writes honest. |
| +- **A map field, not one object per flip.** One object per account would mean |
| + 10k+ bucket objects for a state that is one table in Tito's database. |
| + |
| +**Cap.** Mirror `maxWildcardDomains` (aliases.go:25) with `maxPathsPerClaim = |
| +10000` — deterministic drops in sorted key order, logged, never silent. |
| +Unlike the wildcard cap this does not defend an external budget (there is no |
| +ACME equivalent here); it bounds object size and the per-box fold. At ~40 |
| +bytes an entry, 10k entries is ~400KB re-Put per flip and refolded every 2s |
| +per box, which is comfortable. Sharding a host's map across objects is out of |
| +scope and should not be built until a real host approaches the cap. |
| + |
| +--- |
| + |
| +## 4. Registry: `route_paths`, wholly pass-owned |
| + |
| +```sql |
| +CREATE TABLE IF NOT EXISTS route_paths ( |
| + host TEXT NOT NULL, |
| + prefix TEXT NOT NULL, |
| + target TEXT NOT NULL, |
| + account TEXT NOT NULL, |
| + app TEXT NOT NULL, |
| + PRIMARY KEY (host, prefix) |
| +); |
| +``` |
| + |
| +`host TEXT PRIMARY KEY` on `routes` is what forced this to be a separate |
| +table: a hostname is one row there, and it must stay one row, because that |
| +row is what owns the cert, the home box, the DNS state and the aliases. |
| + |
| +Derivation extends `ReconcileAliases` (`internal/domains/aliases.go:48`), |
| +which already folds claims into `route_aliases` and `wildcard_domains` on the |
| +same pass, and already states the property this table needs: |
| + |
| +> *"Both tables are wholly pass-owned: every row traces to a claim, so |
| +> removal needs no ownership flag."* |
| + |
| +Same rule here. Every `route_paths` row traces to a claim's `Paths` entry; |
| +rows with no claim behind them are deleted every pass. A republished map with |
| +an account removed un-flips that account on the next tick, with no delete |
| +verb anywhere. |
| + |
| +Tenancy gates, mirroring the existing alias gates in the same loop: |
| + |
| +- The claim's host must resolve to a local route whose account and app match |
| + the claim — the check already there for aliases. |
| +- **The target must belong to the same account.** A prefix must never be able |
| + to point another tenant's traffic at your backend. This is the one genuinely |
| + new authorization rule in the spec and the one most worth reviewing. |
| +- A target that is not a local ready route contributes no row, and logs. The |
| + prefix then falls through to the host's default — a missing Go route serves |
| + classic, which is the correct failure direction for a migration. |
| + |
| +`Route.Slug` (registry.go:36 — *"app-account short name usable on a root |
| +domain. Empty = subdomain-only"*) is carried by the registry and by |
| +`provision.Record` and read by nothing. It is an unwired hook for root-domain |
| +path routing that someone anticipated. This spec does **not** use it: |
| +`route_paths` is Tito-published desired state, while `Slug` is a property a |
| +route asserts about itself, and conflating them would give two writers of one |
| +routing decision. Leave it alone; if it is still dead after this ships, delete |
| +it in a separate change. |
| + |
| +--- |
| + |
| +## 5. `Fingerprint()` must fold in `route_paths` |
| + |
| +`Fingerprint()` (registry.go:1676) is the 2s content hash the edge polls, and |
| +it hashes `routes`, `fleets`, `fleet_boxes`, `route_aliases` and |
| +`wildcard_domains` — each under its own section marker so a value can never |
| +migrate between sections and cancel a change out. |
| + |
| +Add a `route_paths` section on the same pattern: |
| + |
| +```go |
| +fmt.Fprint(h, "\x1droute_paths\x1e") |
| +// SELECT host, prefix, target, account, app FROM route_paths ORDER BY host, prefix |
| +``` |
| + |
| +All five columns hash; there is no cadence-stamped column here to exclude, so |
| +this table needs none of the `dns_status`/`cert_expires_at` care that |
| +`route_aliases` does. |
| + |
| +**This is the easiest thing in the spec to omit and the worst to omit.** The |
| +failure mode is silent: flips write correctly, reconcile correctly, land in |
| +the table correctly, and never reach the live map — the edge keeps serving the |
| +old answer until something unrelated moves the digest. The function's own |
| +header already names this class of bug as the one to engineer against: |
| + |
| +> *"the failure mode worth engineering against is the false NEGATIVE."* |
| + |
| +A test that asserts the digest moves when a `route_paths` row changes is a |
| +required deliverable, not a nice-to-have. |
| + |
| +--- |
| + |
| +## 6. The edge: `lookupUpstream(host, path)` |
| + |
| +Host resolution is unchanged. Only when the resolved host has a prefix map |
| +does a second lookup happen: |
| + |
| +1. Resolve `host` exactly as today (exact, then one-label wildcard). |
| +2. If that host has no prefix map — every host on the platform today — return |
| + the route. One nil map check, no allocation, no behaviour change. |
| +3. Otherwise take the first path segment — in **both** its raw and its |
| + normalised spelling, see below — and look it up. A hit returns the |
| + target's upstream; a miss returns the host's own route. |
| + |
| +**No prefix stripping.** `ti.to/paulca/awesomeconf` arrives at Tito Go as |
| +`/paulca/awesomeconf`. Go already knows the account is the first segment |
| +because that is how the URLs are shaped; stripping would make the flipped and |
| +unflipped URL spaces differ, which is exactly what a seamless migration must |
| +not do. This holds for the odd shapes too: matching normalises, **proxying |
| +never does**, so the upstream always receives the request's original bytes. |
| + |
| +**Segment-boundary matching, on both spellings of the first segment.** Because |
| +the stored prefix is a bare segment (§2), the match is a plain equality test |
| +against the request's first segment — never a `strings.HasPrefix` on the raw |
| +path. So the prefix `paulca` serves `/paulca` and `/paulca/awesomeconf/admin`, |
| +and never `/paulcarson`. A request for `/` matches no prefix and falls through |
| +to the host's own route. |
| + |
| +"The request's first segment" is two strings, not one, and the map is |
| +consulted for both: the **raw** split of the path as it arrived, and the |
| +**normalised** one (`path.Clean` first, so leading empty segments and dot |
| +segments resolve). Either may hit; when both hit and they name different |
| +prefixes, the normalised one wins. |
| + |
| +Both are needed because they fail in opposite directions, and the invariant |
| +being protected is one-way — **the app an account was flipped away from must |
| +never answer for that account.** Serving a not-yet-flipped account from the |
| +new app is the benign direction, at worst a 404 while the migration runs. |
| + |
| +- **Raw alone** misses `//paulca/foo`. Most backends, Rails included, collapse |
| + that to `/paulca/foo` and serve it, so the request would fall through to the |
| + legacy app — which then answers for a flipped account. One character, |
| + silent. |
| +- **Normalised alone** misses `/paulca/../bob`, which resolves to `bob`. A |
| + backend that does not collapse `..`, or that routes on the first segment |
| + with a glob like `/:account/*rest`, would then serve paulca's page from the |
| + legacy app. The same bypass from the other side. |
| + |
| +Taking a hit from either closes both. The cost is that `/paulca/../bob` |
| +reaches the new app carrying `/paulca/../bob`, which a normalising target |
| +resolves to `/bob` and 404s — an account it was not given. Benign, and |
| +deliberate. |
| + |
| +**Residue**, recorded so it is not re-derived later. None of it crosses the |
| +invariant above: |
| + |
| +- `/%2e%2e/bob` — `r.URL.Path` is already percent-decoded, so this normalises |
| + to `/bob` and matches `bob`. The upstream receives the raw `/%2e%2e/bob` and |
| + will most likely 404 it. Harmless. |
| +- `/paulca%2ffoo` — decodes to `/paulca/foo`, so both spellings match `paulca` |
| + and the request reaches the new app, while a strict backend treats the |
| + upstream path as the single segment `paulca/foo`. A mis-flip toward the new |
| + app: the safe direction. |
| +- A prefix literally named `.` or `..` cannot exist — `validPathPrefix` |
| + refuses both — so the raw spelling of `/../bob` can never match anything. |
| + |
| +**The scope of the guarantee**, stated so it is not over-read. The two |
| +spellings model exactly two normalisations — percent-decoding and dot-segment |
| +removal — and the invariant holds across every combination of a backend doing |
| +or not doing those two. It is *not* a claim that no other normalisation |
| +exists. A backend that folds case (`/PAULCA/foo`), strips servlet-style path |
| +parameters (`/paulca;jsessionid=x/foo`), accepts backslash separators, strips |
| +trailing dots, or double-decodes could resolve a path neither spelling matches |
| +into a flipped account's subtree. |
| + |
| +Rails does none of these — Rack keeps `;` in `PATH_INFO`, and routing is |
| +case-sensitive — so Tito is not exposed, which is why this is recorded rather |
| +than defended against. A future consumer with different normalisation |
| +behaviour needs this reasoning redone, not inherited. |
| + |
| +**Three call sites, and they do not all want a path** (this is the subtle part): |
| + |
| +- `edge.go:930` — the ServeHTTP dispatch. Takes the path. |
| +- `edge.go:1455` — a `routable` check asking only *is this hostname served |
| + here at all*. Must stay host-only; giving it a path would make a hostname's |
| + routability depend on which URL happened to ask. |
| +- `edge.go:866` — the definition. |
| + |
| +So the host-only form stays as the primitive and the path-aware form wraps |
| +it, rather than the path-aware form replacing it everywhere. |
| + |
| +`ServeHTTP`'s ordering (edge.go:879) is load-bearing and documented: steering |
| +probe → reserved tunnel hostname / store door → `acceptHop` → lookup → miss |
| +handling → usage metering wrapping `w` exactly once. Path resolution goes |
| +**inside** the existing lookup step. It must not move anything above it — |
| +particularly not `acceptHop`, or a backhauled request would resolve its path |
| +twice, once on each box. |
| + |
| +Untouched: `homemap`, `backhaul`, `certsync`, `certwarm`, `wildcard`, the |
| +activator, the cache. That is the payoff of keeping the hostname as the unit |
| +of placement: `ti.to` is one hostname with one home box and one cert whatever |
| +its prefix map says, so none of that machinery has to learn about paths. |
| +`homemap`'s lookup is documented as mirroring `lookupUpstream` *"rule for |
| +rule"* — this spec keeps that true, because the rule it mirrors is the |
| +host-only one. |
| + |
| +**But that is a statement about the HOSTNAME, not a promise that path routing |
| +works across boxes. It does not.** Both halves of the resolution — the |
| +reconcile pass's `byHost` and the edge's live map — are built from *this |
| +box's* routes, and backhaul fires only on a **host** miss, never for a path |
| +target. So the constraint is: |
| + |
| +> **A prefix's target route must be homed on the same box as the hostname's |
| +> own route.** A target on another box derives no `route_paths` row anywhere, |
| +> on any box, and every request keeps reaching the hostname's own route. |
| + |
| +That failure direction is safe (traffic serves the origin, never a 404) but it |
| +is invisible in the traffic, so the reconcile pass logs it: one aggregated |
| +line per host per pass naming the count and the first few unresolved targets, |
| +distinguishing "the target does not exist" from "the target is homed |
| +elsewhere" because the remedies differ. Making cross-box path targets actually |
| +work needs a backhaul equivalent for path targets, which is a design change, |
| +not a fix. See §11. |
| + |
| +**Attribution: a path hit returns the TARGET's upstream entry wholesale.** |
| +Everything downstream that reads the resolved entry reads the target's route, |
| +not the requested hostname's. Four consequences, three of them correct by |
| +design and one a known gap: |
| + |
| +- **Usage metering** bills the target's route, account and app. Correct — the |
| + target's backend burned the bytes. Pinned by a test so a later "fix" in the |
| + other direction is caught. |
| +- **The `X-Carlos-Version` header** reports the target's release. Correct — it |
| + names the code that produced the response. |
| +- **Activator keying and hibernation** are the target's, so a flipped prefix |
| + wakes the target instance. Correct — that is the process that has to be |
| + awake. |
| +- **Purge tenancy** is the target's, while the response **cache keys on the |
| + requested hostname**. That mismatch is a real gap; §11 records it and its |
| + bound. |
| + |
| +--- |
| + |
| +## 7. The write door |
| + |
| +An earlier revision of this design claimed a route write API was net-new |
| +work, because `internal/console/apiroutes.go` is read-only. That was looking |
| +in the wrong place. The claim API already exists, with service-role scoping |
| +(`internal/console/authcli.go:618-624`): |
| + |
| +``` |
| +GET /api/cli/apps/{account}/{app}/domains |
| +POST /api/cli/apps/{account}/{app}/domains |
| +GET /api/cli/apps/{account}/{app}/domains/{host} |
| +DELETE /api/cli/apps/{account}/{app}/domains/{host} |
| +``` |
| + |
| +Tito's door is a sibling: |
| + |
| +``` |
| +PUT /api/cli/apps/{account}/{app}/domains/{host}/paths |
| +``` |
| + |
| +**Wire shape.** The body is the whole map, WRAPPED in a `paths` field — not a |
| +bare map: |
| + |
| +```json |
| +{ "paths": { "paulca": "go.tito.io", "bigcorp": "go.tito.io" } } |
| +``` |
| + |
| +`{"paths": {}}` is how a map is cleared. A bare map, or a body whose `paths` |
| +field is absent or misspelled, answers **400**. The wrapper is what makes that |
| +possible: `paths` is decoded into a POINTER, so "absent" and "empty" are |
| +distinguishable, and a typo in the field name becomes a refusal instead of a |
| +silent wholesale clear of every flip on the hostname. (An earlier revision of |
| +this spec said "body is the whole map", which would fail on the first call — |
| +corrected here.) |
| + |
| +It reuses the existing auth, account scoping and service roles wholesale, and |
| +its handler is the shape of the capability handler at apidomains.go:482: read |
| +claim under etag, replace one field, CAS write. |
| + |
| +**Roles: `Publish` AND `Instance`.** The first implementation made this |
| +`Publish` only, reasoning that deciding whose traffic leaves the legacy app is |
| +a deployment-shaped act. Review overturned that on internal coherence: the |
| +paths *read* (`GET .../domains/{host}`) is gated `Publish+Instance`, so |
| +`Publish`-only left an Instance credential able to read a hostname's published |
| +map and not write it — and `apidomains.go`'s own file header names Tito's |
| +router, calling this family with its `CARLOS_INSTANCE_TOKEN`, as the caller |
| +the door exists for. Widening crosses no tenancy boundary: the door is |
| +owner-gated on account and app, so an Instance token can only ever publish its |
| +own app's map, and the reconcile pass re-checks every target against the same |
| +account regardless. Recorded here so the scoping is a visible choice rather |
| +than an accident. |
| + |
| +**`refuseServiceControlPlane`.** A SERVICE credential of any role is refused |
| +outright when the app is the deployment's own control plane; a person is not. |
| +A prefix map on the console's own hostname would hand a slice of the console's |
| +URL space (`/admin`, `/api`) to whatever else that account runs, and that is |
| +not a decision a CI or router credential should be able to take. The guard |
| +sits before the hostname is normalized, so its answer cannot vary with host |
| +shape — a flagged app refuses a malformed host and a valid one with the |
| +identical status and body. |
| + |
| +Following `SetClaimCapabilities`' precedent (claims.go:279, *"the ONLY writer |
| +of a claim's Wildcard/Catchall fields"*), a single `SetClaimPaths` is the only |
| +writer of `Paths`, so `AttachClaim`'s converge can neither invent nor strip |
| +the map. |
| + |
| +**Flip latency, end to end:** `PUT` → bucket → **serial bump** → |
| +fast-converge tick (`fastTickEvery = 2s` plus jitter, agent.go:1053/1504) → |
| +registry write → 2s fingerprint poll → live map. **Under about five |
| +seconds**, which is inside what a Tito admin toggle can plausibly wait on. |
| + |
| +The serial bump is not incidental, and this document's first revision |
| +omitted it — the omission was caught during implementation. Without it the |
| +write lands in the bucket correctly and then waits for the *hourly* pass, |
| +turning a five-second flip into an hour-long one. Attach and detach already |
| +bump for exactly this reason, so the door must do what its siblings do: |
| +write, then audit, then bump, all inside the `changed` branch so a no-op |
| +republish costs neither an audit row nor a fleet-wide reload. |
| + |
| +The audit row matters independently of latency. A prefix map decides where |
| +an account's traffic goes, so "who moved this hostname's traffic, and when" |
| +has to be answerable afterwards. The row records actor, account, app, host |
| +and the prefix count — deliberately not the targets themselves, since the |
| +map may hold ten thousand entries. |
| + |
| +--- |
| + |
| +## 8. Surfaces |
| + |
| +- `carlos domains paths <host>` — list a host's prefix map. |
| +- `carlos routes` gains path rows, indented under their host, so the flipped |
| + set is visible from the same command operators already use. |
| +- Both read-only. Tito is the writer; a CLI write verb would create a second |
| + writer of state whose source of truth is Tito's database, and the two would |
| + drift the first time anyone used it. |
| + |
| +--- |
| + |
| +## 9. What this does NOT do |
| + |
| +- **No regex, globs, or multi-segment prefixes.** First segment, exact match. |
| + Tito's URL shape is `/<account>/...` and nothing here should outgrow that |
| + before there is a second consumer to generalise against. |
| +- **No reserved-prefix list.** `ti.to/login` and `ti.to/assets` must not be |
| + shadowed by an account slug — but Tito already guarantees that, because |
| + those names cannot be registered as accounts. Duplicating the list in CARLOS |
| + gives two places to update and one to forget. If this proves wrong, it is a |
| + small additive change to the reconcile gate. |
| +- **No per-prefix channel or environment.** Point the prefix at a route that |
| + has them (§2). |
| +- **No path-aware certs, DNS, or placement.** A hostname remains the unit of |
| + all three. |
| +- **No `ti.to` migration.** Explicitly out of scope per Paul's ruling. |
| + |
| +--- |
| + |
| +## 10. Rollout, and how this is proven without `ti.to` |
| + |
| +The honest risk of building machinery with no consumer is that it ships |
| +looking finished and is wrong in a way nobody discovers for months. So the |
| +acceptance bar is an end-to-end flip on a real hostname, not unit tests. |
| + |
| +1. **Table, wire field, fingerprint section.** Additive; zero rows on every |
| + existing deployment; no behaviour change. The fingerprint test from §5 |
| + lands here. |
| +2. **Reconcile derivation + tenancy gates**, including a test that a target |
| + in another account is refused. |
| +3. **`lookupUpstream(host, path)`** plus the three call sites, with the |
| + `edge.go:1455` host-only form preserved. |
| +4. **The write door and `SetClaimPaths`.** |
| +5. **End-to-end proof on a scratch host.** Two trivial apps, `A` and `B`, on |
| + one hostname: `scratch.example/b` reaches `B`, everything else reaches `A`, |
| + `/bat` reaches `A` (boundary), removing the entry from a republished map |
| + restores `A` within ~5s. This is the deliverable that says the machinery |
| + works — everything before it is scaffolding. |
| + |
| +Stages 1-4 are independently landable PRs. Nothing is observable until 5. |
| + |
| +--- |
| + |
| +## 11. Live checks owed, and known gaps |
| + |
| +None is a blocker for stages 1-5; all are blockers before Tito relies on |
| +any of it. |
| + |
| +- **Case 2, end to end.** Can `*.tito.page` be created through a supported |
| + door? `provision.ValidHostShape` (provision.go:250) checks every label |
| + against `labelRe`, which a literal `*` cannot pass — so wildcard rows arrive |
| + by some other route (a `Wildcard`+`Catchall` claim, per aliases.go, is the |
| + likely one). And the delegated wildcard cert path needs proving with a real |
| + handshake. This is read-from-code, not observed. **Case 2 is the case Paul |
| + can act on soonest, and it is the one with the least evidence behind it.** |
| +- **`ti.to` behind the CARLOS edge.** Out of scope here, but it is the |
| + prerequisite for case 1 ever being used, and it is the largest single risk |
| + in the whole migration. Worth its own spec when Paul is ready. |
| + |
| +- **The production converge loop actually folds a path claim on schedule.** |
| + §10's end-to-end test drives `ReconcileAliases` and `Edge.Reload` directly, |
| + which is the right call — the 2s tick is a latency bound, not a correctness |
| + property, and polling it would make the fastest test in the plan slow and |
| + flaky. But that means the test proves the layers compose *given that |
| + something calls those two functions*. It does not prove that the real |
| + converge loop (`internal/host/agent.go:1475` and `:1955`, the two |
| + production call sites of `ReconcileAliases`) invokes them for a live path |
| + claim on the real schedule. |
| + |
| + The general fingerprint→reload mechanism is not unproven — `Edge.Watch` is |
| + exercised against a live goroutine with bounded polling elsewhere in |
| + `internal/edge`. What is unproven is specifically that a published `paths` |
| + map traverses the real host agent's timing end to end. That is an |
| + integration concern a package-level test cannot absorb, so it belongs here |
| + rather than in the suite. **It is also the check that would falsify §7's |
| + "under about five seconds" claim if that claim is wrong**, which makes it |
| + the most load-bearing of the three items in this section. |
| + |
| +- **Cross-box path targets.** Both halves of the path resolution read this |
| + box's routes only, and there is no backhaul equivalent for a path target |
| + (§6). `ti.to` on one box and `go.tito.io` on another — the likely shape for |
| + two apps as different as Tito classic and Tito Go — derives no rows and |
| + routes nothing, forever. The reconcile pass now says so in the log, but |
| + nothing enforces the constraint at the write door and nothing has ever been |
| + observed. Before any real flip: confirm the hostname's route and every |
| + target route are homed on the same box, and decide whether making it work |
| + across boxes is wanted (a design change, not a fix). |
| + |
| +**Known gap: a path-routed app cannot purge its own cache.** The response |
| +cache keys on the REQUESTED hostname (`ti.to`), while `purgeModifier` is built |
| +from the resolved route — the TARGET's (`go.tito.io`). So a `Carlos-Purge` |
| +emitted by the Go app purges under `go.tito.io` and drops nothing; the entry |
| +lives under `ti.to`. The documented `Carlos-Purge-Host: ti.to` escape hatch |
| +does not help either: `resolvePurgeHost` requires the override to name a host |
| +in the same app, and path routing exists precisely to cross an app boundary. |
| +Aliases never hit this, because an alias maps to the primary's own route and |
| +the app comparison passes trivially. |
| + |
| +The bound: a path-routed response can go stale for at most the cache's max |
| +TTL, and only for responses that opted into caching with a positive |
| +`max-age`. Uncached responses are unaffected. Deliberately NOT fixed here — |
| +changing the purge tenancy model is unreviewed territory this feature did not |
| +design. Documented on `lookupUpstreamPath` and recorded here so a consumer |
| +knows before it relies on purge. |
diff --git a/domains_cli.go b/domains_cli.go| index 8f602970c..04b130ea1 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -12,11 +12,12 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "sort" |
| "strings" |
| "time" |
| ) |
| -const domainsUsage = `usage: carlos domains <attach|detach|list> [flags] |
| +const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags] |
| attach --app <a> [--route <r>] [--wildcard] [--catchall] [flags] <hostname> |
| claim a customer's hostname for the app, pointed at |
| @@ -29,6 +30,11 @@ const domainsUsage = `usage: carlos domains <attach|detach|list> [flags] |
| list --app <a> [flags] |
| this app's hostname claims, joined to the fleet's own |
| readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION) |
| + paths --app <a> [flags] <hostname> |
| + the leading path segments this hostname's traffic is split |
| + by, as PUBLISHED. Read-only: the platform that owns the |
| + hostname republishes the whole map, so there is no verb |
| + here that writes one. |
| --account <a> account (sqid or name; default "ops") |
| --account-id <id> account sqid, matched as a sqid ONLY |
| @@ -49,6 +55,9 @@ const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a |
| const domainsListUsage = `usage: carlos domains list --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| ` |
| +const domainsPathsUsage = `usage: carlos domains paths --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| +` |
| + |
| // runDomains dispatches the domains sub-verbs. attach/detach/list are |
| // positional words, not flags, and must never reach fs.Parse — the same |
| // shape runInstances uses for its own sub-verbs. |
| @@ -64,6 +73,9 @@ func runDomains(args []string) { |
| case "list": |
| runDomainsList(args[1:]) |
| return |
| + case "paths": |
| + runDomainsPaths(args[1:]) |
| + return |
| } |
| } |
| log.Fatal(domainsUsage) |
| @@ -260,6 +272,62 @@ func runDomainsList(args []string) { |
| w.Flush() |
| } |
| +// runDomainsPaths prints one hostname's PUBLISHED prefix map — the leading |
| +// path segments its traffic is split by, and the route each is served by. |
| +// |
| +// This is desired state, and the distinction from `carlos routes` is the whole |
| +// reason the verb exists. `carlos routes` prints the route_paths rows a box |
| +// derived, and the reconcile pass silently drops any entry whose target is not |
| +// a route of the same account on that box. So a prefix that appears HERE and |
| +// not there is a flip the platform published and the deployment refused — |
| +// which is the state an operator debugs, and which no other surface shows. The |
| +// footer says so rather than leaving the reader to know it. |
| +// |
| +// READ-ONLY, and there is deliberately no sibling that writes: the platform |
| +// that owns the hostname regenerates this map from its own database and |
| +// republishes it wholesale, so a CLI writer would be a second writer of state |
| +// whose source of truth is elsewhere, and the two would drift the first time |
| +// anyone used it. |
| +func runDomainsPaths(args []string) { |
| + fs := flag.NewFlagSet("domains paths", flag.ExitOnError) |
| + app := appFlag(fs, "app the hostname belongs to") |
| + consoleFlag := fs.String("console", "", "console to read 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.Usage = flagUsage(fs, domainsPathsUsage) |
| + fs.Parse(args) |
| + host := strings.TrimSpace(fs.Arg(0)) |
| + if host == "" || fs.NArg() > 1 { |
| + log.Fatal(domainsPathsUsage) |
| + } |
| + sess := requireAPISession("paths", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, domainsPathsUsage) |
| + paths, err := sess.domainPaths(context.Background(), acct, app2, host) |
| + if err != nil { |
| + log.Fatalf("domains paths: %v", err) |
| + } |
| + if len(paths) == 0 { |
| + mutedf(stdout, "%s splits no traffic by path", host) |
| + return |
| + } |
| + // Sorted by prefix: a map has no order, and a listing that reshuffles |
| + // between calls is one nobody can diff. |
| + prefixes := make([]string, 0, len(paths)) |
| + for p := range paths { |
| + prefixes = append(prefixes, p) |
| + } |
| + sort.Strings(prefixes) |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "PREFIX\tTARGET") |
| + for _, p := range prefixes { |
| + fmt.Fprintf(w, "/%s\t%s\n", p, paths[p]) |
| + } |
| + w.Flush() |
| + mutedf(stdout, "(published desired state; \"carlos routes --app %s\" shows which of these a box is actually serving)", app2) |
| +} |
| + |
| // printDomainRecords prints the records the member must create, grouped by |
| // alternative. Nothing is printed when the door sent none — a pointing |
| // hostname has nothing left to do, and an older console sends no records at |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index abed78e91..92872fe70 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -271,6 +271,12 @@ type fakeDomainsConsole struct { |
| attachCreated bool |
| lastDetachPath string |
| detachRemoved bool |
| + // paths is what the per-host GET answers as the hostname's PUBLISHED |
| + // prefix map, and lastPathsPath records the URL it was asked for. nil |
| + // means the console sends no `paths` key at all — the shape a deployment |
| + // predating path routing sends. |
| + paths map[string]string |
| + lastPathsPath string |
| } |
| func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| @@ -299,6 +305,18 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| case r.Method == http.MethodDelete: |
| fc.lastDetachPath = r.URL.Path |
| json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved}) |
| + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/domains/"): |
| + // The per-host claim door, which carries the published prefix |
| + // map. Matched BEFORE the catch-all list GET below, which would |
| + // otherwise answer it with a list body and no `paths` key at all |
| + // — making an assertion about the map impossible to distinguish |
| + // from a client that never read it. |
| + fc.lastPathsPath = r.URL.Path |
| + body := map[string]any{"domain": map[string]any{"host": "tickets.example.com"}} |
| + if fc.paths != nil { |
| + body["paths"] = fc.paths |
| + } |
| + json.NewEncoder(w).Encode(body) |
| case r.Method == http.MethodGet: |
| json.NewEncoder(w).Encode(map[string]any{ |
| "domains": []map[string]any{ |
| @@ -586,6 +604,81 @@ func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsPathsPrintsThePublishedMap: the read surface for path |
| +// routing. It prints the map as PUBLISHED, sorted by prefix, and says in |
| +// words that this is desired state — because the other surface (`carlos |
| +// routes`) prints the rows a box DERIVED, and the difference between the two |
| +// is the whole diagnostic: the reconcile pass drops an entry whose target is |
| +// not a same-account route on that box, so a prefix visible here and not |
| +// there is a flip the deployment refused. |
| +func TestRunDomainsPathsPrintsThePublishedMap(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + // Deliberately out of alphabetical order in the map, and with one target |
| + // that is not a route of this deployment at all — the refused entry this |
| + // surface exists to make visible. |
| + fc.paths = map[string]string{ |
| + "paulca": "go.tito.io", |
| + "bigcorp": "not-deployed-here.example", |
| + "acme": "go.tito.io", |
| + } |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| + |
| + out := buf.String() |
| + // It asked the per-host claim door for the hostname it was given. |
| + if !strings.HasSuffix(fc.lastPathsPath, "/domains/ti.to") { |
| + t.Fatalf("asked for %q, want the per-host claim door for ti.to", fc.lastPathsPath) |
| + } |
| + for _, want := range []string{ |
| + "PREFIX", "TARGET", |
| + "/acme", "/bigcorp", "/paulca", |
| + "go.tito.io", "not-deployed-here.example", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| + // Sorted by prefix: a map has no order, and a listing that reshuffles |
| + // between calls is one nobody can diff. |
| + acme, bigcorp, paulca := strings.Index(out, "/acme"), strings.Index(out, "/bigcorp"), strings.Index(out, "/paulca") |
| + if !(acme < bigcorp && bigcorp < paulca) { |
| + t.Fatalf("rows are not prefix-sorted (acme=%d bigcorp=%d paulca=%d):\n%s", acme, bigcorp, paulca, out) |
| + } |
| + // And it says which KIND of state this is. Without that line a reader |
| + // takes a published map for what is serving, which is the exact confusion |
| + // that makes a refused flip look like a working one. |
| + if !strings.Contains(out, "published desired state") || !strings.Contains(out, "carlos routes") { |
| + t.Fatalf("stdout does not distinguish published state from what a box serves:\n%s", out) |
| + } |
| +} |
| + |
| +// TestRunDomainsPathsSaysSoWhenThereAreNone: a hostname with no prefix map |
| +// prints a line, not a bare header. An older console sends no `paths` key at |
| +// all and must read the same way — "this hostname splits no traffic by path" |
| +// is the true answer for a deployment that has no path routing. |
| +func TestRunDomainsPathsSaysSoWhenThereAreNone(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + paths map[string]string |
| + }{ |
| + {"an empty published map", map[string]string{}}, |
| + {"a console that predates path routing", nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.paths = tc.paths |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| + |
| + if out := buf.String(); !strings.Contains(out, "splits no traffic by path") { |
| + t.Fatalf("stdout = %q, want a line saying there are none", out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| // ---- subprocess: the usage-error / bad-argument paths --------------------- |
| // TestDomainsCLIUsageErrors drives every domains verb with missing or |
| @@ -623,6 +716,8 @@ func TestDomainsCLIUsageErrors(t *testing.T) { |
| {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"}, |
| {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| {"list no app", []string{"domains", "list"}, "no deployment target configured"}, |
| + {"paths no app", []string{"domains", "paths", "ti.to"}, "no deployment target configured"}, |
| + {"paths no positional host", []string{"domains", "paths", "-app", "tito"}, "usage: carlos domains paths"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| out, err := run(tc.args...) |
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go| new file mode 100644 |
| index 000000000..65ca4d4ec |
| --- /dev/null |
| +++ b/internal/console/apidomainpaths_test.go |
| @@ -0,0 +1,348 @@ |
| +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" |
| + "strings" |
| + "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) |
| + } |
| +} |
| + |
| +// TestAPIDomainPathsRejectsBadTarget is the OTHER half of the prefix map, and |
| +// the half that is easiest to get wrong in the handler rather than the store: a |
| +// malformed target hostname is the CALLER's error, so it must answer 422 |
| +// naming the entry — never the 502 "try again" that a store failure answers |
| +// with, which would tell the publishing platform to retry a body that will |
| +// fail identically forever. |
| +// |
| +// Pinned here at the REQUEST level as well as in |
| +// TestSetClaimPathsRejectsUnnormalisedTarget, because the two can fail |
| +// independently: the store can classify the error correctly while the door |
| +// drops it into the wrong branch, and dropping the %w wrap in claims.go is |
| +// enough to move every case in this test from 422 to 502. |
| +func TestAPIDomainPathsRejectsBadTarget(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-badtarget") |
| + claimedHost(t, fx, member, "ti.to") |
| + for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "go tito io"} { |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, |
| + pathsBody(t, map[string]string{"paulca": target})) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("target %q = %d %s, want 422 — a malformed target is the caller's error, not ours", target, code, out) |
| + } |
| + // The offending ENTRY is named. With 10,000 prefixes in a published |
| + // map, a refusal that does not say which one is unactionable. |
| + if !strings.Contains(out, "paulca") || !strings.Contains(out, target) { |
| + t.Fatalf("target %q refusal = %s, want it to name the prefix and the target", target, out) |
| + } |
| + if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| + t.Fatalf("target %q: claim paths = %v, want none — a refused map must not partially land", target, c.Paths) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIDomainGetAnswersThePublishedMap is the READ half of the feature, and |
| +// the reason it cannot be left to `carlos routes`: that command lists the |
| +// route_paths rows a box DERIVED, and the reconcile pass silently drops any |
| +// entry whose target is not a route of the same account on that box. So an |
| +// entry the deployment REFUSED — a cross-tenant target, a target that is not |
| +// deployed here — appears on no registry-derived surface at all. This door |
| +// answers the map as PUBLISHED, which is the only place that refused entry is |
| +// visible, and the state an operator debugging a stalled migration is looking |
| +// for. |
| +// |
| +// Asserted with a target that is a plausible hostname but NOT a route of this |
| +// deployment, i.e. exactly the entry the pass would drop. It has to come back |
| +// regardless: this door reports what was published, and makes no judgement |
| +// about what a box did with it. |
| +func TestAPIDomainGetAnswersThePublishedMap(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-get-paths") |
| + claimedHost(t, fx, member, "ti.to") |
| + |
| + // Before anything is published: an object, never null, so a client can |
| + // loop without telling two spellings of "none" apart. |
| + if got := getPaths(t, fx, member, "ti.to"); got == nil || len(got) != 0 { |
| + t.Fatalf("paths on a fresh claim = %v (nil=%t), want an empty object", got, got == nil) |
| + } |
| + |
| + publishPaths(t, fx, member, "ti.to", map[string]string{ |
| + "paulca": "go.tito.io", |
| + "bigcorp": "not-deployed-here.example", |
| + }) |
| + got := getPaths(t, fx, member, "ti.to") |
| + if len(got) != 2 || got["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want both published entries", got) |
| + } |
| + if got["bigcorp"] != "not-deployed-here.example" { |
| + t.Fatalf("paths = %v, want the entry whose target is not a route here — a refused flip is exactly what this door exists to show", got) |
| + } |
| + |
| + // And it tracks a republish, so it can be used to check a rollback landed. |
| + publishPaths(t, fx, member, "ti.to", map[string]string{}) |
| + if got := getPaths(t, fx, member, "ti.to"); len(got) != 0 { |
| + t.Fatalf("paths after a clearing publish = %v, want none", got) |
| + } |
| +} |
| + |
| +// getPaths reads one hostname's published map off the per-host claim door. |
| +func getPaths(t *testing.T, fx *consoleFixture, token, host string) map[string]string { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", host), token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get %s = %d %s, want 200", host, code, out) |
| + } |
| + var got struct { |
| + Paths map[string]string `json:"paths"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("get body %s: %v", out, err) |
| + } |
| + return got.Paths |
| +} |
| + |
| +// TestAPIDomainPathsReachesPublishAndInstance is the door's role contract. |
| +// |
| +// Publish AND Instance, matching the READ of the same state beside it (GET |
| +// .../domains/{host} carries both). An earlier revision was Publish-only on |
| +// the reasoning that a flip is deployment-shaped; that left an instance |
| +// credential able to READ a hostname's published prefix map and not write |
| +// it, while apidomains.go's own header names titogo's router — calling this |
| +// family with its CARLOS_INSTANCE_TOKEN — as the caller the door exists for. |
| +// The widening crosses no tenancy boundary: the door is owner-gated on |
| +// account and app and refuses a control-plane app, so an instance token can |
| +// only publish its OWN app's map. |
| +// |
| +// Operate is the negative control: widening the door must widen it to a |
| +// named pair, not to every service role. |
| +func TestAPIDomainPathsReachesPublishAndInstance(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.StatusOK { |
| + t.Fatalf("instance-role publish = %d %s, want 200 — the router that reads this map must be able to write it", 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) |
| + } |
| + op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| + if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), op, body); code != http.StatusForbidden { |
| + t.Fatalf("operate-role publish = %d %s, want 403 — the door is two named roles, not every service role", code, out) |
| + } |
| +} |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 6991285a0..35bcdd10d 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,149 @@ 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, |
| + }) |
| +} |
| + |
| +// pathsOrEmpty renders a claim's prefix map for the wire as an object even |
| +// when it is absent. Claim.Paths is omitempty, so nil and empty are one |
| +// stored state; letting nil marshal to `null` would make a consumer tell two |
| +// spellings of the same answer apart before it could iterate. |
| +func pathsOrEmpty(paths map[string]string) map[string]string { |
| + if paths == nil { |
| + return map[string]string{} |
| + } |
| + return paths |
| +} |
| + |
| // 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 |
| @@ -830,6 +973,23 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| warmRecord(ctx, s.cfg.Releases, c.Host), s.dnsTargets(), now, |
| c.Wildcard && s.delegationZone != ""), |
| "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| + // The prefix map as PUBLISHED, and this is the only surface that |
| + // answers for it. `carlos routes` shows the route_paths rows the |
| + // reconcile pass derived, which is ACTUAL state on one box — the pass |
| + // silently drops an entry whose target is not a same-account local |
| + // route, so an entry it refused appears on no other surface at all. |
| + // That is exactly the state an operator debugs, and a migration whose |
| + // refusals are invisible fails silently. |
| + // |
| + // A sibling key rather than a field on domainItem, deliberately: the |
| + // list door shares that struct, and a host at MaxPathsPerClaim would |
| + // put 10,000 entries into every row of `carlos domains list`. The |
| + // whole map belongs on the door that already narrows to one hostname. |
| + // |
| + // Always an object, never null: an absent map and an empty one are one |
| + // state (Claim.Paths is omitempty), and a client should not have to |
| + // tell a nil from a {} to loop over it. |
| + "paths": pathsOrEmpty(c.Paths), |
| }) |
| } |
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go| index 489397f96..4123bee93 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,48 @@ 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. |
| + // make, not a nil slice: an empty answer must serialise as [] like |
| + // `routes` beside it does, never as null. A client that has to handle |
| + // both spellings of "none" for two keys in one object is a client that |
| + // will handle one of them wrong. |
| + paths := make([]apiRoutePath, 0) |
| + 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. |
| + // release.RouteAccount, matching the routes filter above rather |
| + // than comparing the column raw. The warning there applies here |
| + // word for word: a second, private notion of "whose row is this" |
| + // is exactly how a listing ends up disagreeing with the |
| + // reconciler about ownership. A raw compare fails CLOSED today |
| + // (an unstamped account never equals a sqid, so the row is just |
| + // hidden), but "fails closed" is not the rule this file states, |
| + // and the two filters must resolve ownership identically or a |
| + // route can appear with its own prefixes missing. |
| + if p.App != app || release.RouteAccount(registry.Route{Account: 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..e2a9a6a33 100644 |
| --- a/internal/console/apiroutes_test.go |
| +++ b/internal/console/apiroutes_test.go |
| @@ -9,6 +9,7 @@ package console |
| import ( |
| "net/http" |
| + "strings" |
| "testing" |
| "time" |
| @@ -207,3 +208,79 @@ func TestAPIRoutesAppTokenIsRefused(t *testing.T) { |
| t.Fatal("an app token reached the routes door — it must not") |
| } |
| } |
| + |
| +// TestAPIRoutesPathsIsAnEmptyArrayNotNull: no path rows must serialise as [], |
| +// the way `routes` beside it always does. A key that is sometimes null and |
| +// sometimes an array is a key a client eventually handles wrong, and every |
| +// deployment on the platform hits this branch until someone publishes a map. |
| +func TestAPIRoutesPathsIsAnEmptyArrayNotNull(t *testing.T) { |
| + fx, token := routesFixture(t, "code-routes-nopaths") |
| + status, body := getRaw(t, routesURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET routes = %d %s", status, body) |
| + } |
| + if !strings.Contains(body, `"paths":[]`) { |
| + t.Fatalf("routes body = %s, want an empty paths ARRAY, not null", body) |
| + } |
| +} |
| + |
| +// 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"}, |
| + // The rows that actually exercise the TENANCY compare. The two above |
| + // sit on hostnames that are not in routeFor at all, so the host |
| + // filter excludes them whatever their stamps say — delete the |
| + // account/app compare and the test still passes. These two sit on |
| + // connect.go.tito.io, which IS in the answer, and are stamped to |
| + // another app and another account: only the tenancy compare keeps |
| + // them out. |
| + {Host: "connect.go.tito.io", Prefix: "otherapp", Target: "router-a.go.tito.io", Account: "bab", App: "kass"}, |
| + {Host: "connect.go.tito.io", Prefix: "otheracct", Target: "router-a.go.tito.io", 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"]) |
| + } |
| + for _, r := range rows { |
| + m, _ := r.(map[string]any) |
| + if p := str(t, m, "prefix"); p == "otherapp" || p == "otheracct" { |
| + t.Fatalf("paths carries %q — a row on a hostname in the answer but stamped to another app or account is another tenant's routing table", p) |
| + } |
| + } |
| + // 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..232143bbb 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -622,6 +622,30 @@ 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` AND `Instance`, matching the READ of the same state on |
| + // handleAPIDomainGet above. An earlier revision made this Publish-only on |
| + // the reasoning that a flip is deployment-shaped; the objection that |
| + // carried is internal coherence — as Publish-only, an Instance credential |
| + // could read a hostname's published prefix map and not write it, and this |
| + // file's own header names titogo's router, calling the domains family |
| + // with its CARLOS_INSTANCE_TOKEN, as the caller this door exists for. |
| + // |
| + // Widening crosses no tenancy boundary. The door is already owner-gated |
| + // on account and app (domainsGate) and guarded by |
| + // refuseServiceControlPlane, so an Instance token can only ever publish |
| + // its OWN app's map, naming targets the reconcile pass then re-checks |
| + // against the same account (aliases.go). It is not in |
| + // TestInstanceRoleMatchesTodaysAppTokenReach's legacy map, because |
| + // nothing reached it before service principals existed — see |
| + // exemptFromLegacyPin, where the deliberate grant is recorded. Design |
| + // spec §7. |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/domains/{host}/paths", svcrole.Allow(svcrole.Publish, svcrole.Instance), 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/console/controlplane_test.go b/internal/console/controlplane_test.go| index 181587124..ad3780db6 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -1278,3 +1278,98 @@ func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) { |
| } |
| claimOf(t, fx, "other.example.com") |
| } |
| + |
| +// TestControlPlaneAppRefusesAServicePathPublish is the prefix-map door's place |
| +// in this family. A published prefix map splits a hostname's traffic by leading |
| +// URL segment, and the reconcile pass admits any target route in the same |
| +// account — so a map on the CONTROL PLANE's own hostname hands a slice of the |
| +// console's URL space (`/admin`, `/api`) to whatever else that account runs. |
| +// That is not a decision a CI credential should be able to take. |
| +// |
| +// Service-only, like the attach above and unlike detach: a PERSON publishing a |
| +// prefix map for the control plane is an ordinary operator act. BOTH service |
| +// roles the route admits are exercised (Publish and Instance — see |
| +// TestAPIDomainPathsReachesPublishAndInstance), because this guard, not the |
| +// role table, is what keeps either of them off the console's own hostname. |
| +func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, person := cpDetachFixture(t, "code-cp-paths", host) |
| + u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host + "/paths" |
| + body := pathsBody(t, map[string]string{"paulca": "kass.carlosapp.dev"}) |
| + |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "kass", svcrole.Publish) |
| + status, _, out := rawReq(t, http.MethodPut, u, svc, body) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE publishing paths for the control plane = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + if c := claimOf(t, fx, host); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths) |
| + } |
| + |
| + // The other role the door admits, refused identically: an app's router |
| + // holding a CARLOS_INSTANCE_TOKEN reaches this handler (that is the |
| + // widening in spec §7) and meets the same guard. |
| + inst := serviceToken(t, fx.st, OpsAccountID, "router", "kass", svcrole.Instance) |
| + instStatus, _, instOut := rawReq(t, http.MethodPut, u, inst, body) |
| + if instStatus != status || instOut != out { |
| + t.Fatalf("an INSTANCE publishing paths for the control plane = %d %s, want the same %d %s a PUBLISH gets", |
| + instStatus, instOut, status, out) |
| + } |
| + if c := claimOf(t, fx, host); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths) |
| + } |
| + |
| + // The guard sits BEFORE the hostname is normalized, so its answer cannot |
| + // vary with host SHAPE: a flagged app refuses a malformed host and a valid |
| + // one with the identical status and the identical body. Were the order |
| + // reversed, the 400 "invalid hostname" would reach a prober first and this |
| + // door would report whether a name is well-formed before reporting that |
| + // the app is off limits — a small oracle, but the same class as the ones |
| + // the domains family is built to close. |
| + // |
| + // Only shapes that actually REACH the handler are comparable here. A |
| + // segment of "." or ".." is collapsed by net/http's own path cleaning |
| + // before routing, so it lands on a different pattern entirely (405) and |
| + // says nothing about this guard's ordering. The percent-encoded traversal |
| + // does reach it, which is the case the normalization below the guard |
| + // exists for. |
| + for _, bad := range []string{"not a host", "no-dot", "%2e%2e%2fclaims", "UPPER.example.com."} { |
| + badStatus, _, badOut := rawReq(t, http.MethodPut, |
| + fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+bad+"/paths", svc, body) |
| + if badStatus != status || badOut != out { |
| + t.Fatalf("host %q answered %d %q; the flagged-app refusal must be constant across host shapes (%d %q)", |
| + bad, badStatus, badOut, status, out) |
| + } |
| + } |
| + |
| + // A non-member still meets the family's byte-identical 404, never the |
| + // refusal: the guard sits after domainsGate, so it is not an oracle for |
| + // which accounts hold a control-plane app. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-paths-eve") |
| + eveStatus, eveH, eveOut := rawReq(t, http.MethodPut, u, eve, body) |
| + assertNotFound(t, "non-member path publish on a flagged app", canonicalNotFound(t), eveStatus, eveH, eveOut) |
| + |
| + // The half that must keep working: a person publishes. |
| + if status, _, out := rawReq(t, http.MethodPut, u, person, body); status != http.StatusOK { |
| + t.Fatalf("a PERSON publishing paths for the control plane = %d %s, want 200", status, out) |
| + } |
| + if c := claimOf(t, fx, host); c.Paths["paulca"] != "kass.carlosapp.dev" { |
| + t.Fatalf("claim paths = %v, want the person's publish to have landed", c.Paths) |
| + } |
| + |
| + // The control: with the flag cleared, the identical service call lands. |
| + // The flag is the only variable between these two outcomes. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + cleared := pathsBody(t, map[string]string{"bigcorp": "kass.carlosapp.dev"}) |
| + if status, _, out := rawReq(t, http.MethodPut, u, svc, cleared); status != http.StatusOK { |
| + t.Fatalf("a SERVICE publishing once the flag is cleared = %d %s, want 200", status, out) |
| + } |
| + if c := claimOf(t, fx, host); c.Paths["bigcorp"] != "kass.carlosapp.dev" { |
| + t.Fatalf("claim paths = %v, want the service's publish to have landed once the flag was cleared", c.Paths) |
| + } |
| +} |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index f5fac7a98..2c3fb99a4 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -230,9 +230,22 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) { |
| // credential that may already CREATE and DELETE instances (both in `want`) |
| // is not gaining authority by relocating one. TestAPIInstanceMoveVerbs |
| // pins what the doors themselves refuse. |
| +// |
| +// PUT .../domains/{host}/paths is the seventh, and on the same footing: path |
| +// routing did not exist before service principals, so listing it in `want` |
| +// would make the legacy map lie. The instance role reaches it DELIBERATELY, |
| +// to match the READ of the same state (GET .../domains/{host}, exempted |
| +// above) — an app's router that may read its own published prefix map with |
| +// its CARLOS_INSTANCE_TOKEN is the writer this door was built for, and a |
| +// credential that may already attach and detach that hostname is not gaining |
| +// authority by saying how its traffic splits. The door is owner-gated on |
| +// account and app and refuses a control-plane app outright, so an instance |
| +// token can only ever publish its OWN app's map. |
| +// TestAPIDomainPathsReachesPublishAndInstance pins the contract itself. |
| var exemptFromLegacyPin = map[string]bool{ |
| "GET /api/cli/apps": true, |
| "GET /api/cli/apps/{account}/{app}/domains/{host}": true, |
| + "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true, |
| "GET /api/cli/apps/{account}/{app}/features": true, |
| "GET /api/cli/apps/{account}/{app}/regions": true, |
| "GET /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 21530eb4c..e4d315418 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -1,17 +1,21 @@ |
| // Alias reconcile pass — derives the registry's route_aliases table AND its |
| -// wildcard_domains table from the global claims recorded in the bucket |
| -// (#151). A claim contributes an ordinary alias row when it names a Route |
| -// (see the loop below); a Wildcard claim additionally contributes a |
| -// wildcard_domains row (a delegated-ACME wildcard-cert parent), and a |
| +// wildcard_domains and route_paths tables from the global claims recorded in |
| +// the bucket (#151). A claim contributes an ordinary alias row when it names |
| +// a Route (see the loop below); a Wildcard claim additionally contributes a |
| +// wildcard_domains row (a delegated-ACME wildcard-cert parent), a |
| // Wildcard+Catchall claim also contributes a "*.<host>" route_aliases row |
| -// pointing at the same primary. See claims.go for the claim shape and |
| -// registry.go's RouteAlias/WildcardDomain for the storage shapes; this file |
| -// is the seam that turns one into the other. |
| +// pointing at the same primary, and a claim's Paths map contributes one |
| +// route_paths row per prefix (path-based routing). See claims.go for the |
| +// claim shape and registry.go's RouteAlias/WildcardDomain/RoutePath for the |
| +// storage shapes; this file is the seam that turns one into the other. |
| package domains |
| import ( |
| "context" |
| "log" |
| + "slices" |
| + "sort" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -24,13 +28,14 @@ import ( |
| // order); drops are logged, never silent. |
| const maxWildcardDomains = 32 |
| -// ReconcileAliases derives route_aliases and wildcard_domains from the |
| -// global claim records. Both tables are wholly pass-owned: every row traces |
| -// to a claim, so removal needs no ownership flag. The authoritative tenancy |
| -// gate is HERE — the door's route check is best-effort (it cannot see the |
| -// registry); a claim only materializes when its primary route exists |
| -// locally AND its account/app match the claim. Routes always win a host |
| -// collision: an alias (plain or catch-all) must never shadow a real route. |
| +// ReconcileAliases derives route_aliases, wildcard_domains and route_paths |
| +// from the global claim records. All three tables are wholly pass-owned: |
| +// every row traces to a claim, so removal needs no ownership flag. The |
| +// authoritative tenancy gate is HERE — the door's route check is |
| +// best-effort (it cannot see the registry); a claim only materializes when |
| +// its primary route exists locally AND its account/app match the claim. |
| +// Routes always win a host collision: an alias (plain or catch-all) must |
| +// never shadow a real route. |
| // |
| // deploymentRoutes is every hostname a live box in this deployment publishes |
| // as a REAL route (edgesfile.DeploymentRoutes). It extends "routes always |
| @@ -64,6 +69,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| desired := map[string]bool{} |
| desiredWild := map[string]registry.WildcardDomain{} |
| + desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| wildCount := 0 |
| for _, c := range claims { |
| // Plain alias derivation (#164): a routeless (first-route) claim is |
| @@ -110,6 +116,122 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| } |
| + // Path derivation (prefix routing). A claim's Paths map splits its own |
| + // hostname's traffic by leading URL segment; each entry names the |
| + // hostname of the route that serves that segment. |
| + // |
| + // Gated on the claim's host being a LOCAL route whose tenancy matches |
| + // the claim — the same per-box rule the alias half above follows, and |
| + // for the same reason: the prefix map belongs to that route, so a box |
| + // that does not serve the route derives nothing. |
| + // |
| + // The TARGET is resolved through the same local byHost, which makes |
| + // co-location a hard constraint of the feature: a prefix's target |
| + // route must be homed on the same box as the hostname's own route. |
| + // There is no backhaul equivalent for a path target — backhaul fires |
| + // on a host miss, and the host resolves fine here — so a target on |
| + // another box derives no row anywhere at all. See the unresolved log |
| + // below, and design spec §6. |
| + // |
| + // Ordered BEFORE the wildcard section on purpose: Paths is a third |
| + // claim capability independent of Wildcard (claims.go's comment on |
| + // the field), and the wildcard section below `continue`s for every |
| + // non-Wildcard claim — placed after that, a Paths-only claim would |
| + // never reach this block. |
| + if len(c.Paths) > 0 { |
| + hostRt, ok := byHost[c.Host] |
| + if !ok { |
| + // The hostname's own route is not on this box; another box |
| + // derives these rows. |
| + } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| + log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s", |
| + c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App) |
| + } else { |
| + // Sorted so the cap below truncates DETERMINISTICALLY: every |
| + // box must derive the identical subset, or the same URL would |
| + // resolve differently depending on which edge answered. |
| + prefixes := make([]string, 0, len(c.Paths)) |
| + for p := range c.Paths { |
| + prefixes = append(prefixes, p) |
| + } |
| + sort.Strings(prefixes) |
| + if len(prefixes) > MaxPathsPerClaim { |
| + log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order", |
| + c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim) |
| + prefixes = prefixes[:MaxPathsPerClaim] |
| + } |
| + // Unresolved targets are counted and named ONCE per host per |
| + // pass, not logged per prefix: this pass runs every ~2s on |
| + // every box, and a 10k-entry map whose targets are all |
| + // elsewhere would otherwise write 10k lines every two |
| + // seconds. The count is the operator's signal; the first few |
| + // names are enough to identify which map is wrong. |
| + unresolved := 0 |
| + var unresolvedEg []string |
| + for _, prefix := range prefixes { |
| + target := c.Paths[prefix] |
| + if !validPathPrefix(prefix) { |
| + log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix) |
| + continue |
| + } |
| + tgtRt, ok := byHost[target] |
| + // The authorization rule this feature adds: a prefix must |
| + // never point another tenant's traffic at your backend. |
| + // Account only — an account routing between its own apps |
| + // (classic and Go ARE two apps) is the whole use case. |
| + // Checked before readiness so a cross-tenant target is |
| + // named as cross-tenant rather than swept into the |
| + // unresolved count below. |
| + if ok && release.RouteAccount(tgtRt) != c.Account { |
| + log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s", |
| + c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account) |
| + continue |
| + } |
| + if !ok || tgtRt.Status != "ready" { |
| + // The target is not a local READY route, which has |
| + // three distinct causes with three different |
| + // remedies, and the log below cannot tell them apart |
| + // from here: |
| + // |
| + // 1. The target route does not exist anywhere — a |
| + // typo in the published map, or a route deleted |
| + // out from under it. Remedy: fix the map, or |
| + // re-create the route. |
| + // 2. The target exists but is HOMED ON ANOTHER BOX. |
| + // byHost holds this box's routes only, and there |
| + // is no backhaul equivalent for a path target |
| + // (backhaul fires on a HOST miss), so nothing |
| + // derives the row anywhere. Remedy: co-locate — |
| + // a prefix's target must be homed on the same box |
| + // as the hostname's own route (design spec §6). |
| + // 3. The target is local but not ready yet — a |
| + // route mid-provision. Self-resolving; the row |
| + // appears on the pass after it goes ready. |
| + // |
| + // No row either way, so the prefix falls through to |
| + // the host's own route — the right failure direction |
| + // for a migration: a missing destination serves the |
| + // ORIGIN, never a 404. Silent traffic is the danger |
| + // here, not broken traffic, which is why this is |
| + // logged at all. |
| + unresolved++ |
| + if len(unresolvedEg) < 3 && !slices.Contains(unresolvedEg, target) { |
| + unresolvedEg = append(unresolvedEg, target) |
| + } |
| + continue |
| + } |
| + desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{ |
| + Host: c.Host, Prefix: prefix, Target: target, |
| + Account: c.Account, App: c.App, |
| + } |
| + } |
| + if unresolved > 0 { |
| + log.Printf("domains: paths on %s: %d prefix(es) name no local ready route (e.g. %s) — the target is missing, or it is homed on another box; a path target must be homed on the SAME box as %s. Those prefixes serve %s's own route.", |
| + c.Host, unresolved, strings.Join(unresolvedEg, ", "), c.Host, c.Host) |
| + } |
| + } |
| + } |
| + |
| // Wildcard/catch-all derivation. A claim with Catchall but not |
| // Wildcard cannot come from the write path (SetClaimCapabilities |
| // refuses it), but a hand-written or pre-migration claim could carry |
| @@ -220,5 +342,27 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| } |
| } |
| + |
| + // route_paths is wholly pass-owned, like route_aliases and |
| + // wildcard_domains: every row traces to a claim's Paths entry, so a |
| + // prefix removed from a republished map disappears here with no delete |
| + // verb anywhere. That is exactly how a per-account rollback reaches the |
| + // edge. |
| + existingPaths, err := reg.ListPaths() |
| + if err != nil { |
| + return err |
| + } |
| + for _, p := range desiredPaths { |
| + if err := reg.UpsertPath(p); err != nil { |
| + log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + for _, p := range existingPaths { |
| + if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok { |
| + if err := reg.DeletePath(p.Host, p.Prefix); err != nil { |
| + log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + } |
| return nil |
| } |
diff --git a/internal/domains/aliases_paths_test.go b/internal/domains/aliases_paths_test.go| new file mode 100644 |
| index 000000000..db02d65cc |
| --- /dev/null |
| +++ b/internal/domains/aliases_paths_test.go |
| @@ -0,0 +1,269 @@ |
| +package domains |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// pathsByPrefix collapses ListPaths into a prefix -> target map for one host. |
| +func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string { |
| + t.Helper() |
| + rows, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for _, p := range rows { |
| + if p.Host == host { |
| + out[p.Prefix] = p.Target |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes |
| +// route_paths rows. |
| +func TestReconcileDerivesPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got) |
| + } |
| +} |
| + |
| +// TestReconcileRemovesUndesiredPathRows is the rollback path and the |
| +// pass-ownership property: a prefix dropped from the published map disappears |
| +// from the table on the next pass, with no delete verb anywhere. |
| +func TestReconcileRemovesUndesiredPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}, |
| + }) |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := pathsByPrefix(t, reg, "ti.to") |
| + if _, still := got["bigcorp"]; still { |
| + t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got) |
| + } |
| + if got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca still routed", got) |
| + } |
| +} |
| + |
| +// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization |
| +// rule in this feature: a prefix must never point another tenant's traffic at |
| +// your backend. |
| +func TestReconcileRefusesCrossTenantTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "victim.example", "someone-else", "their-app") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "victim.example"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsUnknownTarget: a target that is not a local route yields |
| +// no row, so the prefix falls through to the host's own route. That is the |
| +// correct failure direction for a migration — a missing Go route serves |
| +// classic rather than 404ing. |
| +func TestReconcileSkipsUnknownTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "not-deployed-here.example"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to |
| +// the hostname's own route. If that route lives on another box, this box |
| +// derives nothing — the same per-box rule the alias half already follows. |
| +func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got) |
| + } |
| +} |
| + |
| +// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated |
| +// deterministically (sorted key order), never silently and never at random. |
| +func TestReconcileCapsPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + big := make(map[string]string, MaxPathsPerClaim+10) |
| + for i := 0; i < MaxPathsPerClaim+10; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: big, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := pathsByPrefix(t, reg, "ti.to") |
| + if len(got) != MaxPathsPerClaim { |
| + t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim) |
| + } |
| + // WHICH prefixes survive, not just how many. The count alone passes with |
| + // the sort deleted, and the sort is the safety-critical half: every box |
| + // folds the same claim independently, so a truncation that varied with |
| + // Go's map iteration order would let the same URL resolve to different |
| + // backends depending on which edge answered. |
| + want := make([]string, 0, len(big)) |
| + for p := range big { |
| + want = append(want, p) |
| + } |
| + sort.Strings(want) |
| + want = want[:MaxPathsPerClaim] |
| + for _, p := range want { |
| + if _, ok := got[p]; !ok { |
| + t.Fatalf("prefix %q was dropped; the surviving set must be the first %d in SORTED order, identically on every box", p, MaxPathsPerClaim) |
| + } |
| + } |
| +} |
| + |
| +// TestReconcileSkipsUnreadyTarget: a target route that exists on this box but |
| +// is not ready yet contributes no row (design spec §4, "a target that is not |
| +// a local READY route contributes no row"). The edge filters unready routes |
| +// out of its map anyway, so traffic already fell through safely — what this |
| +// gate buys is that `carlos routes` and the edge agree about which prefixes |
| +// are actually serving, and that surface is the one built for diagnosing a |
| +// flip. |
| +func TestReconcileSkipsUnreadyTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", App: "classic", Account: "tito", Kind: "instance", Status: "provisioning", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row for a target that is not ready", got) |
| + } |
| +} |
| + |
| +// TestReconcileLogsUnresolvedTargetsOncePerHost is the critical-silence fix. |
| +// A target homed on ANOTHER BOX is the likeliest real-world refusal in this |
| +// feature — `ti.to` and `go.tito.io` are two very different apps — and it |
| +// used to be the ONLY refusal in the path block that said nothing at all, so |
| +// a migration could be published, accepted, and never route, forever, with |
| +// no signal. It must log; and because this pass runs every ~2s against a map |
| +// that may hold 10k entries, it must log ONE line per host per pass. |
| +func TestReconcileLogsUnresolvedTargetsOncePerHost(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{ |
| + "paulca": "elsewhere.example", |
| + "bigcorp": "elsewhere.example", |
| + "acme": "also-elsewhere.example", |
| + }, |
| + }) |
| + |
| + var buf bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(old) |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + log.SetOutput(old) |
| + |
| + out := buf.String() |
| + if n := strings.Count(out, "name no local ready route"); n != 1 { |
| + t.Fatalf("logged the unresolved-target line %d times, want exactly one per host per pass:\n%s", n, out) |
| + } |
| + for _, want := range []string{"ti.to", "3 prefix(es)", "elsewhere.example", "homed on another box"} { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("unresolved-target log is missing %q:\n%s", want, out) |
| + } |
| + } |
| +} |
diff --git a/internal/domains/claimcache_test.go b/internal/domains/claimcache_test.go| index 985dffad1..6b74485cb 100644 |
| --- a/internal/domains/claimcache_test.go |
| +++ b/internal/domains/claimcache_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "os" |
| + "reflect" |
| "testing" |
| "time" |
| @@ -117,7 +118,7 @@ func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) { |
| t.Errorf("cached fold invented %s", got.Host) |
| continue |
| } |
| - if got != want { |
| + if !reflect.DeepEqual(got, want) { |
| t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want) |
| } |
| } |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index ec8c8533e..935a8b062 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -55,6 +55,22 @@ type Claim struct { |
| // the same change that first writes it. |
| Wildcard bool `json:"wildcard,omitempty"` |
| Catchall bool `json:"catchall,omitempty"` |
| + // Paths splits this hostname's traffic by leading URL segment: a bare |
| + // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the |
| + // route that serves it. Absent or empty means this host routes by |
| + // hostname alone — the behaviour of every host today, and the reason this |
| + // field is additive in the strongest sense: no row, no map, no change. |
| + // |
| + // This is a THIRD claim capability alongside Wildcard/Catchall, and it is |
| + // declared here for the reason stated above them — converge preserves only |
| + // DECLARED fields, so an undeclared field would be stripped by any binary |
| + // that rewrote the claim. |
| + // |
| + // The writer is the tenant's own platform, publishing the whole map |
| + // regenerated from its own database. Deltas were rejected deliberately: a |
| + // lost delta desyncs the two systems silently, whereas a wholesale |
| + // republish is always a full repair. |
| + Paths map[string]string `json:"paths,omitempty"` |
| } |
| // ErrHostUnavailable is returned by AttachClaim when host is already claimed |
| @@ -316,3 +332,132 @@ func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, a |
| } |
| } |
| } |
| + |
| +// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains |
| +// this defends no external budget — there is no ACME equivalent for a path |
| +// row — it bounds the claim object's size and the per-box fold that reads it |
| +// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB |
| +// object refolded every couple of seconds, which is comfortable; sharding a |
| +// host's map across objects is deliberately not built until a real host |
| +// approaches this number. |
| +const MaxPathsPerClaim = 10000 |
| + |
| +// ErrTooManyPaths is returned by SetClaimPaths for a map above |
| +// MaxPathsPerClaim. Refused whole: a silently truncated map would route a |
| +// subset of accounts and look successful. |
| +var ErrTooManyPaths = errors.New("too many path prefixes") |
| + |
| +// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare |
| +// 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 |
| +// a first segment could never match and must not be stored. |
| +func validPathPrefix(p string) bool { |
| + if p == "" || len(p) > 255 { |
| + return false |
| + } |
| + for i := 0; i < len(p); i++ { |
| + c := p[i] |
| + switch { |
| + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': |
| + case c == '-' || c == '_' || c == '.' || c == '~': |
| + default: |
| + return false |
| + } |
| + } |
| + // "." and ".." would be normalised away by any path cleaner between the |
| + // browser and here, so a row keyed on either is unreachable by |
| + // construction. |
| + return p != "." && p != ".." |
| +} |
| + |
| +// SetClaimPaths is the ONLY writer of a claim's Paths field, the same |
| +// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall |
| +// and for the same reason: AttachClaim's converge repoints Route and nothing |
| +// else, so it can preserve this map but must never invent or strip it. |
| +// |
| +// The write is WHOLESALE — paths replaces the stored map rather than merging |
| +// into it. That is the contract the caller's platform relies on: it |
| +// regenerates the map from its own database and publishes the result, so an |
| +// entry it no longer lists must disappear. Merging would make rollback |
| +// impossible to express. |
| +// |
| +// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold |
| +// the claim gets the neutral ErrHostUnavailable, and a missing claim answers |
| +// identically, so this door is not an existence oracle. |
| +func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) { |
| + if len(paths) > MaxPathsPerClaim { |
| + return false, ErrTooManyPaths |
| + } |
| + for prefix, target := range paths { |
| + if !validPathPrefix(prefix) { |
| + return false, ErrBadPathPrefix |
| + } |
| + // The target must be a plausible hostname. Whether it is a route this |
| + // 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: %w", prefix, target, ErrBadPathTarget) |
| + } |
| + } |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, ErrHostUnavailable |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != account || cur.App != app { |
| + return false, ErrHostUnavailable |
| + } |
| + if samePaths(cur.Paths, paths) { |
| + return false, nil |
| + } |
| + next := *cur |
| + if len(paths) == 0 { |
| + next.Paths = nil // omitempty: an empty map and no map are one state |
| + } else { |
| + next.Paths = make(map[string]string, len(paths)) |
| + for k, v := range paths { |
| + next.Paths[k] = v |
| + } |
| + } |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 { |
| + continue |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| + |
| +// samePaths reports whether two prefix maps are equal, treating nil and empty |
| +// as the same state. Republishing an unchanged map is the COMMON case — the |
| +// caller republishes wholesale as a repair — so this is what keeps a repair |
| +// from costing a bucket write and a fleet-wide edge reload. |
| +func samePaths(a, b map[string]string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k, v := range a { |
| + if b[k] != v { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go| index 72513ef18..0a3fd8063 100644 |
| --- a/internal/domains/claims_test.go |
| +++ b/internal/domains/claims_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "os" |
| + "reflect" |
| "strings" |
| "testing" |
| @@ -53,7 +54,7 @@ func TestAttachClaimIdempotent(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if *before != *after { |
| + if !reflect.DeepEqual(before, after) { |
| t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after) |
| } |
| } |
| @@ -117,7 +118,7 @@ func TestAttachClaimOtherOwnerRefused(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if *before != *after { |
| + if !reflect.DeepEqual(before, after) { |
| t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after) |
| } |
| } |
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go| new file mode 100644 |
| index 000000000..7e172915b |
| --- /dev/null |
| +++ b/internal/domains/paths_test.go |
| @@ -0,0 +1,254 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strconv" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestSetClaimPathsRoundTrip: the map written is the map read back. |
| +func TestSetClaimPathsRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !changed { |
| + t.Fatal("changed = false on the first write of a paths map") |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito |
| +// regenerates the whole map from its own database, so an account it no longer |
| +// lists must disappear — that is how a rollback reaches the edge. |
| +func TestSetClaimPathsIsWholesale(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, still := c.Paths["bigcorp"]; still { |
| + t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account |
| +// is across, Tito repoints the host's own route and publishes an empty map. |
| +func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.Paths) != 0 { |
| + t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets |
| +// the same neutral ErrHostUnavailable the attach door uses — no existence |
| +// oracle, byte-identical to the answer for a claim that does not exist. |
| +func TestSetClaimPathsIsOwnerGated(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic", |
| + map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic", |
| + map[string]string{"a": "b.example.com"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRejectsUnnormalisedTarget: a target that is not a |
| +// normalised hostname (no interior dot, wrong case, trailing dot, ...) is |
| +// refused with ErrBadPathTarget — this is an owned, existing claim, so the |
| +// failure can only be about the target shape, not about who is asking or |
| +// whether the host exists. |
| +// |
| +// Asserted through the SENTINEL, not merely as "non-nil and not |
| +// ErrHostUnavailable". The API door answers this class as the CALLER's error |
| +// (422, naming the entry) rather than as a store failure (502, "try again"), |
| +// and errors.Is is the only thing that tells it which it has — so a lost %w |
| +// wrap would silently start telling the publishing platform to retry a body |
| +// that will fail identically forever. A non-nil check passes either way and |
| +// would not catch it. |
| +func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Every shape NormalizeAliasHost turns away, and each one is something a |
| + // generated map could plausibly carry. |
| + for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "", "go tito io"} { |
| + _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": target}) |
| + if !errors.Is(err, ErrBadPathTarget) { |
| + t.Fatalf("target %q: err = %v, want ErrBadPathTarget", target, err) |
| + } |
| + if errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("target %q: err = %v, want the target-shape error, not ErrHostUnavailable", target, err) |
| + } |
| + // Refused WHOLE: a map with one bad entry stores none of it. |
| + if c, err := GetClaim(ctx, s, "ti.to"); err != nil { |
| + t.Fatal(err) |
| + } else if len(c.Paths) != 0 { |
| + t.Fatalf("target %q: claim paths = %v, want none", target, c.Paths) |
| + } |
| + } |
| +} |
| + |
| +// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not |
| +// burn a bucket write. Tito republishes wholesale as a repair, so the no-op |
| +// case is the common one. |
| +func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := map[string]string{"paulca": "go.tito.io"} |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed { |
| + t.Fatal("changed = true for an identical republish — a no-op must not write") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an |
| +// oversize map is refused whole rather than silently truncated into the |
| +// bucket. (The reconcile pass caps again, defensively, for hand-written |
| +// claims — see Task 3.) |
| +func TestSetClaimPathsRefusesOversizeMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + big := make(map[string]string, MaxPathsPerClaim+1) |
| + for i := 0; i <= MaxPathsPerClaim; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) { |
| + t.Fatalf("err = %v, want ErrTooManyPaths", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A |
| +// leading slash, an embedded slash, or an empty key would each produce a row |
| +// the edge could never match, so they are refused at the door rather than |
| +// stored as dead weight. |
| +func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} { |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) { |
| + t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err) |
| + } |
| + } |
| +} |
| + |
| +// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map. |
| +func TestAttachConvergePreservesPaths(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 9c5a1bc42..5d3ee5b39 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -36,6 +36,7 @@ import ( |
| "net/textproto" |
| "os" |
| "os/signal" |
| + "path" |
| "path/filepath" |
| "slices" |
| "strconv" |
| @@ -254,6 +255,18 @@ type upstream struct { |
| // the flag exists so wildcard parent derivation (currentHosts) never |
| // treats a customer hostname as a platform host. |
| alias bool |
| + // paths, when non-nil, splits this hostname's traffic by leading URL |
| + // segment: a bare first segment ("paulca") mapped to the HOSTNAME of the |
| + // route that serves it, resolved against the same live map at request |
| + // time. Nil for every host that does not use prefix routing, which is |
| + // every host on the platform until a claim publishes a map — the nil |
| + // check in lookupUpstreamPath is the entire cost this feature imposes on |
| + // everyone else. |
| + // |
| + // Hostnames rather than resolved upstreams, deliberately: an upstream |
| + // captured here would be a stale copy of the target's entry the moment |
| + // the target's route changed within the same map build. |
| + paths map[string]string |
| } |
| // New builds an Edge over reg and performs the startup reload. act may be nil |
| @@ -581,6 +594,33 @@ func (e *Edge) Reload() error { |
| } |
| m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib} |
| } |
| + // Path routes, folded BEFORE aliases so an alias of a prefix-routed |
| + // hostname inherits its map along with everything else it copies — an |
| + // alias is meant to be indistinguishable from its primary, and a |
| + // hostname that routed by prefix under one name but not the other would |
| + // be a trap. |
| + // |
| + // Targets are recorded as hostnames, not resolved here: every route is |
| + // already in m by this point, but resolving now would freeze a copy of |
| + // the target's entry, and the target's own row may still be rewritten by |
| + // the alias fold below. |
| + paths, err := e.reg.ListPaths() |
| + if err != nil { |
| + return err |
| + } |
| + for _, p := range paths { |
| + up, ok := m[p.Host] |
| + if !ok { |
| + // The hostname has no route on this box. The reconcile pass |
| + // should not have produced this row; nothing to attach it to. |
| + continue |
| + } |
| + if up.paths == nil { |
| + up.paths = make(map[string]string) |
| + } |
| + up.paths[p.Prefix] = p.Target |
| + m[p.Host] = up |
| + } |
| // Attached hostnames (#151): each alias resolves to the primary's OWN |
| // entry — same proxy value, same route (so activator keying, version |
| // header, and purge tenancy are all the primary's). Routes always win |
| @@ -849,13 +889,15 @@ func (e *Edge) deploymentHost(host string) bool { |
| return e.Allowed(host) || e.homes.Load().KnownHost(host) |
| } |
| -// lookupUpstream resolves host to its upstream entry — the request-routing |
| -// lookup ServeHTTP uses (NOT Allowed's autocert HostPolicy — see the comment |
| -// there). An exact match (a real route, or a plain #151 alias landed in the |
| -// same map) always wins; on a miss, a one-label wildcard key folds in: |
| -// "*.<parent>" is the shape Task 3's reconcile pass derives from a claim's |
| -// catch-all row, and it lands in the SAME map as every other alias |
| -// (up.alias = true, primary's route + proxy verbatim) so this fallback |
| +// lookupIn resolves host to its upstream entry within a given map — the |
| +// HOST half of the request-routing lookup (NOT Allowed's autocert HostPolicy |
| +// — see the comment there). ServeHTTP does not call it directly: it calls |
| +// lookupUpstreamPath, which wraps this and then resolves a leading path |
| +// segment for the hostnames that carry a prefix map. An exact match (a real route, or a plain #151 |
| +// alias landed in the same map) always wins; on a miss, a one-label wildcard |
| +// key folds in: "*.<parent>" is the shape Task 3's reconcile pass derives |
| +// from a claim's catch-all row, and it lands in the SAME map as every other |
| +// alias (up.alias = true, primary's route + proxy verbatim) so this fallback |
| // resolves to the primary's own entry — the identical map value the |
| // #151/#152 agreed rule requires, not a value that merely behaves the same. |
| // A wildcard matches exactly one label, mirroring the TLS cert: host[i:] |
| @@ -863,8 +905,7 @@ func (e *Edge) deploymentHost(host string) bool { |
| // (a.b.woodstar.app) composes "*.b.woodstar.app" and misses, and the apex |
| // itself (woodstar.app) composes "*.app" — never its own "*.woodstar.app" — |
| // so it can never match its own wildcard. |
| -func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| - m := e.current() |
| +func lookupIn(m map[string]upstream, host string) (upstream, bool) { |
| if up, ok := m[host]; ok { |
| return up, true |
| } |
| @@ -876,6 +917,201 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| return upstream{}, false |
| } |
| +// lookupUpstream resolves a hostname alone. Kept as the primitive because not |
| +// every caller is answering "where does this REQUEST go" — the :80->:443 |
| +// routability check in httpFallbackHandler asks only whether this box serves |
| +// the hostname at all, and giving that question a path would make a |
| +// hostname's routability depend on which URL happened to ask. |
| +func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| + return lookupIn(e.current(), host) |
| +} |
| + |
| +// lookupUpstreamPath resolves a request: the hostname exactly as |
| +// lookupUpstream does, then — only for a hostname carrying a prefix map — the |
| +// first path segment. |
| +// |
| +// Both lookups read ONE snapshot of the map. Calling lookupUpstream and then |
| +// current() again would let a reload land between them and resolve a prefix |
| +// against a different table than the host came from. |
| +// |
| +// Every miss falls through to the hostname's own route rather than failing: |
| +// an unlisted prefix, a dangling target, a request for "/". A prefix map is a |
| +// migration in progress, and the un-migrated answer is always the safe one. |
| +// |
| +// ATTRIBUTION: a path hit returns the TARGET's upstream entry wholesale, so |
| +// everything downstream that reads up.route reads the target's row, not the |
| +// requested hostname's. Four consequences, named here because this is where a |
| +// reader will look for them: |
| +// |
| +// 1. Usage metering bills the TARGET's route, account and app (the |
| +// UsageMeter wrap in ServeHTTP keys on up.route.Host). Correct: the |
| +// target's backend did the work and burned the bytes. Pinned by |
| +// TestPathRoutedRequestMetersAgainstTheTarget — a later "fix" that |
| +// attributes to the requested hostname would double-count nothing and |
| +// bill the wrong app. |
| +// 2. The X-Carlos-Version response header reports the TARGET's release. |
| +// Correct: it names the code that produced the response. |
| +// 3. Purge tenancy is the TARGET's app and account (purgeModifier is built |
| +// from up.route). Correct as tenancy — but it is also a real GAP, see |
| +// below. |
| +// 4. Activator keying and hibernation are the TARGET's (up.hibernating and |
| +// up.route come from the same entry), so a request for a flipped prefix |
| +// wakes the target instance, not the requested hostname's. Correct: the |
| +// target is the process that has to be awake to answer. |
| +// |
| +// The gap in (3): the response CACHE keys on the REQUESTED host |
| +// (CacheKey{Host: host} in ServeHTTP), while purgeModifier resolves the purge |
| +// host from the target's route. So a path-routed app emitting Carlos-Purge |
| +// purges under go.tito.io while its entry is stored under ti.to, and nothing |
| +// is dropped; the documented Carlos-Purge-Host: ti.to escape hatch does not |
| +// help either, because resolvePurgeHost requires the override to name a host |
| +// in the SAME app — and path routing exists precisely to cross an app |
| +// boundary. Aliases never hit this: an alias key maps to the primary's own |
| +// route, so the app comparison passes trivially. |
| +// |
| +// The bound on that gap: a path-routed response can go stale for at most the |
| +// cache's max TTL, and only for responses that opted into caching with a |
| +// positive max-age. Uncached responses are unaffected. Widening the purge |
| +// tenancy model to follow a prefix map is a design change this feature did |
| +// not make; it is recorded as a known gap in design spec §11. |
| +func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) { |
| + m := e.current() |
| + up, ok := lookupIn(m, host) |
| + if !ok || up.paths == nil { |
| + return up, ok |
| + } |
| + // BOTH FORMS, cleaned first. The prefix map is consulted for the |
| + // path.Clean'd first segment and then, if that missed and the two differ, |
| + // for the raw one. When both name a prefix, the cleaned one wins. |
| + // |
| + // Neither form alone is safe, and they fail in OPPOSITE directions. The |
| + // invariant being protected is one-way: during a reversible dual-run |
| + // migration, the app an account was flipped AWAY from must never answer |
| + // for that account. Serving a not-yet-flipped account from the NEW app is |
| + // the benign direction — at worst a 404 while the migration runs. |
| + // |
| + // - RAW alone misses "//paulca/foo". Rails and most other backends |
| + // collapse that to "/paulca/foo" and serve it, so the request falls |
| + // through to the legacy app, which then answers for a flipped |
| + // account. One character, silent. |
| + // - CLEANED alone misses "/paulca/../bob", which resolves to "bob". A |
| + // backend that does NOT collapse ".." — or that routes on the first |
| + // segment with a glob like "/:account/*rest" — then serves paulca's |
| + // page from the legacy app. The same bypass, reached the other way. |
| + // |
| + // Taking a hit from either form closes both: "//paulca/foo" hits on the |
| + // cleaned form, "/paulca/../bob" hits on the raw one, and a flipped |
| + // account reaches its new app whatever the backend does with the odd |
| + // shape. The cost is that "/paulca/../bob" reaches the NEW app carrying |
| + // "/paulca/../bob", which a normalising target resolves to "/bob" and |
| + // 404s — an account it was not given. Benign direction, and deliberate. |
| + // |
| + // Residue, measured rather than assumed. None of it crosses the |
| + // invariant above, and it is written down so nobody has to re-derive it: |
| + // |
| + // - "/%2e%2e/bob": r.URL.Path is already percent-DECODED, so this |
| + // cleans to "/bob" and matches "bob". The upstream receives the raw |
| + // "/%2e%2e/bob" and will most likely 404 it. Harmless. |
| + // - "/paulca%2ffoo": decodes to "/paulca/foo", so both forms match |
| + // "paulca" and the request reaches paulca's new app — while a strict |
| + // backend treats the upstream path as the SINGLE segment |
| + // "paulca/foo". A mis-flip toward the NEW app: the safe direction. |
| + // - A prefix literally named "." or ".." cannot exist: validPathPrefix |
| + // (internal/domains/claims.go) refuses both, so the raw form of |
| + // "/../bob" ("..") can never match anything. |
| + // |
| + // Do not collapse this back to a single form. Whichever one you keep, |
| + // you reopen the bypass the other one closes. |
| + // |
| + // The scope of the guarantee, stated so it is not over-read: the two |
| + // forms model percent-decoding and dot-segment removal, and the |
| + // invariant holds across every combination of a backend doing or not |
| + // doing those two. It is NOT a claim that no normalisation exists. A |
| + // backend that folds case ("/PAULCA/foo"), strips servlet-style path |
| + // parameters ("/paulca;jsessionid=x/foo"), accepts backslash |
| + // separators, strips trailing dots, or double-decodes could resolve a |
| + // path neither form matches into a flipped account's subtree. Rails |
| + // does none of these — Rack keeps ";" in PATH_INFO and routing is |
| + // case-sensitive — so Tito is not exposed, but a future consumer with |
| + // different normalisation would need this reasoning redone rather than |
| + // inherited. |
| + // |
| + // No guard is needed for the empty segment ("/", "//", ""): validPathPrefix |
| + // refuses an empty prefix, so no row can be keyed on one and both lookups |
| + // below simply miss, falling through to the host's own route. |
| + seg := firstSegmentClean(urlPath) |
| + raw := firstSegment(urlPath) |
| + target, ok := up.paths[seg] |
| + if !ok && raw != seg { |
| + target, ok = up.paths[raw] |
| + } |
| + if !ok { |
| + return up, true |
| + } |
| + // m[target], not lookupIn(m, target): the TARGET is resolved exactly, |
| + // while the host half above also accepts a one-label wildcard key. The |
| + // asymmetry is deliberate and it is documented rather than removed — |
| + // route_paths targets are hostnames the reconcile pass verified against a |
| + // real local route (aliases.go), and a real route is always an exact key, |
| + // so a target reachable ONLY through "*.parent" cannot be produced by any |
| + // supported writer. If one ever were, it would miss here and fall through |
| + // to the host's own route, which is this function's safe direction |
| + // anyway. |
| + tu, ok := m[target] |
| + if !ok { |
| + return up, true |
| + } |
| + // ONE HOP, deliberately: if tu itself carries a prefix map, it is NOT |
| + // re-resolved against this request's path. A prefix selects a route, and |
| + // the selected route's own map belongs to ITS hostname's traffic, not to |
| + // this one's. Do not add recursion here — chaining buys nothing the |
| + // publisher cannot express by pointing the prefix at the final target |
| + // directly, and it would introduce a cycle to detect. |
| + return tu, true |
| +} |
| + |
| +// firstSegment returns the first path segment of an absolute URL path, |
| +// without slashes, splitting the path EXACTLY as it arrived: |
| +// "/paulca/awesomeconf" -> "paulca". "" for "/", for the empty string, and |
| +// for anything not starting with "/". |
| +// |
| +// Returning the segment rather than testing a string prefix is what makes |
| +// matching segment-bounded: "/paulcarson" yields "paulcarson", which is not |
| +// equal to "paulca", so a longer account name can never be dragged across a |
| +// migration by a shorter one. |
| +// |
| +// This is the RAW half of the matcher. See lookupUpstreamPath's match block |
| +// for why both halves exist and why neither is safe alone. |
| +func firstSegment(p string) string { |
| + if p == "" || p[0] != '/' { |
| + return "" |
| + } |
| + p = p[1:] |
| + if i := strings.IndexByte(p, '/'); i >= 0 { |
| + p = p[:i] |
| + } |
| + return p |
| +} |
| + |
| +// firstSegmentClean is firstSegment over the path.Clean'd path, so leading |
| +// empty segments and dot segments resolve before the split: |
| +// |
| +// "//paulca/foo" -> "paulca" (leading empty segments collapse) |
| +// "/./paulca" -> "paulca" (dot segment collapses) |
| +// "/paulca/../bob" -> "bob" (".." resolves) |
| +// "/paulca/.." -> "" (resolves to "/", the host's own root) |
| +// "/paulca/" -> "paulca" (a trailing slash is not a second segment) |
| +// |
| +// This is the CLEANED half of the matcher; the raw half is firstSegment. |
| +func firstSegmentClean(p string) string { |
| + if p == "" || p[0] != '/' { |
| + return "" |
| + } |
| + // Clean of a rooted path is rooted, and is "/" when everything resolves |
| + // away — so the slice below is always safe and yields "" for the root. |
| + return firstSegment(path.Clean(p)) |
| +} |
| + |
| func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| host := r.Host |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| @@ -927,7 +1163,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // nothing else. |
| r, hopped := e.acceptHop(r, host) |
| - up, ok := e.lookupUpstream(host) |
| + up, ok := e.lookupUpstreamPath(host, r.URL.Path) |
| if !ok { |
| if hopped { |
| // A PROVEN hop from a sibling edge, for a host that has moved (or |
diff --git a/internal/edge/path_e2e_test.go b/internal/edge/path_e2e_test.go| new file mode 100644 |
| index 000000000..67ed8bc26 |
| --- /dev/null |
| +++ b/internal/edge/path_e2e_test.go |
| @@ -0,0 +1,117 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestPathFlipEndToEnd walks the whole chain a real flip takes — claim |
| +// published, reconcile pass, fingerprint moves, edge reloads, traffic changes |
| +// — and then walks it backwards. Each earlier task proved one layer against a |
| +// hand-written input; this is the only test that proves they compose. |
| +// |
| +// The registry write and the edge reload are driven directly rather than |
| +// waited on: the 2s tick is a latency bound, not a correctness property, and |
| +// polling it would make this test slow and flaky. What must be true is that |
| +// the fingerprint MOVES, which is asserted rather than assumed. |
| +func TestPathFlipEndToEnd(t *testing.T) { |
| + ctx := context.Background() |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", Account: "tito", App: "classic", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", Account: "tito", App: "go", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + store := release.NewDirStore(t.TempDir()) |
| + if _, _, err := domains.AttachClaim(ctx, store, domains.Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + // Before the flip: everything is classic. |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| + t.Fatalf("before the flip: body = %q, want classic", body) |
| + } |
| + |
| + fpBefore, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The flip: Tito publishes its map, the pass folds it, the edge reloads. |
| + if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + fpAfter, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fpBefore == fpAfter { |
| + t.Fatal("fingerprint did not move across a flip — the edge's watcher would never reload") |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // After the flip: that account is on Go, its neighbours are not. |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "go" { |
| + t.Fatalf("after the flip: body = %q, want go", body) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/someoneelse/event"); body != "classic" { |
| + t.Fatalf("after the flip: a neighbouring account moved too, body = %q", body) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulcarson"); body != "classic" { |
| + t.Fatalf("after the flip: a longer account name was dragged along, body = %q", body) |
| + } |
| + |
| + // The rollback: republish without that account. |
| + if _, err := domains.SetClaimPaths(ctx, store, "ti.to", "tito", "classic", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.ReconcileAliases(ctx, store, reg, time.Now, nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf"); body != "classic" { |
| + t.Fatalf("after the rollback: body = %q, want classic", body) |
| + } |
| + rows, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 0 { |
| + t.Fatalf("after the rollback: %d path rows remain, want 0", len(rows)) |
| + } |
| +} |
diff --git a/internal/edge/path_test.go b/internal/edge/path_test.go| new file mode 100644 |
| index 000000000..b6c48e3ba |
| --- /dev/null |
| +++ b/internal/edge/path_test.go |
| @@ -0,0 +1,471 @@ |
| +package edge |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// upsertPath writes one route_paths row, the shape the domains reconcile pass |
| +// derives from a claim's Paths map. |
| +func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) { |
| + t.Helper() |
| + if err := reg.UpsertPath(registry.RoutePath{ |
| + Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app", |
| + }); err != nil { |
| + t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err) |
| + } |
| +} |
| + |
| +// splitHost stands up one hostname whose default route is "classic" and whose |
| +// "flipped" prefix reaches a second route, returning the live test server. |
| +func splitHost(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the |
| +// other route's backend while the same hostname's other traffic does not. |
| +func TestPathRouteReachesTarget(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, tc := range []struct{ path, want string }{ |
| + {"/flipped", "go"}, |
| + {"/flipped/awesomeconf", "go"}, |
| + {"/flipped/awesomeconf/admin", "go"}, |
| + {"/notflipped", "classic"}, |
| + {"/notflipped/event", "classic"}, |
| + {"/", "classic"}, |
| + {"", "classic"}, |
| + } { |
| + code, body := getHostPath(t, srv.URL, "ti.to", tc.path) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", tc.path, code) |
| + } |
| + if body != tc.want { |
| + t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be |
| +// segment-wise equality, never a string prefix — "flippedxyz" is a different |
| +// account from "flipped" and must not be dragged across the migration with it. |
| +func TestPathMatchIsSegmentBounded(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} { |
| + code, body := getHostPath(t, srv.URL, "ti.to", p) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", p, code) |
| + } |
| + if body != "classic" { |
| + t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body) |
| + } |
| + } |
| +} |
| + |
| +// TestPathFoldOrderAliasInheritsPrefixMap pins the fold order in Reload: |
| +// path routes are folded into the map BEFORE aliases (edge.go, the comment |
| +// above the path fold), so an alias of a prefix-routed hostname inherits its |
| +// prefix map along with everything else it copies. Swap that order and this |
| +// test is the one that catches it — every other test in this file talks to |
| +// the primary hostname directly and would keep passing. |
| +func TestPathFoldOrderAliasInheritsPrefixMap(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + upsertAlias(t, reg, "ti-to.alias.example", "ti.to") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + code, body := getHostPath(t, srv.URL, "ti-to.alias.example", "/flipped") |
| + if code != http.StatusOK || body != "go" { |
| + t.Fatalf("alias /flipped: status=%d body=%q, want 200/go — the alias must inherit ti.to's prefix map, which requires path routes to fold BEFORE aliases in Reload", code, body) |
| + } |
| +} |
| + |
| +// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The |
| +// flipped and unflipped URL spaces must be identical, which is the whole |
| +// point of a seamless migration. |
| +func TestPathRouteDoesNotStripThePrefix(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(r.URL.Path)) |
| + })) |
| + t.Cleanup(golive.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf") |
| + if body != "/paulca/awesomeconf" { |
| + t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body) |
| + } |
| +} |
| + |
| +// TestPathMatchTriesBothFormsOfTheFirstSegment is the flip-bypass pin, and it |
| +// pins BOTH directions of it, because the matcher's two halves fail opposite |
| +// ways and each one alone reopens what the other closes. |
| +// |
| +// The invariant: during a reversible dual-run migration, the app an account |
| +// was flipped AWAY from must never answer for that account. |
| +// |
| +// - Raw-split alone yields "" for "//flipped/foo", so the request falls |
| +// through to classic — which most backends then serve as |
| +// "/flipped/foo". One character, silent. (The original I2 finding.) |
| +// - Clean-first alone yields "notflipped" for "/flipped/../notflipped", so |
| +// THAT falls through to classic — and a backend which does not collapse |
| +// ".." (or which routes "/:account/*rest") serves flipped's page from |
| +// classic. Same bypass, reached from the other side. |
| +// |
| +// Consulting both closes both. The cost is the benign direction: a |
| +// normalising target gets asked for an account it was not given and 404s. |
| +func TestPathMatchTriesBothFormsOfTheFirstSegment(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + seen := make(chan string, 16) |
| + golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + seen <- r.URL.Path |
| + w.Write([]byte("go")) |
| + })) |
| + t.Cleanup(golive.Close) |
| + beta, _ := hostEchoBackend(t, "beta") |
| + |
| + reg := openReg(t) |
| + for _, rt := range []registry.Route{ |
| + {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), Status: "ready"}, |
| + {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), Status: "ready"}, |
| + {Host: "beta.tito.io", Kind: "service", Addr: beta.Listener.Addr().String(), Status: "ready"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + upsertPath(t, reg, "ti.to", "onbeta", "beta.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + for _, tc := range []struct{ path, want, why string }{ |
| + // Only the CLEANED form finds the account. |
| + {"//flipped", "go", "leading empty segment"}, |
| + {"//flipped/event", "go", "leading empty segment"}, |
| + {"///flipped/event", "go", "leading empty segments"}, |
| + {"/./flipped", "go", "dot segment"}, |
| + // Only the RAW form finds it. These are the rows that would go to |
| + // classic under a clean-only matcher — the regression this test |
| + // exists to prevent. |
| + {"/flipped/../notflipped", "go", "raw first segment is still flipped"}, |
| + {"/flipped/..", "go", "raw first segment is still flipped"}, |
| + {"/flipped/../", "go", "raw first segment is still flipped"}, |
| + // BOTH forms hit, and they name different prefixes: the CLEANED one |
| + // wins, because that is the account a normalising backend will serve. |
| + {"/flipped/../onbeta", "beta", "cleaned wins when both hit"}, |
| + // Neither form names a flipped account: the origin still answers. |
| + {"/notflipped", "classic", "no prefix matches"}, |
| + {"/notflipped/../alsonot", "classic", "no prefix matches either way"}, |
| + {"/", "classic", "root"}, |
| + {"//", "classic", "root, however spelled"}, |
| + // Segment-bounded as ever; normalisation must not have loosened it. |
| + {"/flippedxyz", "classic", "segment equality, never HasPrefix"}, |
| + {"//flippedxyz/event", "classic", "segment equality after cleaning too"}, |
| + } { |
| + code, body := getHostPath(t, srv.URL, "ti.to", tc.path) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", tc.path, code) |
| + } |
| + if body != tc.want { |
| + t.Fatalf("%s: body = %q, want %q (%s)", tc.path, body, tc.want, tc.why) |
| + } |
| + } |
| + |
| + // Matching normalises; PROXYING does not. The upstream must still receive |
| + // the request's original, uncleaned path — no stripping, no rewriting, so |
| + // the flipped and unflipped URL spaces stay byte-identical (spec §6). |
| + for _, want := range []string{"//flipped", "//flipped/event", "///flipped/event", "/./flipped"} { |
| + if got := <-seen; got != want { |
| + t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want) |
| + } |
| + } |
| + for _, want := range []string{"/flipped/../notflipped", "/flipped/..", "/flipped/../"} { |
| + if got := <-seen; got != want { |
| + t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestPathPreservesInboundHost: the upstream sees the hostname the browser |
| +// asked for, matching the alias contract. Tito Go must be able to build |
| +// absolute URLs that keep the visitor on ti.to. |
| +func TestPathPreservesInboundHost(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, seenHost := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if *seenHost != "ti.to" { |
| + t.Fatalf("upstream saw Host %q, want ti.to", *seenHost) |
| + } |
| +} |
| + |
| +// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the |
| +// live map must serve the hostname's own route, never 404. This is the |
| +// failure direction the whole design depends on. |
| +func TestPathRowForMissingTargetFallsThrough(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "vanished.example") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + code, body := getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if code != http.StatusOK || body != "classic" { |
| + t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body) |
| + } |
| +} |
| + |
| +// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on |
| +// the platform today has no prefix map and must behave exactly as before. |
| +func TestHostsWithoutPathsAreUnaffected(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "plain") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + for _, p := range []string{"/", "/anything", "/deep/path/here"} { |
| + code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p) |
| + if code != http.StatusOK || body != "plain" { |
| + t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body) |
| + } |
| + } |
| +} |
| + |
| +// TestPathRoutedRequestMetersAgainstTheTarget pins the attribution ruling |
| +// documented on lookupUpstreamPath: a path hit returns the TARGET's upstream |
| +// entry wholesale, so the usage meter bills the target's route, account and |
| +// app — not the hostname the browser asked for. That is correct (the target's |
| +// backend burned the bytes) and it is easy to "fix" the wrong way later, so |
| +// it is pinned here. The same entry is what makes the version header, the |
| +// purge tenancy and the activator keying the target's; see that doc comment. |
| +func TestPathRoutedRequestMetersAgainstTheTarget(t *testing.T) { |
| + body := "go" |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, body) |
| + reg := openReg(t) |
| + for _, rt := range []registry.Route{ |
| + {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), |
| + Status: "ready", Account: "tito", App: "classic"}, |
| + {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), |
| + Status: "ready", Account: "tito", App: "titogo"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + meter := NewUsageMeter() |
| + e.EnableUsage(meter) |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + if code, got := getHostPath(t, srv.URL, "ti.to", "/flipped/event"); code != http.StatusOK || got != body { |
| + t.Fatalf("flipped request = %d %q, want 200 %q", code, got, body) |
| + } |
| + |
| + rows := meter.Drain() |
| + row, ok := rows["go.tito.io"] |
| + if !ok { |
| + t.Fatalf("usage rows = %+v, want the TARGET route metered", rows) |
| + } |
| + if row.Requests != 1 || row.BytesOut != int64(len(body)) { |
| + t.Fatalf("go.tito.io row = %+v, want Requests=1 BytesOut=%d", row, len(body)) |
| + } |
| + if row.App != "titogo" { |
| + t.Fatalf("go.tito.io row = %+v, want the TARGET's app, not the requested hostname's", row) |
| + } |
| + if _, ok := rows["ti.to"]; ok { |
| + t.Fatalf("usage rows = %+v, want nothing billed to the requested hostname for a path-routed request", rows) |
| + } |
| +} |
| + |
| +// TestFirstSegment is the matcher's two halves, tested directly because every |
| +// routing decision in this feature rests on them. Both columns are pinned for |
| +// every row, INCLUDING the rows where they disagree — the disagreements are |
| +// the whole reason lookupUpstreamPath consults both (see its match block). |
| +func TestFirstSegment(t *testing.T) { |
| + for _, tc := range []struct{ in, wantRaw, wantClean string }{ |
| + // Ordinary shapes: the two halves agree, and this is every real |
| + // request on the platform. |
| + {"/paulca", "paulca", "paulca"}, |
| + {"/paulca/", "paulca", "paulca"}, |
| + {"/paulca/awesomeconf", "paulca", "paulca"}, |
| + {"/paulca/awesomeconf/admin", "paulca", "paulca"}, |
| + {"/paulca/awesomeconf/", "paulca", "paulca"}, |
| + {"/paulcarson", "paulcarson", "paulcarson"}, // segment equality, never HasPrefix |
| + {"/", "", ""}, |
| + {"", "", ""}, |
| + {"paulca", "", ""}, // not an absolute path; never matches |
| + {"//", "", ""}, |
| + |
| + // Leading empty segments and dot segments: only the CLEANED half |
| + // finds the account. Rails and friends serve //paulca/foo as |
| + // /paulca/foo, so without this half a flipped account falls through |
| + // to the app it was flipped away from — the original I2 bypass. |
| + {"//paulca", "", "paulca"}, |
| + {"//paulca/foo", "", "paulca"}, |
| + {"///paulca/foo", "", "paulca"}, |
| + {"/./paulca", ".", "paulca"}, |
| + {"/paulca/./awesomeconf", "paulca", "paulca"}, |
| + {"/paulca//awesomeconf", "paulca", "paulca"}, |
| + |
| + // ".." shapes: the two halves DISAGREE, and only the RAW half still |
| + // names paulca. A backend that does not collapse ".." would serve |
| + // paulca's page, so dropping the raw half here reopens the same |
| + // bypass from the other side. Consulting both is what closes it. |
| + {"/paulca/../bob", "paulca", "bob"}, |
| + {"/paulca/..", "paulca", ""}, |
| + {"/paulca/../", "paulca", ""}, |
| + {"/../bob", "..", "bob"}, // ".." can never be a prefix (validPathPrefix) |
| + {"/..", "..", ""}, |
| + {"/.", ".", ""}, |
| + } { |
| + if got := firstSegment(tc.in); got != tc.wantRaw { |
| + t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.wantRaw) |
| + } |
| + if got := firstSegmentClean(tc.in); got != tc.wantClean { |
| + t.Fatalf("firstSegmentClean(%q) = %q, want %q", tc.in, got, tc.wantClean) |
| + } |
| + } |
| +} |
| + |
| +// getHostPath issues GET path against srv with an explicit Host header. |
| +func getHostPath(t *testing.T, srvURL, host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", srvURL+path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return resp.StatusCode, string(b) |
| +} |
diff --git a/internal/registry/paths_test.go b/internal/registry/paths_test.go| new file mode 100644 |
| index 000000000..0e4c474db |
| --- /dev/null |
| +++ b/internal/registry/paths_test.go |
| @@ -0,0 +1,132 @@ |
| +package registry |
| + |
| +import "testing" |
| + |
| +// TestPathRoundTrip: a written prefix row comes back, and a repoint of the |
| +// same (host, prefix) replaces rather than duplicates. |
| +func TestPathRoundTrip(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListPaths returned %d rows, want 1", len(got)) |
| + } |
| + if got[0].Target != "go.tito.io" { |
| + t.Fatalf("target = %q, want go.tito.io", got[0].Target) |
| + } |
| + |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("repoint produced %d rows, want 1 — (host, prefix) is the key", len(got)) |
| + } |
| + if got[0].Target != "beta.tito.io" { |
| + t.Fatalf("target = %q, want beta.tito.io after repoint", got[0].Target) |
| + } |
| +} |
| + |
| +// TestDeletePathIsIdempotent: deleting a row that is already gone is a |
| +// success, the same contract DeleteAlias carries. The reconcile pass deletes |
| +// unconditionally, so this must not error. |
| +func TestDeletePathIsIdempotent(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.DeletePath("ti.to", "nobody"); err != nil { |
| + t.Fatalf("deleting an absent path row: %v", err) |
| + } |
| +} |
| + |
| +// TestPathsAreScopedToTheirHost: two hosts may carry the same prefix without |
| +// colliding — the key is the pair, not the prefix. |
| +func TestPathsAreScopedToTheirHost(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + for _, h := range []string{"ti.to", "other.example"} { |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: h, Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 2 { |
| + t.Fatalf("got %d rows, want 2 — (host, prefix) is the key, not prefix alone", len(got)) |
| + } |
| +} |
| + |
| +// TestFingerprintChangesOnPathMutation is the load-bearing test of this |
| +// task. Fingerprint() is the 2s content hash the edge polls; a route_paths |
| +// change it cannot see is a flip that reconciles perfectly and never reaches |
| +// the live map — a silent false negative, the failure class Fingerprint's own |
| +// header names as the one to engineer against. |
| +func TestFingerprintChangesOnPathMutation(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "beta.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a route_paths repoint: %q", after) |
| + } |
| +} |
| + |
| +// TestFingerprintChangesOnPathRemoval: the un-flip direction. Rollback is a |
| +// row disappearing, and it must reach the edge on the same 2s bound as the |
| +// flip did. |
| +func TestFingerprintChangesOnPathRemoval(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertPath(RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.io", |
| + Account: "tito", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.DeletePath("ti.to", "paulca"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a route_paths removal: %q", after) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 130626a37..07baf89ce 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -415,6 +415,27 @@ CREATE TABLE IF NOT EXISTS wildcard_domains ( |
| account TEXT NOT NULL DEFAULT '', |
| app TEXT NOT NULL DEFAULT '', |
| created_at INTEGER NOT NULL DEFAULT 0 |
| +);`); err != nil { |
| + return err |
| + } |
| + // route_paths is a derived table (the domains reconcile pass owns every |
| + // row, materializing a claim's Paths map into it) — a plain CREATE TABLE |
| + // IF NOT EXISTS, the same additive shape as route_aliases and |
| + // wildcard_domains above, so existing DBs migrate on open with zero rows |
| + // and no behaviour change. |
| + // |
| + // The key is the PAIR: `routes` has host as its primary key and must |
| + // stay one row per hostname (that row owns the cert, the home box, the |
| + // DNS state and the aliases), so a hostname's prefixes live here instead. |
| + // prefix is a BARE segment — "paulca", never "/paulca". |
| + if _, err := r.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS route_paths ( |
| + host TEXT NOT NULL, |
| + prefix TEXT NOT NULL, |
| + target TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + PRIMARY KEY (host, prefix) |
| );`); err != nil { |
| return err |
| } |
| @@ -1594,8 +1615,69 @@ func (r *Registry) DeleteWildcardDomain(host string) error { |
| return err |
| } |
| -// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases and |
| -// wildcard_domains tables over a fresh connection, for the edge's cheap |
| +// RoutePath is one derived row of the route_paths table: a leading URL path |
| +// segment on Host that is served by the route at Target instead of by Host's |
| +// own route. Wholly pass-owned, like route_aliases and wildcard_domains: |
| +// every row traces to a claim's Paths entry, so removal needs no ownership |
| +// flag. |
| +// |
| +// A path row deliberately carries NO upstream, channel or environment of its |
| +// own. It is a pointer; everything about how the traffic is served comes from |
| +// Target's ordinary route row. That is what makes pointing a subset of |
| +// prefixes at a second route a working canary rather than a special case. |
| +// |
| +// Prefix is a bare segment — "paulca", never "/paulca". |
| +type RoutePath struct { |
| + Host string |
| + Prefix string |
| + Target string |
| + Account string |
| + App string |
| +} |
| + |
| +// ListPaths returns every path route, ordered by host then prefix. |
| +func (r *Registry) ListPaths() ([]RoutePath, error) { |
| + rows, err := r.sql.Query(`SELECT host, prefix, target, account, app |
| +FROM route_paths ORDER BY host, prefix`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []RoutePath |
| + for rows.Next() { |
| + var p RoutePath |
| + if err := rows.Scan(&p.Host, &p.Prefix, &p.Target, &p.Account, &p.App); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, p) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// UpsertPath inserts or repoints one path route by its (host, prefix) key. |
| +// Unlike UpsertAlias there are no stamp columns to preserve — nothing on this |
| +// table is maintained on a separate cadence — so every column is overwritten. |
| +func (r *Registry) UpsertPath(p RoutePath) error { |
| + _, err := r.sql.Exec(` |
| +INSERT INTO route_paths (host, prefix, target, account, app) |
| +VALUES (?, ?, ?, ?, ?) |
| +ON CONFLICT(host, prefix) DO UPDATE SET |
| + target=excluded.target, account=excluded.account, app=excluded.app`, |
| + p.Host, p.Prefix, p.Target, p.Account, p.App) |
| + return err |
| +} |
| + |
| +// DeletePath removes one path route. Idempotent: a row already gone is a |
| +// success, the same contract as DeleteAlias — the reconcile pass deletes |
| +// unconditionally from its own view of what is no longer desired. |
| +func (r *Registry) DeletePath(host, prefix string) error { |
| + _, err := r.sql.Exec(`DELETE FROM route_paths WHERE host = ? AND prefix = ?`, host, prefix) |
| + return err |
| +} |
| + |
| +// Fingerprint summarizes the routes, fleets, fleet_boxes, route_aliases, |
| +// wildcard_domains and route_paths tables over a fresh connection, for the |
| +// edge's cheap |
| // change poll. It is content-accurate: it hashes the actual VALUES of the |
| // reload-relevant columns, not a length-weighted aggregate, so two |
| // DIFFERENT values of the SAME length |
| @@ -1669,6 +1751,13 @@ func (r *Registry) DeleteWildcardDomain(host string) error { |
| // columns are hashed — there is no cadence-stamped column on this table to |
| // exclude, unlike route_aliases' dns_status/cert_expires_at. |
| // |
| +// route_paths is hashed for the same reload-latency reason, and it is the |
| +// section most worth stating explicitly: a path flip is the ONLY routing |
| +// change in the system whose whole observable effect is in this table. A |
| +// missing section here would not degrade a flip, it would make flips do |
| +// nothing at all — reconciled correctly, stored correctly, never served — |
| +// until some unrelated route write happened to move the digest. |
| +// |
| // The ORDER BY clauses are what make the digest deterministic across polls; |
| // a hypothetical reordering would only cost a redundant reload (idempotent), |
| // never a missed change — the failure mode worth engineering against is the |
| @@ -1792,6 +1881,30 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| return "", err |
| } |
| + // route_paths: which prefix on which hostname reaches which route is a |
| + // routing decision in exactly the sense route_aliases' alias/host is, so |
| + // it hashes here or a flip never reaches the live map. All five columns |
| + // hash — unlike route_aliases there is no box-stamped, cadence-driven |
| + // column on this table to exclude, so there is nothing here that could |
| + // force a pointless fleet-wide reload. |
| + fmt.Fprint(h, "\x1droute_paths\x1e") |
| + prows, err := db.Query(`SELECT host, prefix, target, account, app |
| +FROM route_paths ORDER BY host, prefix`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer prows.Close() |
| + for prows.Next() { |
| + var host, prefix, target, account, app string |
| + if err := prows.Scan(&host, &prefix, &target, &account, &app); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e", host, prefix, target, account, app) |
| + } |
| + if err := prows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |
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..c4010c829 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,101 @@ 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"]]) |
| + } |
| +} |
| + |
| +// TestRoutesPathRowsAreNotCountedAsRoutes: a path row is a pointer at a |
| +// route, not a route. It is printed under its host, so when --match filters |
| +// every host away nothing is printed at all — and the "no routes" line must |
| +// still appear, because path rows must not inflate the count of what was |
| +// shown. The obvious spelling of this check (assert the line is ABSENT from |
| +// a listing that prints two routes) cannot fail, which is why it is written |
| +// the other way round. |
| +func TestRoutesPathRowsAreNotCountedAsRoutes(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"}, |
| + }, |
| + []map[string]any{ |
| + {"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, |
| + "-match", "nothing-matches-this"}) |
| + |
| + if !strings.Contains(out.String(), "no routes for titogo") { |
| + t.Fatalf("every route was filtered away, so the listing must say so:\n%s", out.String()) |
| + } |
| + if strings.Contains(out.String(), "/paulca") { |
| + t.Fatalf("a path row survived a filter its host did not:\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) { |