domains: e2e + docs — every app is born addressable
The e2e now runs one real reconcile pass over the DirStore + registry: account manifest in, platform route out (frozen sqid and all), DNS stamped by the fake resolver, and the edge serving the minted domain by Host header over the live echo backend. README gains the Domains section: the scheme, the ops default, the config surface, the canonical-host caveat, and the unset-means-inert promise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+84
−0
README.md+42 −0e2e_test.go+42 −0
diff --git a/README.md b/README.md| index d7e445a60..f4f05ce5a 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -22,6 +22,48 @@ One static binary plays every role, selected by subcommand: `carlos edge`, |
| process per host), `carlos ship`/`promote`/`rollback`, `carlos vet`. Run |
| `carlos` with no arguments for the full command list. |
| +## Domains |
| + |
| +Every app has domains as a first-class concept: a **platform domain** it is |
| +born with, and any number of **custom domains** layered on top — each with |
| +DNS and TLS health visible in the console, and point-your-DNS-here guidance |
| +when a custom domain isn't connected yet. The scheme: |
| + |
| + <app>.<account-sqid>.<apps-domain> kass.bab.oncarlos.com |
| + <canary>.<app>.<account-sqid>.<apps-domain> pricing.kass.bab.oncarlos.com |
| + |
| +The account carries the sqid (its registry row id, base-23 over a frozen |
| +no-lookalike alphabet — deliberately not configurable, so nobody can squat a |
| +vanity label; custom domains are the vanity mechanism), the app carries its |
| +name — app names are unique per account, not globally. Account #1 is `ops`, |
| +the deployment operator's built-in account, created by migration; CLI-added |
| +apps live there, and its sqid (`bab`) is identical on every deployment. The |
| +console writes `apps/<app>/account.json` at claim time; the agent's |
| +reconcile pass (riding the 5-minute tick) reads those manifests and mints |
| +platform routes, minting canary hosts for hostless canary channels and |
| +removing only routes it minted itself — **custom domains are never touched |
| +by reconciliation**. DNS needs one wildcard A record (`*.<apps-domain>` → |
| +edge; RFC 4592 wildcards match multiple labels); certs come per-host from |
| +the edge's existing ACME machinery. |
| + |
| +**Config surface:** |
| + |
| +- `PLATFORM_APPS_DOMAIN` — the deployment's apps domain (flagship: |
| + `oncarlos.com`), read by `carlos agent`. **Unset = the whole feature is |
| + inert**: no minting, no manifests read, zero behavior change — the same |
| + litestream convention as everything else here. |
| +- `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks |
| + compare against, read by both the agent (checks) and the console |
| + (guidance text), so the two can never name different addresses. Default: |
| + the box's public IP via IMDS, best-effort; if neither is knowable the |
| + checks stand down and health reads unknown, never a guess. |
| + |
| +**Canonical-host caveat**, stated honestly: an app that pins its own host |
| +(e.g. keymail's `-host keymail.dev`) will redirect or 404 on its platform |
| +domain — correct behavior. Rastrillo apps accept any granted host from day |
| +one; for legacy apps the platform domain is the pre-custom-domain bootstrap |
| +URL. |
| + |
| ## Fleet status |
| Every deployment computes a fleet status view: how many instances are |
diff --git a/e2e_test.go b/e2e_test.go| index ae3fe3ace..9976151a1 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -31,6 +31,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -382,6 +383,47 @@ func TestEndToEndSmoke(t *testing.T) { |
| t.Errorf("fleet.json instances.total = %v, want %d (one box over %d routes)", got, wantTotal, wantTotal) |
| } |
| + // (5) Domains: an account manifest in the bucket + one reconcile pass |
| + // mints the app's platform domain, and the edge serves it by Host header |
| + // over the same echo backend — every app is born addressable. The |
| + // manifest names the owner's address as the account (the console's claim |
| + // shape); its id lands after the built-in ops (id 1), so the sqid is the |
| + // frozen Sqid(2) = "bac". |
| + if _, err := store.Put(ctx, "apps/echoapp/account.json", |
| + []byte(`{"account":"paul@keymail.dev","owner":"paul@keymail.dev"}`), ""); err != nil { |
| + t.Fatalf("write account manifest: %v", err) |
| + } |
| + if err := domains.Reconcile(ctx, store, reg, domains.Config{ |
| + AppsDomain: "oncarlos.test", |
| + EdgeIPs: []string{"203.0.113.7"}, |
| + Resolve: func(host string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| + Now: time.Now, |
| + }); err != nil { |
| + t.Fatalf("domains.Reconcile: %v", err) |
| + } |
| + const platformHost = "echoapp.bac.oncarlos.test" |
| + prt, err := reg.Get(platformHost) |
| + if err != nil { |
| + t.Fatalf("get platform route: %v", err) |
| + } |
| + if prt == nil { |
| + t.Fatalf("reconcile minted no platform route %s", platformHost) |
| + } |
| + if !prt.Platform || prt.Addr != backendAddr || prt.Account != "paul@keymail.dev" { |
| + t.Fatalf("platform route = %+v, want platform-flagged, echo upstream, manifest account", prt) |
| + } |
| + if prt.DNSStatus != "pointing" { |
| + t.Fatalf("platform route dns_status = %q, want pointing (fake resolver says edge)", prt.DNSStatus) |
| + } |
| + // In production the fingerprint watcher reloads the edge; here the test |
| + // plays the watcher. |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("edge.Reload after reconcile: %v", err) |
| + } |
| + if code, body := e2eGet(t, srv.URL, platformHost, "/"); code != 200 || !strings.Contains(body, "BUILD=v1") { |
| + t.Fatalf("GET %s / = %d %q, want 200 containing BUILD=v1 (the platform domain serves the app)", platformHost, code, body) |
| + } |
| + |
| // Binary channel pointer flow: ship v2 (same bytes — a rebuild), promote, |
| // then roll back; the pointer must return to v1 with v2 as the rollback |
| // target. (Applying the pointer on a box is host/agent territory.) |