merge: domains — platform domains, DNS/TLS health, console Domains section
Account-sqid platform domains minted by an agent reconcile pass, custom domain health stamped on routes, console Domains section. Inert until PLATFORM_APPS_DOMAIN is set — deliberately NOT enabled on the flagship yet: durable account identity and the multi-account model land first, so every app mints its domain once, under its real account. Merge review caught a canary URL that would have proxied to the stable process while advertising the canary channel; canary minting is now static-only and instance canaries wait for carlos canary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 files changed,
+1856
−30
README.md+70 −0cmd/console/main.go+16 −0e2e_test.go+42 −0internal/console/server.go+72 −2internal/console/server_test.go+149 −1internal/console/templates/app.html+12 −11internal/console/view.go+52 −3internal/console/view_test.go+5 −5internal/domains/reconcile.go+437 −0internal/domains/reconcile_test.go+425 −0internal/domains/sqid.go+34 −0internal/domains/sqid_test.go+42 −0internal/host/agent.go+170 −0internal/host/host_test.go+57 −0internal/registry/registry.go+99 −6internal/registry/registry_test.go+79 −0internal/release/adopt.go+12 −2internal/release/adopt_test.go+49 −0internal/status/snapshot.go+9 −0internal/status/status_test.go+25 −0
diff --git a/README.md b/README.md| index d7e445a60..b2b065ff3 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -22,6 +22,76 @@ 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, and removes 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. |
| + |
| +A platform domain is an **alias**: a second name for the app's canonical |
| +route, pointing at the same backend. It is not a lifecycle host — release |
| +adoption and the fleet status counts both skip minted routes, so a |
| +deployment that turns this feature on does not double-stage artifacts or |
| +publish an inflated instance count. |
| + |
| +**Three honest limits**, none of them papered over: |
| + |
| +- **Minting is additive — an app is never re-homed.** Once an app has a |
| + platform apex, changing its account does *not* mint a new one or remove |
| + the old: one app, one apex, and moving an app between accounts is a |
| + deliberate operator migration (delete the old apex route by hand, let the |
| + next pass mint the new one), never a silent side effect of editing a |
| + manifest. |
| +- **A hibernating app's platform domain 502s while the app sleeps.** The |
| + activator arms on the canonical route only; minted routes deliberately do |
| + not copy the `hibernate` flag, because a minted route carrying it would |
| + make the activator spawn a *second* process for the same app against an |
| + empty `DBPath` — meaningfully worse than a 502. Wake the app through its |
| + canonical host; the platform domain serves normally once it's up. |
| +- **Canary hosts are minted for static apps only.** For a static app the |
| + channel genuinely is the backend (the edge resolves the channel pointer |
| + to find that release's tar), so a canary host serves exactly the canary. |
| + An instance app's minted canary would only be able to alias the app's |
| + *stable* process — a URL claiming to serve the canary while proxying |
| + stable — so none is minted; spawning a real per-host canary instance is |
| + future work (`carlos canary`), and until it exists the pass logs the gap |
| + rather than faking it. |
| + |
| +**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/cmd/console/main.go b/cmd/console/main.go| index 76583271b..0f4f8e077 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -126,6 +126,7 @@ func serve() { |
| Releases: openStore(), |
| ReleasePub: pub, |
| Key: key, |
| + EdgeIPs: edgeIPs(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -160,6 +161,21 @@ func openStore() release.Store { |
| return nil |
| } |
| +// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| +// agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| +// guidance and the agent's DNS health checks can never name different |
| +// addresses. Unset is legal: guidance falls back to naming "this |
| +// deployment's edge IP" in prose. |
| +func edgeIPs() []string { |
| + var out []string |
| + for _, ip := range strings.Split(os.Getenv("PLATFORM_EDGE_IPS"), ",") { |
| + if ip = strings.TrimSpace(ip); ip != "" { |
| + out = append(out, ip) |
| + } |
| + } |
| + return out |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
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.) |
diff --git a/internal/console/server.go b/internal/console/server.go| index d63ff66d6..0a09465e6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -10,6 +10,7 @@ import ( |
| "crypto/ed25519" |
| "crypto/subtle" |
| "embed" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "html/template" |
| @@ -95,6 +96,11 @@ type Config struct { |
| // AppScheme is the scheme apps' public hosts are reached on — "https" |
| // (the default) everywhere but tests. |
| AppScheme string |
| + // EdgeIPs is this deployment's edge address list (PLATFORM_EDGE_IPS, |
| + // read at console start) — surfaced in the Domains section's |
| + // point-your-DNS-here guidance. nil is legal: guidance then says |
| + // "this deployment's edge IP" instead of inventing an address. |
| + EdgeIPs []string |
| } |
| // Server is the console's http.Handler. |
| @@ -499,7 +505,7 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if len(apps) > 0 { |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps, s.cfg.EdgeIPs) |
| if err != nil { |
| log.Printf("console: assemble: %v", err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -541,7 +547,11 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}) |
| + // Backfill the account manifest for apps claimed before the domains |
| + // feature existed (or CLI-granted ones): best-effort, on the OWNER's own |
| + // first view — ownedApp above already proved the viewer owns this app. |
| + s.ensureAccountManifest(r.Context(), app, addr) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}, s.cfg.EdgeIPs) |
| if err != nil || len(views) != 1 { |
| log.Printf("console: assemble %s: %v", app, err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -575,6 +585,59 @@ func (s *Server) audit(app, addr, action, detail string) { |
| } |
| } |
| +// writeAccountManifest lands apps/<app>/account.json in the deployment |
| +// bucket: {"account": <addr>, "owner": <addr>} — the account name IS the |
| +// claiming address for now (one account per user), and the agent's domains |
| +// reconcile pass materializes it into the registry from here. Overwrite-safe |
| +// put (status.PublishFleet's read-etag-then-Put idiom): the store's Put is |
| +// CAS-only, and a backfill racing another of the owner's own tabs must |
| +// retry, not fail. |
| +func (s *Server) writeAccountManifest(ctx context.Context, app, addr string) error { |
| + key := "apps/" + app + "/account.json" |
| + body, err := json.Marshal(map[string]string{"account": addr, "owner": addr}) |
| + if err != nil { |
| + return err |
| + } |
| + put := func() error { |
| + etag := "" |
| + if _, cur, err := s.cfg.Releases.Get(ctx, key); err == nil { |
| + etag = cur |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + _, err := s.cfg.Releases.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := put(); errors.Is(err, release.ErrCASConflict) { |
| + return put() // one retry on a lost race; a second loss surfaces |
| + } else if err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// ensureAccountManifest is the view-time backfill: write the manifest only |
| +// when none exists. An existing manifest is account truth someone already |
| +// wrote (possibly by hand, possibly naming a different account) — a page |
| +// view must never clobber it. Best-effort: failures are logged, never fail |
| +// the page. |
| +func (s *Server) ensureAccountManifest(ctx context.Context, app, addr string) { |
| + key := "apps/" + app + "/account.json" |
| + _, _, err := s.cfg.Releases.Get(ctx, key) |
| + if err == nil { |
| + return // already there — nothing to backfill |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: account manifest %s: %v", key, err) |
| + return |
| + } |
| + if err := s.writeAccountManifest(ctx, app, addr); err != nil { |
| + log.Printf("console: backfill account manifest %s: %v", key, err) |
| + return |
| + } |
| + log.Printf("console: backfilled account manifest for %s (owner %s)", app, addr) |
| +} |
| + |
| // handleNewApp claims an app name — once, ever. A name is refused when the |
| // apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| // claim-proof), or when the release store already holds anything under |
| @@ -634,6 +697,13 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("Created the app, but granting you ownership failed — ask an operator.") |
| return |
| } |
| + // The account manifest: claim is where account truth originates, and it |
| + // travels through the bucket to the agent's reconcile pass. Best-effort — |
| + // the claim already succeeded, and the app-page backfill is the safety |
| + // net if this write loses to a network hiccup. |
| + if err := s.writeAccountManifest(ctx, name, addr); err != nil { |
| + log.Printf("console: account manifest for %s: %v", name, err) |
| + } |
| s.audit(name, addr, "create", "created app "+name) |
| s.setFlash(w, "ok", "Created app "+name+" — it's yours.") |
| http.Redirect(w, r, "/apps/"+url.PathEscape(name), http.StatusSeeOther) |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index c0cecb579..7a7aa6a06 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "encoding/json" |
| "errors" |
| "io" |
| "net/http" |
| @@ -233,7 +234,7 @@ func TestConsoleE2E(t *testing.T) { |
| t.Fatalf("/apps/kass = %d", status) |
| } |
| for _, want := range []string{ |
| - "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "instance", "static", |
| + "Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", |
| "ready", "v1", "stable", "canary/x", "v2", |
| "bakes until ~", "<td>live</td>", "approximation", |
| } { |
| @@ -829,6 +830,10 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| if !strings.Contains(body, "Promotions disabled: no signing key on this host.") { |
| t.Fatal("keyless console is missing the disabled note") |
| } |
| + // The Domains section is a read — keyless (read-only) mode still renders it. |
| + if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") { |
| + t.Fatal("keyless console is missing the Domains section") |
| + } |
| if strings.Contains(body, `action="/apps/kass/promote"`) || strings.Contains(body, `action="/apps/kass/rollback"`) || |
| strings.Contains(body, `action="/apps/kass/canary"`) || strings.Contains(body, `action="/apps/kass/canary/retire"`) { |
| t.Fatal("keyless console rendered mutation forms") |
| @@ -857,6 +862,149 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| } |
| } |
| +// ---- domains ---------------------------------------------------------------- |
| + |
| +// TestClaimWritesAccountManifest: claiming an app writes the account |
| +// manifest into the deployment bucket — the console originates account |
| +// truth, and it travels through the bucket (never a console→registry write). |
| +func TestClaimWritesAccountManifest(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + if status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != "/apps/zephyr" { |
| + t.Fatalf("claim = %d -> %q", status, loc) |
| + } |
| + body, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json") |
| + if err != nil { |
| + t.Fatalf("claim wrote no account manifest: %v", err) |
| + } |
| + var m struct{ Account, Owner string } |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatalf("manifest not JSON: %v (%q)", err, body) |
| + } |
| + if m.Account != "paul@keymail.dev" || m.Owner != "paul@keymail.dev" { |
| + t.Errorf("manifest = %+v, want the claiming address as account and owner", m) |
| + } |
| +} |
| + |
| +// TestAppPageBackfillsAccountManifest: an already-claimed app with no |
| +// manifest (claimed before this feature shipped, or CLI-granted) gets one |
| +// written on the owner's first page view — and an existing manifest is |
| +// never overwritten by a view. |
| +func TestAppPageBackfillsAccountManifest(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + if _, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("fixture already has a manifest: %v", err) |
| + } |
| + if status, _, _ := get(t, c, fx.ts.URL+"/apps/kass"); status != 200 { |
| + t.Fatalf("/apps/kass = %d", status) |
| + } |
| + body, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json") |
| + if err != nil { |
| + t.Fatalf("view did not backfill the manifest: %v", err) |
| + } |
| + if !strings.Contains(string(body), "paul@keymail.dev") { |
| + t.Errorf("backfilled manifest = %q", body) |
| + } |
| + |
| + // A pre-existing manifest is account truth someone already wrote — |
| + // a page view must not clobber it. |
| + custom := []byte(`{"account":"someteam","owner":"paul@keymail.dev"}`) |
| + if _, err := fx.rel.Put(context.Background(), "apps/zephyr/account.json", custom, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.Grant("paul@keymail.dev", "zephyr"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, _ := get(t, c, fx.ts.URL+"/apps/zephyr"); status != 200 { |
| + t.Fatalf("/apps/zephyr = %d", status) |
| + } |
| + body, _, err = fx.rel.Get(context.Background(), "apps/zephyr/account.json") |
| + if err != nil || string(body) != string(custom) { |
| + t.Errorf("view clobbered an existing manifest: %q %v", body, err) |
| + } |
| +} |
| + |
| +// TestDomainViewStates is the pure Domains-row builder over route fixtures: |
| +// type from the platform flag, unknown DNS as "" (rendered em-dash), |
| +// guidance ONLY for non-pointing custom domains, canary labeling preserved. |
| +func TestDomainViewStates(t *testing.T) { |
| + edge := []string{"203.0.113.7", "198.51.100.9"} |
| + renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() |
| + cases := []struct { |
| + name string |
| + rt registry.Route |
| + ips []string |
| + want DomainView |
| + }{ |
| + {"platform pointing ready with cert", |
| + registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", |
| + DNSStatus: "pointing", CertExpiresAt: renew}, |
| + edge, |
| + DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "pointing", |
| + TLS: "ready · renews 2026-10-12"}}, |
| + {"custom elsewhere gets guidance", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", |
| + Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, |
| + {"custom unknown: em-dash DNS, still guided", |
| + registry.Route{Host: "kass.training", Status: "issuing"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "", TLS: "issuing", |
| + Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, |
| + {"custom pointing: no guidance", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}}, |
| + {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)", |
| + registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"}, |
| + edge, |
| + DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "elsewhere", TLS: "ready"}}, |
| + {"canary row keeps its label", |
| + registry.Route{Host: "rc.kass.bab.oncarlos.com", Platform: true, Status: "ready", |
| + DNSStatus: "pointing", Channel: "canary/rc", Canary: true}, |
| + edge, |
| + DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}}, |
| + {"no known edge IPs: guidance still helps, without inventing an address", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, |
| + nil, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", |
| + Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + if got := domainView(c.rt, c.ips); got != c.want { |
| + t.Errorf("domainView:\n got %+v\n want %+v", got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAppPageRendersDomains: the app page's Domains table renders from |
| +// registry state — platform + custom rows, em-dash unknowns, and the |
| +// guidance row beneath a non-pointing custom domain. |
| +func TestAppPageRendersDomains(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance", |
| + Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"}) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{ |
| + "Domains", "kass.bab.carlosapp.dev", "platform", "pointing", |
| + "kass.carlosapp.dev", "custom", "—", |
| + "Point an A record for kass.carlosapp.dev at this deployment's edge IP.", |
| + "Checks run every few minutes.", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| // seedFleetJSON writes a small status/fleet.json fixture through the real |
| // production path (one box snapshot, then BuildFleet + PublishFleet reading |
| // the fixture registry's routes) rather than hand-rolling JSON — so the test |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index c821cedd2..deef1e511 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -3,24 +3,25 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| -<h2>Routes</h2> |
| -{{if .Detail.Routes}} |
| +<h2>Domains</h2> |
| +{{if .Detail.Domains}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Host</th><th>Kind</th><th>Status</th><th>Version</th><th>Channel</th><th>Hibernate</th></tr> |
| - {{range .Detail.Routes}} |
| + <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr> |
| + {{range .Detail.Domains}} |
| <tr> |
| - <td><code>{{.Host}}</code></td> |
| - <td>{{.Kind}}</td> |
| - <td>{{.Status}}</td> |
| - <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td> |
| - <td>{{.Channel}}</td> |
| - <td>{{if .Hibernate}}yes{{else}}—{{end}}</td> |
| + <td><code>{{.Domain}}</code></td> |
| + <td>{{.Type}}</td> |
| + <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| + <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| </tr> |
| + {{if .Guidance}} |
| + <tr><td colspan="4" class="small">{{.Guidance}}</td></tr> |
| + {{end}} |
| {{end}} |
| </table> |
| </div> |
| -{{else}}<p class="small">No routes yet.</p>{{end}} |
| +{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}} |
| {{if and .Instances .Instances.Reporting}} |
| <h2>Instances</h2> |
diff --git a/internal/console/view.go b/internal/console/view.go| index fa1ab7b73..2ca4e5bf8 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -24,10 +24,55 @@ import ( |
| type AppView struct { |
| App string |
| Routes []RouteView |
| + Domains []DomainView |
| Channels []ChannelView |
| Canaries []CanaryView |
| } |
| +// DomainView is one row of the app page's Domains table — the product |
| +// surface for what routes always were. Unknowns stay empty strings here and |
| +// render as the em-dash convention; Guidance is non-empty ONLY for a custom |
| +// domain that isn't pointing at this deployment yet. |
| +type DomainView struct { |
| + Domain, Type, DNS, TLS string |
| + Guidance string |
| +} |
| + |
| +// domainView folds one registry route into its Domains row. edgeIPs is the |
| +// console's PLATFORM_EDGE_IPS (nil = unknown; guidance then names "this |
| +// deployment's edge IP" rather than inventing an address). |
| +func domainView(rt registry.Route, edgeIPs []string) DomainView { |
| + typ := "custom" |
| + if rt.Platform { |
| + typ = "platform" |
| + } |
| + // Canary labeling is preserved: a canary follower's row says so, right |
| + // in its type cell, whichever type it is. |
| + if strings.HasPrefix(rt.Channel, "canary/") { |
| + typ += " · " + rt.Channel |
| + } |
| + // TLS: the route's status is the issuance signal (issuing/ready/failed); |
| + // a known cert expiry adds the renew-by date. |
| + tls := rt.Status |
| + if tls == "" { |
| + tls = "ready" // registry contract: empty status means ready |
| + } |
| + if rt.CertExpiresAt > 0 { |
| + tls += " · renews " + time.Unix(rt.CertExpiresAt, 0).UTC().Format("2006-01-02") |
| + } |
| + dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls} |
| + // Guidance only for custom + non-pointing: platform domains are ours to |
| + // fix, and a pointing custom domain needs no pointing instructions. |
| + if !rt.Platform && rt.DNSStatus != "pointing" { |
| + target := "this deployment's edge IP" |
| + if len(edgeIPs) > 0 { |
| + target = strings.Join(edgeIPs, ", ") |
| + } |
| + dv.Guidance = "Point an A record for " + rt.Host + " at " + target + ". Checks run every few minutes." |
| + } |
| + return dv |
| +} |
| + |
| // CanaryView is one canary on the app page: a canary/<name> channel plus |
| // the hosts (routes) currently following it. A canary with no hosts is a |
| // pointer waiting for a route — real, serving nothing yet. |
| @@ -96,9 +141,10 @@ func openRegistry(path string) (*registry.Registry, error) { |
| // Assemble builds the view for the given (owned) apps: routes from the |
| // registry at regPath, channels from the release store s verified against |
| -// pub. Every app in apps gets an AppView, even an empty one; per-channel |
| -// failures degrade to ChannelView.Err. |
| -func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string) ([]AppView, error) { |
| +// pub, Domains rows with guidance targeting edgeIPs (nil = unknown). Every |
| +// app in apps gets an AppView, even an empty one; per-channel failures |
| +// degrade to ChannelView.Err. |
| +func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string, edgeIPs []string) ([]AppView, error) { |
| ctx := context.Background() |
| reg, err := openRegistry(regPath) |
| if err != nil { |
| @@ -109,6 +155,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| return nil, err |
| } |
| byApp := map[string][]RouteView{} |
| + domainsByApp := map[string][]DomainView{} |
| for _, rt := range routes { |
| ch := rt.Channel |
| if ch == "" { |
| @@ -118,6 +165,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| Host: rt.Host, Kind: rt.Kind, Status: rt.Status, |
| Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate, |
| }) |
| + domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs)) |
| } |
| now := time.Now() |
| sorted := append([]string(nil), apps...) |
| @@ -133,6 +181,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| out = append(out, AppView{ |
| App: app, |
| Routes: byApp[app], // List orders by host already |
| + Domains: domainsByApp[app], |
| Channels: channels, |
| Canaries: assembleCanaries(channels, byApp[app]), |
| }) |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 66d3f00b5..17ec58918 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -108,7 +108,7 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| func TestAssemble(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -164,7 +164,7 @@ func TestAssembleCanaries(t *testing.T) { |
| Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -186,7 +186,7 @@ func TestAssembleCanaries(t *testing.T) { |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,7 +204,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) { |
| func TestAssembleEmptyAppStillListed(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -222,7 +222,7 @@ func TestAssembleDegradesBadChannel(t *testing.T) { |
| if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatalf("Assemble must not fail on a bad channel: %v", err) |
| } |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| new file mode 100644 |
| index 000000000..a514e9e3e |
| --- /dev/null |
| +++ b/internal/domains/reconcile.go |
| @@ -0,0 +1,437 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "crypto/x509" |
| + "encoding/json" |
| + "encoding/pem" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Config is everything one reconcile pass reads its world through. Every |
| +// field is a seam: the tests inject fixtures, the agent injects production |
| +// values (internal/host wires it). |
| +type Config struct { |
| + // AppsDomain is the deployment's platform apps domain (flagship: |
| + // "oncarlos.com"). "" makes the WHOLE pass inert — no minting, no |
| + // manifests read, zero registry writes — the litestream convention: |
| + // unconfigured means the feature does not exist. |
| + AppsDomain string |
| + // EdgeIPs are this deployment's edge addresses; a host resolving to any |
| + // of them is "pointing". |
| + EdgeIPs []string |
| + // Resolve is the DNS seam (net resolver in production, a fake in |
| + // tests); nil skips DNS stamping entirely. |
| + Resolve func(host string) ([]string, error) |
| + // AcmeDir is the edge's autocert DirCache directory; "" skips TLS |
| + // stamping entirely. |
| + AcmeDir string |
| + // Now is the clock seam; nil = time.Now. |
| + Now func() time.Time |
| +} |
| + |
| +// Reconcile runs the spec §3 steps in order, idempotently: bucket account |
| +// manifests → registry accounts + route linkage, platform-domain minting, |
| +// canary-host minting/removal, then DNS and TLS health stamps. Per-item |
| +// failures are logged and skipped — one bad manifest or one unresolvable |
| +// host must never take the pass down; only setup-level failures (listing the |
| +// bucket or the registry) return an error. |
| +// |
| +// The one destructive move — removing a route — happens ONLY for routes with |
| +// Platform set whose canary channel pointer is gone from the bucket. Custom |
| +// domains are owner property; reconciliation never touches them. |
| +func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg Config) error { |
| + if cfg.AppsDomain == "" { |
| + return nil // inert until configured |
| + } |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + |
| + // One bucket listing feeds both the manifest scan and the canary-pointer |
| + // scan below. |
| + keys, err := s.List(ctx, "apps/") |
| + if err != nil { |
| + return fmt.Errorf("domains: list bucket: %w", err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return fmt.Errorf("domains: list routes: %w", err) |
| + } |
| + |
| + // --- 1. account materialization ------------------------------------------ |
| + // The console originates account truth as apps/<app>/account.json in the |
| + // bucket; this is the only place it becomes registry state. A deployment |
| + // with no console has no manifests: everything stays under ops. |
| + accountOf := map[string]string{} // app -> manifest account name |
| + for _, key := range keys { |
| + app, ok := manifestApp(key) |
| + if !ok { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("domains: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var m struct{ Account, Owner string } |
| + if err := json.Unmarshal(body, &m); err != nil || m.Account == "" { |
| + log.Printf("domains: skipping %s: malformed manifest: %v", key, err) |
| + continue |
| + } |
| + if _, err := reg.EnsureAccount(m.Account); err != nil { |
| + log.Printf("domains: ensure account %q: %v", m.Account, err) |
| + continue |
| + } |
| + accountOf[app] = m.Account |
| + for i := range routes { |
| + if routes[i].App != app || routes[i].Account == m.Account { |
| + continue |
| + } |
| + if err := reg.SetAccount(routes[i].Host, m.Account); err != nil { |
| + log.Printf("domains: stamp account on %s: %v", routes[i].Host, err) |
| + continue |
| + } |
| + routes[i].Account = m.Account |
| + } |
| + } |
| + |
| + // --- 2. platform-domain minting ------------------------------------------ |
| + byHost := map[string]bool{} |
| + // hasApex tracks apps that already own a platform apex, under ANY |
| + // account. The guard is deliberately "already has one" rather than |
| + // "already has the account-correct one": minting is additive (nothing |
| + // here ever removes an apex), so keying on the expected host would mint |
| + // a SECOND apex the moment an app's account changed, leaving both live |
| + // forever with no rule for which is canonical. One app, one apex; moving |
| + // an app between accounts is a deliberate operator migration, not a |
| + // silent side effect of a manifest edit. |
| + hasApex := map[string]bool{} |
| + for _, rt := range routes { |
| + byHost[rt.Host] = true |
| + if rt.Platform && !rt.Canary { |
| + hasApex[rt.App] = true |
| + } |
| + } |
| + for _, app := range candidateApps(routes) { |
| + if hasApex[app] { |
| + continue |
| + } |
| + acct := appAccount(routes, accountOf, app) |
| + sq, err := accountSqid(reg, acct) |
| + if err != nil { |
| + log.Printf("domains: account id for %q: %v", acct, err) |
| + continue |
| + } |
| + host := fmt.Sprintf("%s.%s.%s", app, sq, cfg.AppsDomain) |
| + if byHost[host] { |
| + continue // already minted — idempotent |
| + } |
| + src := firstReadyUpstream(routes, app) |
| + if src == nil { |
| + continue // nothing serving to copy an upstream from; next pass |
| + } |
| + nr := mintedRoute(host, app, acct, *src) |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("domains: mint %s: %v", host, err) |
| + continue |
| + } |
| + log.Printf("domains: minted platform domain %s (upstream %s)", host, src.Host) |
| + routes = append(routes, nr) |
| + byHost[host] = true |
| + hasApex[app] = true |
| + } |
| + |
| + // --- 3. canary hosts: mint for hostless pointers, remove for retired ---- |
| + canaryChannels := map[string]map[string]bool{} // app -> set of live canary/<name> channels |
| + for _, key := range keys { |
| + app, name, ok := canaryPointer(key) |
| + if !ok { |
| + continue |
| + } |
| + if canaryChannels[app] == nil { |
| + canaryChannels[app] = map[string]bool{} |
| + } |
| + channel := "canary/" + name |
| + canaryChannels[app][channel] = true |
| + followed := false |
| + for _, rt := range routes { |
| + if rt.App == app && rt.Channel == channel { |
| + followed = true |
| + break |
| + } |
| + } |
| + if followed { |
| + continue |
| + } |
| + acct := appAccount(routes, accountOf, app) |
| + sq, err := accountSqid(reg, acct) |
| + if err != nil { |
| + log.Printf("domains: account id for %q: %v", acct, err) |
| + continue |
| + } |
| + src := firstReadyUpstream(routes, app) |
| + if src == nil { |
| + continue // app has nothing serving yet; the pointer waits |
| + } |
| + // Static apps ONLY. A minted route can only copy an upstream that |
| + // already exists, and for an instance/service app the only one |
| + // available is the app's stable process — so a "canary" host here |
| + // would proxy straight to stable while the console labeled it with |
| + // the canary's version: a URL that lies about what it serves. |
| + // Spawning a real per-host canary instance is the not-yet-built |
| + // `carlos canary`'s job, not something reconciliation can fake. For |
| + // static apps the copied Channel genuinely IS the backend (the edge |
| + // resolves the pointer to find that channel's tar), so those mint |
| + // honestly. |
| + if src.Kind != "static" { |
| + log.Printf("domains: canary channel %s for app %s has no host: minting one needs a per-host canary instance (carlos canary), not an alias to stable — skipping", channel, app) |
| + continue |
| + } |
| + host := fmt.Sprintf("%s.%s.%s.%s", name, app, sq, cfg.AppsDomain) |
| + nr := mintedRoute(host, app, acct, *src) |
| + nr.Channel = channel |
| + nr.Canary = true |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("domains: mint canary %s: %v", host, err) |
| + continue |
| + } |
| + log.Printf("domains: minted canary domain %s (channel %s)", host, channel) |
| + routes = append(routes, nr) |
| + byHost[host] = true |
| + } |
| + // Removal: ONLY canary hosts this pass minted, whose pointer is now gone |
| + // from the bucket. Three conditions, each load-bearing: Platform (a |
| + // custom route on the same channel is owner property — the flag, not the |
| + // channel, grants removal authority), Canary (a STATIC app's apex mint |
| + // legitimately carries a canary channel too, since that's how it finds |
| + // its tar — removing it on retirement would delete the app's platform |
| + // domain and re-mint it next pass, forever), and the channel prefix. |
| + kept := routes[:0] |
| + for _, rt := range routes { |
| + if rt.Platform && rt.Canary && strings.HasPrefix(rt.Channel, "canary/") && |
| + !canaryChannels[rt.App][rt.Channel] { |
| + if err := reg.Delete(rt.Host); err != nil { |
| + log.Printf("domains: remove retired canary route %s: %v", rt.Host, err) |
| + kept = append(kept, rt) // still there; stamp it honestly below |
| + continue |
| + } |
| + log.Printf("domains: removed retired canary route %s (channel %s)", rt.Host, rt.Channel) |
| + continue |
| + } |
| + kept = append(kept, rt) |
| + } |
| + routes = kept |
| + |
| + // --- 4. DNS health -------------------------------------------------------- |
| + if cfg.Resolve != nil { |
| + edge := map[string]bool{} |
| + for _, ip := range cfg.EdgeIPs { |
| + edge[ip] = true |
| + } |
| + now := cfg.Now().Unix() |
| + for _, rt := range routes { |
| + status := "" // resolver failure is UNKNOWN, never a guess |
| + if ips, err := cfg.Resolve(rt.Host); err == nil { |
| + status = "elsewhere" |
| + for _, ip := range ips { |
| + if edge[ip] { |
| + status = "pointing" |
| + break |
| + } |
| + } |
| + } |
| + if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| + log.Printf("domains: stamp dns on %s: %v", rt.Host, err) |
| + } |
| + } |
| + } |
| + |
| + // --- 5. TLS health -------------------------------------------------------- |
| + if cfg.AcmeDir != "" { |
| + for _, rt := range routes { |
| + at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host)) |
| + if err := reg.SetCertExpiresAt(rt.Host, at); err != nil { |
| + log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// manifestApp reports whether key is an account manifest (apps/<app>/ |
| +// account.json, exactly one path level deep) and which app it names. |
| +func manifestApp(key string) (string, bool) { |
| + rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", false |
| + } |
| + app, ok := strings.CutSuffix(rest, "/account.json") |
| + if !ok || app == "" || strings.Contains(app, "/") { |
| + return "", false |
| + } |
| + return app, true |
| +} |
| + |
| +// canaryPointer reports whether key is a canary channel pointer |
| +// (apps/<app>/channels/canary/<name>.json) and its app + canary name. |
| +func canaryPointer(key string) (app, name string, ok bool) { |
| + rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", "", false |
| + } |
| + app, rest, ok = strings.Cut(rest, "/channels/canary/") |
| + if !ok || app == "" || strings.Contains(app, "/") { |
| + return "", "", false |
| + } |
| + name, ok = strings.CutSuffix(rest, ".json") |
| + if !ok || name == "" || strings.Contains(name, "/") { |
| + return "", "", false |
| + } |
| + return app, name, true |
| +} |
| + |
| +// candidateApps returns the distinct app names eligible for a platform |
| +// domain, in first-seen (host) order: every app — static sites included, a |
| +// shipped static tree deserves an addressable URL as much as an instance |
| +// does — outside the platform-reserved svc/* namespace, with at least one |
| +// route that is NOT itself platform-minted. An app whose only routes are |
| +// minted ones has been removed by its operator, and re-minting from a mint |
| +// would keep a ghost alive forever. |
| +func candidateApps(routes []registry.Route) []string { |
| + seen := map[string]bool{} |
| + real := map[string]bool{} // has a non-platform route |
| + var order []string |
| + for _, rt := range routes { |
| + if rt.App == "" || strings.HasPrefix(rt.App, "svc/") { |
| + continue |
| + } |
| + if !seen[rt.App] { |
| + seen[rt.App] = true |
| + order = append(order, rt.App) |
| + } |
| + if !rt.Platform { |
| + real[rt.App] = true |
| + } |
| + } |
| + var out []string |
| + for _, app := range order { |
| + if real[app] { |
| + out = append(out, app) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// appAccount is the account an app's domains mint under: the bucket |
| +// manifest's word first, else whatever the app's routes already carry, else |
| +// the built-in ops — a deployment with no console lives entirely there. |
| +func appAccount(routes []registry.Route, accountOf map[string]string, app string) string { |
| + if a := accountOf[app]; a != "" { |
| + return a |
| + } |
| + for _, rt := range routes { |
| + if rt.App == app && rt.Account != "" { |
| + return rt.Account |
| + } |
| + } |
| + return "ops" |
| +} |
| + |
| +// accountSqid resolves an account name to its frozen sqid, creating the |
| +// account row if needed (idempotent — the row is the sqid's anchor). |
| +func accountSqid(reg *registry.Registry, name string) (string, error) { |
| + id, err := reg.EnsureAccount(name) |
| + if err != nil { |
| + return "", err |
| + } |
| + return Sqid(id), nil |
| +} |
| + |
| +// firstReadyUpstream picks the deterministic upstream a minted domain |
| +// serves from: the app's first ready route in host order (reg.List's order) |
| +// that has something to copy and is not itself a minted route — minting |
| +// from a mint would be self-reference. "Something to copy" is a proxy |
| +// upstream (Socket or Addr) for instances and services, or — for static |
| +// routes — the release channel, since a static route's backend IS its |
| +// channel pointer, not a socket. |
| +func firstReadyUpstream(routes []registry.Route, app string) *registry.Route { |
| + for i := range routes { |
| + rt := &routes[i] |
| + if rt.App != app || rt.Platform || rt.Status != "ready" { |
| + continue |
| + } |
| + if rt.Socket != "" || rt.Addr != "" || (rt.Kind == "static" && rt.Channel != "") { |
| + return rt |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// mintedRoute is the platform route shape: same upstream trio |
| +// (Socket/Addr/Backing) and Kind as the source, ready (the upstream is — |
| +// that's how it was chosen), platform-flagged, account-stamped. |
| +// |
| +// Channel is copied for static routes and NOT for anything else, because |
| +// the two serving classes reach their backend by different mechanisms. A |
| +// proxied route (instance/service) reaches its backend through Socket/Addr, |
| +// already copied above — the channel would be decoration, and a copied |
| +// canary channel would make the apex route look like a canary follower, so |
| +// retiring that canary would delete the app's platform domain. A static |
| +// route has no socket at all: the edge's static handler resolves the app's |
| +// CHANNEL POINTER to find the shipped tar, so a channel-less static mint |
| +// would serve nothing. Hence the asymmetry — same field, opposite verdicts, |
| +// for reasons that live one layer down in internal/edge. |
| +func mintedRoute(host, app, account string, src registry.Route) registry.Route { |
| + rt := registry.Route{ |
| + Host: host, |
| + Kind: src.Kind, |
| + Socket: src.Socket, |
| + Addr: src.Addr, |
| + Backing: src.Backing, |
| + App: app, |
| + Status: "ready", |
| + Platform: true, |
| + Account: account, |
| + } |
| + if src.Kind == "static" { |
| + rt.Channel = src.Channel |
| + } |
| + return rt |
| +} |
| + |
| +// certExpiry reads the autocert DirCache entry at path (the PEM bundle |
| +// cachePut writes: private key block first, then the cert chain) and returns |
| +// the leaf's NotAfter as unix seconds. Anything short of a parseable |
| +// certificate — no file, no CERTIFICATE block, a corrupt one — is 0: |
| +// unknown, never a guess. |
| +func certExpiry(path string) int64 { |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + return 0 |
| + } |
| + for len(body) > 0 { |
| + var block *pem.Block |
| + block, body = pem.Decode(body) |
| + if block == nil { |
| + return 0 |
| + } |
| + if block.Type != "CERTIFICATE" { |
| + continue // the private-key block(s) come first in the bundle |
| + } |
| + cert, err := x509.ParseCertificate(block.Bytes) |
| + if err != nil { |
| + return 0 |
| + } |
| + return cert.NotAfter.Unix() |
| + } |
| + return 0 |
| +} |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| new file mode 100644 |
| index 000000000..4276d8625 |
| --- /dev/null |
| +++ b/internal/domains/reconcile_test.go |
| @@ -0,0 +1,425 @@ |
| +package domains_test |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/pem" |
| + "errors" |
| + "math/big" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ctx = context.Background() |
| + |
| +func dirStore(t *testing.T) *release.DirStore { |
| + t.Helper() |
| + return release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +func regWith(t *testing.T, routes ...registry.Route) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return reg |
| +} |
| + |
| +// manifest writes the console-shaped account manifest for app. |
| +func manifest(t *testing.T, s release.Store, app, account, owner string) { |
| + t.Helper() |
| + body := []byte(`{"account":"` + account + `","owner":"` + owner + `"}`) |
| + if _, err := s.Put(ctx, "apps/"+app+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// cfg is the standard reconcile config: apps domain set, a fake resolver |
| +// saying every host points at the edge, DNS/TLS seams otherwise defaulted. |
| +func cfg(t *testing.T) domains.Config { |
| + t.Helper() |
| + return 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: func() time.Time { return time.Unix(1754000000, 0) }, |
| + } |
| +} |
| + |
| +func mustGet(t *testing.T, reg *registry.Registry, host string) *registry.Route { |
| + t.Helper() |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt == nil { |
| + t.Fatalf("route %s missing", host) |
| + } |
| + return rt |
| +} |
| + |
| +func routeCount(t *testing.T, reg *registry.Registry) int { |
| + t.Helper() |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return len(routes) |
| +} |
| + |
| +func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No manifest anywhere: the app lives under the built-in ops account |
| + // (id 1, Sqid "bab" — frozen). |
| + got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| + if !got.Platform || got.Addr != "127.0.0.1:9000" || got.Account != "ops" || got.App != "kass" { |
| + t.Errorf("minted route = %+v", got) |
| + } |
| + n := routeCount(t, reg) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if routeCount(t, reg) != n { |
| + t.Errorf("second Reconcile changed the route count: %d -> %d", n, routeCount(t, reg)) |
| + } |
| +} |
| + |
| +func TestReconcileUsesManifestAccount(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // paul is the first ensured account after ops: id 2 → frozen sqid "bac". |
| + got := mustGet(t, reg, "kass.bac.oncarlos.test") |
| + if !got.Platform || got.Account != "paul" { |
| + t.Errorf("minted route = %+v", got) |
| + } |
| + if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| + t.Errorf("source route account not stamped from manifest: %+v", src) |
| + } |
| +} |
| + |
| +// A static app deserves an addressable URL as much as an instance does |
| +// (platform-site is exactly that case). Its minted route must carry |
| +// Kind=static AND the source route's Channel — the static handler resolves |
| +// that channel pointer to find the tar, so a channel-less static route has |
| +// nothing to serve. |
| +func TestReconcileMintsStaticAppWithChannel(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "carlosframework.com", Kind: "static", |
| + App: "platform-site", Status: "ready", Channel: "stable"}) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := mustGet(t, reg, "platform-site.bab.oncarlos.test") |
| + if !got.Platform || got.Kind != "static" || got.Channel != "stable" || got.Account != "ops" { |
| + t.Errorf("minted static route = %+v, want platform static on channel stable", got) |
| + } |
| + // Idempotence with the channel copy in place: the minted route now |
| + // follows "stable" itself, which must not confuse a second pass into |
| + // minting again (nor into treating it as a canary follower). |
| + n := routeCount(t, reg) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if routeCount(t, reg) != n { |
| + t.Errorf("second Reconcile changed the route count: %d -> %d", n, routeCount(t, reg)) |
| + } |
| + if got := mustGet(t, reg, "platform-site.bab.oncarlos.test"); got.Channel != "stable" { |
| + t.Errorf("second pass lost the minted route's channel: %+v", got) |
| + } |
| +} |
| + |
| +// A static app riding a canary channel mints an apex route ON that channel |
| +// (it must, to serve anything) — and that route must NOT be swept away as a |
| +// "retired canary follower" when the channel goes: only genuinely |
| +// canary-minted hosts are removal candidates. |
| +func TestReconcileKeepsStaticApexOnRetiredCanaryChannel(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "site.custom", Kind: "static", App: "site", |
| + Status: "ready", Channel: "canary/x"}, |
| + registry.Route{Host: "site.bab.oncarlos.test", Kind: "static", App: "site", |
| + Status: "ready", Channel: "canary/x", Platform: true}, |
| + ) |
| + // No canary/x pointer exists in the bucket at all (retired). |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "site.bab.oncarlos.test") // the apex mint survives |
| +} |
| + |
| +// Canary hosts are minted for STATIC apps only, where the copied channel |
| +// genuinely is the backend (the static handler resolves the pointer to find |
| +// the tar). See TestReconcileSkipsCanaryMintForInstanceApps for the other |
| +// half of that rule and why it exists. |
| +func TestReconcileMintsCanaryHost(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "static", |
| + App: "kass", Status: "ready", Channel: "stable"}) |
| + // The pointer's existence is what reconcile reads — content is not its |
| + // business (the edge verifies signatures when serving). |
| + if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := mustGet(t, reg, "pricing.kass.bab.oncarlos.test") |
| + if !got.Platform || got.Channel != "canary/pricing" || got.Kind != "static" || !got.Canary { |
| + t.Errorf("minted canary route = %+v", got) |
| + } |
| + // A followed canary never doubles up: the minted route follows the |
| + // channel now, so a second pass mints nothing new. |
| + n := routeCount(t, reg) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if routeCount(t, reg) != n { |
| + t.Errorf("second Reconcile changed the route count: %d -> %d", n, routeCount(t, reg)) |
| + } |
| +} |
| + |
| +// An instance app's canary host would be a LIE: a minted route can only |
| +// copy an existing upstream, and the only upstream available is the app's |
| +// stable process — so the "canary" URL would proxy straight to stable while |
| +// the console labeled it with the canary's version. Nothing here spawns a |
| +// per-host canary instance; that gap belongs to the not-yet-built `carlos |
| +// canary`. Minting nothing is the honest answer until then. |
| +func TestReconcileSkipsCanaryMintForInstanceApps(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("pricing.kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("minted a canary host for an instance app: %+v %v — it would serve stable, not the canary", rt, err) |
| + } |
| + // The app's ordinary apex still mints: only the canary host is withheld. |
| + mustGet(t, reg, "kass.bab.oncarlos.test") |
| +} |
| + |
| +// An account change (or any hand-edited account column) must never leave an |
| +// app with two live apex domains: minting is additive — the pass never |
| +// removes an apex — so the guard is "already has one", not "has the |
| +// account-correct one". |
| +func TestReconcileNeverMintsASecondApex(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000", Platform: true, Account: "ops"}, |
| + ) |
| + // The app moves to paul's account (id 2, sqid "bac"). |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("kass.bac.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("minted a second apex %+v %v — one app, one platform apex", rt, err) |
| + } |
| + mustGet(t, reg, "kass.bab.oncarlos.test") // the existing apex stays live |
| +} |
| + |
| +func TestReconcileRemovesRetiredCanaryHostOnly(t *testing.T) { |
| + // The canary/old pointer is GONE from the bucket; its platform-minted |
| + // follower must be removed, but the custom route following the same |
| + // channel is owner property — reconciliation never touches it. |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "old.kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000", Channel: "canary/old", Platform: true, Canary: true}, |
| + registry.Route{Host: "rc.kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000", Channel: "canary/old"}, |
| + ) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("old.kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("retired canary's platform route still present: %+v %v", rt, err) |
| + } |
| + mustGet(t, reg, "rc.kass.training") // the custom follower survives |
| +} |
| + |
| +func TestReconcileNeverTouchesCustomDomains(t *testing.T) { |
| + // A custom route whose app is dead (nothing ready, nothing shipped): |
| + // reconciliation must leave it exactly alone — and mint nothing, since |
| + // there is no ready upstream to copy. |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "failed", Addr: "127.0.0.1:9000"}) |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "kass.training") |
| + if rt, err := reg.Get("kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("minted a platform route with no ready upstream: %+v %v", rt, err) |
| + } |
| +} |
| + |
| +func TestReconcileStampsDNS(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "points.test", Kind: "static", Status: "ready"}, |
| + registry.Route{Host: "elsewhere.test", Kind: "static", Status: "ready"}, |
| + registry.Route{Host: "broken.test", Kind: "static", Status: "ready"}, |
| + ) |
| + c := cfg(t) |
| + c.Resolve = func(host string) ([]string, error) { |
| + switch host { |
| + case "points.test": |
| + return []string{"198.51.100.9", "203.0.113.7"}, nil // edge IP among others |
| + case "elsewhere.test": |
| + return []string{"198.51.100.9"}, nil |
| + default: |
| + return nil, errors.New("NXDOMAIN or worse") |
| + } |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := c.Now().Unix() |
| + for host, want := range map[string]string{ |
| + "points.test": "pointing", |
| + "elsewhere.test": "elsewhere", |
| + "broken.test": "", // resolver failure is UNKNOWN, never a guess |
| + } { |
| + got := mustGet(t, reg, host) |
| + if got.DNSStatus != want { |
| + t.Errorf("%s dns_status = %q, want %q", host, got.DNSStatus, want) |
| + } |
| + if got.DNSCheckedAt != now { |
| + t.Errorf("%s dns_checked_at = %d, want %d", host, got.DNSCheckedAt, now) |
| + } |
| + } |
| +} |
| + |
| +// writeCertBundle writes what autocert's cachePut writes under DirCache key |
| +// <host>: the private key PEM block first, then the CERTIFICATE chain — so |
| +// the parser is exercised on skipping the key block. |
| +func writeCertBundle(t *testing.T, dir, host string, notAfter time.Time) { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{ |
| + SerialNumber: big.NewInt(1), |
| + Subject: pkix.Name{CommonName: host}, |
| + DNSNames: []string{host}, |
| + NotBefore: notAfter.Add(-24 * time.Hour), |
| + NotAfter: notAfter, |
| + } |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + kb, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var buf bytes.Buffer |
| + if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, host), buf.Bytes(), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestReconcileStampsCertExpiry(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| + Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "junk.test", Kind: "static", Status: "ready"}, |
| + ) |
| + acmeDir := t.TempDir() |
| + notAfter := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) |
| + writeCertBundle(t, acmeDir, "kass.training", notAfter) |
| + // An unparseable cache entry is unknown, same as absent — never a crash, |
| + // never a guess. |
| + if err := os.WriteFile(filepath.Join(acmeDir, "junk.test"), []byte("not pem at all"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cfg(t) |
| + c.AcmeDir = acmeDir |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "kass.training"); got.CertExpiresAt != notAfter.Unix() { |
| + t.Errorf("kass.training cert_expires_at = %d, want %d", got.CertExpiresAt, notAfter.Unix()) |
| + } |
| + if got := mustGet(t, reg, "junk.test"); got.CertExpiresAt != 0 { |
| + t.Errorf("junk.test cert_expires_at = %d, want 0 (unparseable = unknown)", got.CertExpiresAt) |
| + } |
| + // The freshly minted platform route has no cached cert yet: 0. |
| + if got := mustGet(t, reg, "kass.bab.oncarlos.test"); got.CertExpiresAt != 0 { |
| + t.Errorf("minted route cert_expires_at = %d, want 0 (absent = unknown)", got.CertExpiresAt) |
| + } |
| +} |
| + |
| +func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") // present but must not be read |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := cfg(t) |
| + c.AppsDomain = "" |
| + if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("unconfigured Reconcile changed the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| + if id, _ := reg.AccountID("paul"); id != 0 { |
| + t.Error("unconfigured Reconcile read a manifest and minted an account") |
| + } |
| +} |
| + |
| +func TestReconcileSkipsMalformedManifest(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + if _, err := s.Put(ctx, "apps/bad/account.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatalf("one bad manifest must not fail the pass: %v", err) |
| + } |
| + if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| + t.Errorf("good manifest not processed after the bad one: %+v", src) |
| + } |
| +} |
diff --git a/internal/domains/sqid.go b/internal/domains/sqid.go| new file mode 100644 |
| index 000000000..978adda17 |
| --- /dev/null |
| +++ b/internal/domains/sqid.go |
| @@ -0,0 +1,34 @@ |
| +// Package domains is the platform-domains machinery: the account-sqid |
| +// encoder and the agent's reconcile pass that mints platform domains, stamps |
| +// DNS/TLS health, and materializes bucket account manifests into the |
| +// registry. See docs/superpowers/specs/2026-08-02-custom-domains-design.md. |
| +package domains |
| + |
| +// sqidAlphabet is FROZEN FOREVER: lowercase base-23 with the lookalikes |
| +// i, l, o removed. Every platform domain ever minted embeds a sqid over this |
| +// alphabet — changing it (or the offset below) re-homes every one of them. |
| +// The golden test (sqid_test.go) exists to make that impossible to do by |
| +// accident. Deliberately not configurable: vanity account names in the |
| +// platform domain invite squatting; custom domains are the vanity mechanism. |
| +const sqidAlphabet = "abcdefghjkmnpqrstuvwxyz" |
| + |
| +// sqidOffset guarantees a minimum length of 3: encoding id+23² in plain |
| +// base-23 means every id ≥ 1 needs at least three digits, with no padding |
| +// case-split (a single strictly-monotonic bijection, so distinct ids can |
| +// never collide). Frozen with the alphabet, for the same reason. |
| +const sqidOffset = 23 * 23 |
| + |
| +// Sqid encodes an account's registry row id as its frozen domain label — |
| +// e.g. the built-in ops account (id 1) is Sqid(1) forever, on every |
| +// deployment. |
| +func Sqid(id int64) string { |
| + n := id + sqidOffset |
| + var buf [16]byte // 23^16 dwarfs int64; never grows |
| + i := len(buf) |
| + for n > 0 { |
| + i-- |
| + buf[i] = sqidAlphabet[n%23] |
| + n /= 23 |
| + } |
| + return string(buf[i:]) |
| +} |
diff --git a/internal/domains/sqid_test.go b/internal/domains/sqid_test.go| new file mode 100644 |
| index 000000000..0f9b4263f |
| --- /dev/null |
| +++ b/internal/domains/sqid_test.go |
| @@ -0,0 +1,42 @@ |
| +package domains_test |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| + |
| +func TestSqidGolden(t *testing.T) { |
| + // FROZEN 2026-08-02 (first green run of the id+23² base-23 scheme). If |
| + // this test fails you are re-homing every platform domain ever minted — |
| + // ops's sqid is baked into every <app>.bab.<apps-domain> URL in the |
| + // wild. Do not "fix" the expectations; fix your code. |
| + golden := map[int64]string{ |
| + 1: "bab", // ops — the built-in account, on every deployment |
| + 2: "bac", |
| + 23: "bba", |
| + 24: "bbb", |
| + 529: "caa", |
| + 1000: "cxn", |
| + } |
| + goldenSeen := map[string]int64{} |
| + for id, want := range golden { |
| + s := domains.Sqid(id) |
| + if s != want { |
| + t.Errorf("Sqid(%d) = %q, want frozen %q", id, s, want) |
| + } |
| + if len(s) < 3 { |
| + t.Errorf("Sqid(%d)=%q shorter than 3", id, s) |
| + } |
| + for _, c := range s { |
| + if !strings.ContainsRune("abcdefghjkmnpqrstuvwxyz", c) { |
| + t.Errorf("Sqid(%d)=%q uses %q outside the frozen alphabet", id, s, c) |
| + } |
| + } |
| + if prev, dup := goldenSeen[s]; dup { |
| + t.Errorf("collision: %d and %d both %q", prev, id, s) |
| + } |
| + goldenSeen[s] = id |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 16455f514..0277c1d7a 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -46,6 +46,18 @@ |
| // with a public hostname; recorded here only so the fleet status |
| // page's entire config surface is documented in one place rather than |
| // split across this file and the tofu module. |
| +// |
| +// Domains config surface (read by domainsLoop / domainsTickConfig): |
| +// |
| +// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain |
| +// (flagship: oncarlos.com). UNSET = the domains feature is inert: no |
| +// platform domains minted, no bucket manifests read, no health stamps — |
| +// zero behavior change, same litestream-convention gate as the loops |
| +// above. |
| +// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks |
| +// compare against. Default: this box's public IPv4 from IMDS |
| +// (best-effort; if neither is knowable, DNS stamping stands down |
| +// entirely rather than guess "elsewhere" about every host). |
| package host |
| import ( |
| @@ -55,6 +67,8 @@ import ( |
| "io" |
| "log" |
| "math/rand/v2" |
| + "net" |
| + "net/http" |
| "os" |
| "os/exec" |
| "path" |
| @@ -63,6 +77,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -119,6 +134,7 @@ func RunAgent(args []string) { |
| go adoptLoop(reg) |
| go statusLoop(reg) |
| + go domainsLoop(reg, peekAcmeCache(args)) |
| // The same serving loop as `carlos edge`, with the activator injected. |
| edge.RunWithActivator(args, act) |
| @@ -181,6 +197,160 @@ func adoptLoop(reg *registry.Registry) { |
| } |
| } |
| +// peekAcmeCache pre-parses the agent args for -acme-cache the same way |
| +// peekRegistryPath peeks -registry: the edge does the authoritative parse, |
| +// this just lets the domains loop stamp cert expiry from the SAME cache dir |
| +// the edge actually mints into when the operator moved it off the default. |
| +// "" = flag not given; domainsTickConfig then derives the edge's own default |
| +// ($CARLOS_DATA_DIR/acme). |
| +func peekAcmeCache(args []string) string { |
| + fs := flag.NewFlagSet("agent", flag.ContinueOnError) |
| + fs.SetOutput(io.Discard) |
| + fs.String("registry", "", "") |
| + acme := fs.String("acme-cache", "", "") |
| + fs.String("https", "", "") |
| + fs.String("http", "", "") |
| + fs.String("fallback", "", "") |
| + fs.Bool("dev", false, "") |
| + _ = fs.Parse(args) |
| + return *acme |
| +} |
| + |
| +// domainsTickConfig builds one reconcile pass's Config from the environment |
| +// — pure (getenv + the already-read public IP injected) so the whole env |
| +// contract is testable without t.Setenv. PLATFORM_APPS_DOMAIN unset returns |
| +// the zero Config, on which Reconcile is inert by contract. When no edge IP |
| +// is knowable at all (no PLATFORM_EDGE_IPS, no IMDS answer), Resolve stays |
| +// nil so DNS stamping stands down: with nothing to compare a resolution |
| +// against, "pointing" vs "elsewhere" would be a guess, and unknowns are |
| +// never guessed. |
| +func domainsTickConfig(getenv func(string) string, publicIP string) domains.Config { |
| + domain := strings.TrimSpace(getenv("PLATFORM_APPS_DOMAIN")) |
| + if domain == "" { |
| + return domains.Config{} |
| + } |
| + var ips []string |
| + for _, ip := range strings.Split(getenv("PLATFORM_EDGE_IPS"), ",") { |
| + if ip = strings.TrimSpace(ip); ip != "" { |
| + ips = append(ips, ip) |
| + } |
| + } |
| + if len(ips) == 0 && publicIP != "" { |
| + ips = []string{publicIP} |
| + } |
| + dd := getenv("CARLOS_DATA_DIR") |
| + if dd == "" { |
| + dd = "/data" |
| + } |
| + cfg := domains.Config{ |
| + AppsDomain: domain, |
| + EdgeIPs: ips, |
| + // The same default the edge's -acme-cache flag carries |
| + // (edge.RunWithActivator): $CARLOS_DATA_DIR/acme. domainsLoop |
| + // overrides this when the flag was given explicitly. |
| + AcmeDir: filepath.Join(dd, "acme"), |
| + Now: time.Now, |
| + } |
| + if len(ips) > 0 { |
| + cfg.Resolve = resolveHost |
| + } |
| + return cfg |
| +} |
| + |
| +// resolveHost is the production DNS seam: the default resolver with a hard |
| +// 3s timeout, so one dead nameserver can't stall a whole reconcile pass. |
| +func resolveHost(host string) ([]string, error) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + return net.DefaultResolver.LookupHost(ctx, host) |
| +} |
| + |
| +// readPublicIP best-effort reads this box's public IPv4 from EC2 IMDSv2 |
| +// (token PUT, then the public-ipv4 leaf). Any failure — not on EC2, IMDS |
| +// disabled, no public address — returns "", and the caller treats that as |
| +// "unknown", never as an error: a box without a discoverable public IP is a |
| +// valid deployment whose DNS checks simply stand down. |
| +func readPublicIP() string { |
| + client := &http.Client{Timeout: 2 * time.Second} |
| + req, err := http.NewRequest(http.MethodPut, "http://169.254.169.254/latest/api/token", nil) |
| + if err != nil { |
| + return "" |
| + } |
| + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60") |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return "" |
| + } |
| + token, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return "" |
| + } |
| + req, err = http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data/public-ipv4", nil) |
| + if err != nil { |
| + return "" |
| + } |
| + req.Header.Set("X-aws-ec2-metadata-token", strings.TrimSpace(string(token))) |
| + resp, err = client.Do(req) |
| + if err != nil { |
| + return "" |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusOK { |
| + return "" |
| + } |
| + ip := strings.TrimSpace(string(body)) |
| + if net.ParseIP(ip) == nil { |
| + return "" |
| + } |
| + return ip |
| +} |
| + |
| +// domainsLoop runs the domains reconcile pass forever on the STATUS tick's |
| +// cadence (5min + jitter), not the adopt loop's hour: the console's |
| +// custom-domain guidance promises "Checks run every few minutes", and a DNS |
| +// fix that takes up to an hour to turn green would read as broken. Thin and |
| +// untested by convention (like adoptLoop/statusLoop) — everything with |
| +// behavior lives in domainsTickConfig and domains.Reconcile. Missing config |
| +// disables the loop with one log line, the same inert-until-configured |
| +// shape as its siblings. |
| +func domainsLoop(reg *registry.Registry, acmeCacheFlag string) { |
| + ctx := context.Background() |
| + // The inert gate is checked BEFORE readPublicIP: an unconfigured |
| + // deployment must make no IMDS call at all (a 2s link-local probe on |
| + // every agent start, on boxes that aren't even on EC2, for a feature |
| + // that is switched off). Inert means inert — no network, not just no |
| + // writes. domainsTickConfig re-checks the same variable; that's the |
| + // authoritative gate, this is the "don't pay for it first" one. |
| + if strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")) == "" { |
| + log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| + return |
| + } |
| + cfg := domainsTickConfig(os.Getenv, readPublicIP()) |
| + if cfg.AppsDomain == "" { |
| + log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set") |
| + return |
| + } |
| + if acmeCacheFlag != "" { |
| + cfg.AcmeDir = acmeCacheFlag |
| + } |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: domains loop disabled: %v", err) |
| + return |
| + } |
| + log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v", cfg.AppsDomain, cfg.EdgeIPs) |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = 5*time.Minute + rand.N(time.Minute) |
| + if err := domains.Reconcile(ctx, store, reg, cfg); err != nil { |
| + log.Printf("agent: domains pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| // resolveStatusBucket applies the status/deployment bucket defaulting |
| // documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET |
| // always wins, and when it's unset the status loop falls back to whatever |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 31965f120..44b880ede 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -5,10 +5,12 @@ import ( |
| "net" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -293,6 +295,61 @@ func TestWrapListeningProbeNullsUnitBackedSockets(t *testing.T) { |
| } |
| } |
| +// ---- domains: the reconcile loop's pure config builder ---------------------- |
| + |
| +// TestDomainsTickConfig pins the env contract: PLATFORM_APPS_DOMAIN unset |
| +// means a zero Config (Reconcile is inert on it — the feature does not |
| +// exist); set means every seam is populated, with PLATFORM_EDGE_IPS parsed |
| +// comma-separated and the box's public IP as the fallback. When NO edge IP |
| +// is known at all, Resolve stays nil: with nothing to compare against, |
| +// "pointing" vs "elsewhere" would be a guess, and unknowns are never guessed. |
| +func TestDomainsTickConfig(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + |
| + if got := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) { |
| + t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, want zero", got) |
| + } |
| + |
| + got := domainsTickConfig(env(map[string]string{ |
| + "PLATFORM_APPS_DOMAIN": "oncarlos.test", |
| + "PLATFORM_EDGE_IPS": "203.0.113.7, 198.51.100.9", |
| + "CARLOS_DATA_DIR": "/custom", |
| + }), "9.9.9.9") |
| + if got.AppsDomain != "oncarlos.test" { |
| + t.Errorf("AppsDomain = %q", got.AppsDomain) |
| + } |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) { |
| + t.Errorf("EdgeIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", got.EdgeIPs) |
| + } |
| + if got.AcmeDir != filepath.Join("/custom", "acme") { |
| + t.Errorf("AcmeDir = %q, want the edge's cache dir under CARLOS_DATA_DIR", got.AcmeDir) |
| + } |
| + if got.Resolve == nil || got.Now == nil { |
| + t.Error("Resolve/Now seams not populated on a configured deployment") |
| + } |
| + |
| + // No explicit edge IPs: the box's own public IP (IMDS) is the default. |
| + got = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9") |
| + if !reflect.DeepEqual(got.EdgeIPs, []string{"9.9.9.9"}) { |
| + t.Errorf("EdgeIPs = %v, want the public-IP fallback", got.EdgeIPs) |
| + } |
| + if got.AcmeDir != filepath.Join("/data", "acme") { |
| + t.Errorf("AcmeDir = %q, want the /data default", got.AcmeDir) |
| + } |
| + |
| + // No edge IPs knowable at all: DNS stamping stands down (Resolve nil), |
| + // but minting still runs — the domain still works, only health is unknown. |
| + got = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "") |
| + if got.EdgeIPs != nil || got.Resolve != nil { |
| + t.Errorf("no known edge IPs: EdgeIPs = %v Resolve nil? %v — stamping must stand down, not guess", got.EdgeIPs, got.Resolve == nil) |
| + } |
| + if got.AppsDomain != "oncarlos.test" { |
| + t.Error("minting must stay configured even without edge IPs") |
| + } |
| +} |
| + |
| func TestBootstrapIdempotent(t *testing.T) { |
| root := t.TempDir() |
| if err := bootstrap(root); err != nil { |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index ea2994893..e4365680a 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -57,6 +57,24 @@ type Route struct { |
| // DynamicUser, StateDirectory) that the activator drives via systemctl. |
| Backing string |
| + // Platform marks a route the domains reconcile pass minted |
| + // (<app>.<sqid>.<apps-domain>). Only platform routes are ever |
| + // auto-removed by reconciliation; custom domains are operator/owner |
| + // property and never touched. |
| + Platform bool |
| + // DNSStatus is the last DNS health verdict for this host: "pointing" |
| + // (resolves to a deployment edge IP), "elsewhere", or "" — unknown, the |
| + // honest answer for a resolver failure, never a guess. |
| + DNSStatus string |
| + DNSCheckedAt int64 // unix seconds of the last DNS check; 0 = never checked |
| + // CertExpiresAt is the NotAfter of the host's cached ACME cert, unix |
| + // seconds; 0 = no cert known (absent or unparseable — unknown, not "bad"). |
| + CertExpiresAt int64 |
| + // Account is the app→account linkage carried on the route rows |
| + // (consistent with the registry's route-centric shape); "" displays as |
| + // the built-in "ops" account. |
| + Account string |
| + |
| CreatedAt int64 |
| } |
| @@ -125,28 +143,76 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN next_due INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE routes ADD COLUMN backing TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN status_named INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN platform INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN dns_status TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN dns_checked_at INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| } |
| } |
| + // The accounts table: which account each app belongs to, for platform |
| + // domain sqids. Account #1 is "ops" — the deployment operator's built-in |
| + // account, created by migration with a PINNED id so its sqid is identical |
| + // on every deployment ever bootstrapped. INSERT OR IGNORE keeps re-runs |
| + // (every Open migrates) idempotent. |
| + if _, err := r.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS accounts ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + name TEXT UNIQUE NOT NULL |
| +);`); err != nil { |
| + return err |
| + } |
| + if _, err := r.sql.Exec(`INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'ops')`); err != nil { |
| + return err |
| + } |
| return nil |
| } |
| +// EnsureAccount returns the id for the named account, creating it if absent — |
| +// idempotent upsert-by-name (the reconcile pass calls this every tick for |
| +// every manifest it sees, so re-ensuring must never mint duplicate rows or |
| +// churn ids). |
| +func (r *Registry) EnsureAccount(name string) (int64, error) { |
| + if _, err := r.sql.Exec(`INSERT OR IGNORE INTO accounts (name) VALUES (?)`, name); err != nil { |
| + return 0, err |
| + } |
| + return r.AccountID(name) |
| +} |
| + |
| +// AccountID returns the id for the named account, or 0, nil when absent — |
| +// "no such account" is an answer, not an error. |
| +func (r *Registry) AccountID(name string) (int64, error) { |
| + var id int64 |
| + err := r.sql.QueryRow(`SELECT id FROM accounts WHERE name = ?`, name).Scan(&id) |
| + if err == sql.ErrNoRows { |
| + return 0, nil |
| + } |
| + if err != nil { |
| + return 0, err |
| + } |
| + return id, nil |
| +} |
| + |
| const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path, |
| version, prev_version, app, pool, region, channel, hibernate, canary, |
| -replication, next_due, backing, status_named, created_at` |
| +replication, next_due, backing, status_named, platform, dns_status, |
| +dns_checked_at, cert_expires_at, account, created_at` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| - var hib, can, sn int |
| + var hib, can, sn, plat int |
| err := scan(&t.Host, &t.Slug, &t.Kind, &t.Socket, &t.Addr, &t.Status, |
| &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool, |
| &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing, |
| - &sn, &t.CreatedAt) |
| + &sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account, |
| + &t.CreatedAt) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| + t.Platform = plat != 0 |
| return t, err |
| } |
| @@ -221,7 +287,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| _, err := r.sql.Exec(` |
| INSERT INTO routes (`+routeCols+`) |
| -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| ON CONFLICT(host) DO UPDATE SET |
| slug=excluded.slug, kind=excluded.kind, socket=excluded.socket, |
| addr=excluded.addr, status=excluded.status, last_error=excluded.last_error, |
| @@ -230,11 +296,38 @@ ON CONFLICT(host) DO UPDATE SET |
| region=excluded.region, channel=excluded.channel, |
| hibernate=excluded.hibernate, canary=excluded.canary, |
| replication=excluded.replication, next_due=excluded.next_due, |
| - backing=excluded.backing, status_named=excluded.status_named`, |
| + backing=excluded.backing, status_named=excluded.status_named, |
| + platform=excluded.platform, dns_status=excluded.dns_status, |
| + dns_checked_at=excluded.dns_checked_at, |
| + cert_expires_at=excluded.cert_expires_at, account=excluded.account`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
| t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, |
| b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing, |
| - b2i(t.StatusNamed), t.CreatedAt) |
| + b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt, |
| + t.CertExpiresAt, t.Account, t.CreatedAt) |
| + return err |
| +} |
| + |
| +// SetAccount stamps a route's app→account linkage as a narrow UPDATE — the |
| +// reconcile pass re-stamps this every tick from the bucket manifests, and a |
| +// read-modify-write Upsert racing a deploy's SetVersions (or the activator's |
| +// SetNextDue) would clobber those columns. |
| +func (r *Registry) SetAccount(host, account string) error { |
| + _, err := r.sql.Exec(`UPDATE routes SET account = ? WHERE host = ?`, account, host) |
| + return err |
| +} |
| + |
| +// SetDNSStatus stamps a route's DNS health verdict + check time, narrowly |
| +// (same rationale as SetAccount; this lands every few minutes on every host). |
| +func (r *Registry) SetDNSStatus(host, status string, checkedAt int64) error { |
| + _, err := r.sql.Exec(`UPDATE routes SET dns_status = ?, dns_checked_at = ? WHERE host = ?`, status, checkedAt, host) |
| + return err |
| +} |
| + |
| +// SetCertExpiresAt stamps a route's cached-cert NotAfter, narrowly. 0 = |
| +// unknown (no cert in the cache, or one we couldn't parse). |
| +func (r *Registry) SetCertExpiresAt(host string, at int64) error { |
| + _, err := r.sql.Exec(`UPDATE routes SET cert_expires_at = ? WHERE host = ?`, at, host) |
| return err |
| } |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index 07ab272f4..1d4029882 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -197,6 +197,85 @@ func TestSetVersionsAndHibernateAreNarrow(t *testing.T) { |
| } |
| } |
| +// openTestRegistry opens a fresh registry under a per-test temp dir — the |
| +// shared open helper for tests that don't care about the path. |
| +func openTestRegistry(t *testing.T) *Registry { |
| + t.Helper() |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return reg |
| +} |
| + |
| +// The ops account is the migration's own row — id 1, always, on every |
| +// deployment — so platform-domain sqids for CLI-added apps are stable without |
| +// any console ever existing. EnsureAccount must be an idempotent |
| +// upsert-by-name that never collides with it. |
| +func TestOpsAccountExistsAndEnsureIsIdempotent(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + id, err := reg.AccountID("ops") |
| + if err != nil || id != 1 { |
| + t.Fatalf("ops account: id=%d err=%v, want 1", id, err) |
| + } |
| + // Absent is 0, nil — not an error: "no such account" is an answer. |
| + if id, err := reg.AccountID("nobody"); err != nil || id != 0 { |
| + t.Fatalf("AccountID(absent) = %d, %v; want 0, nil", id, err) |
| + } |
| + a, _ := reg.EnsureAccount("paul") |
| + b, _ := reg.EnsureAccount("paul") |
| + if a != b || a == 1 { |
| + t.Errorf("EnsureAccount not idempotent or collided with ops: %d %d", a, b) |
| + } |
| +} |
| + |
| +func TestDomainColumnsRoundTrip(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + r := Route{Host: "kass.abs.oncarlos.com", Kind: "instance", App: "kass", |
| + Platform: true, DNSStatus: "pointing", DNSCheckedAt: 123, CertExpiresAt: 456, Account: "ops"} |
| + if err := reg.Upsert(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get(r.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.Platform || got.DNSStatus != "pointing" || got.DNSCheckedAt != 123 || |
| + got.CertExpiresAt != 456 || got.Account != "ops" { |
| + t.Errorf("round-trip lost fields: %+v", got) |
| + } |
| +} |
| + |
| +// The domain-health stamps land every few minutes on every route, so they go |
| +// through narrow UPDATEs (the SetVersions rationale: a read-modify-write |
| +// Upsert racing the activator's SetNextDue or a deploy's SetVersions would |
| +// clobber them). |
| +func TestDomainSettersAreNarrow(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "kass.training", Kind: "instance", App: "kass", Channel: "stable"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetAccount("kass.training", "paul"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetDNSStatus("kass.training", "elsewhere", 111); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCertExpiresAt("kass.training", 222); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("kass.training") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Account != "paul" || got.DNSStatus != "elsewhere" || got.DNSCheckedAt != 111 || got.CertExpiresAt != 222 { |
| + t.Fatalf("narrow domain updates lost: %+v", got) |
| + } |
| + if got.App != "kass" || got.Channel != "stable" { |
| + t.Fatalf("narrow domain update clobbered unrelated columns: %+v", got) |
| + } |
| +} |
| + |
| func TestStatusNamedRoundTrip(t *testing.T) { |
| reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| if err != nil { |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 261879ec4..87d0baf5c 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -116,9 +116,19 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ |
| // adoptable filters the routes a Pass moves: lifecycle-managed instances of |
| // a known app that are ready (an app-less or mid-provision route has nothing |
| -// to adopt or must not be disturbed). |
| +// to adopt or must not be disturbed), excluding platform-minted routes. |
| +// |
| +// A platform domain (internal/domains) is an ALIAS for an app's canonical |
| +// route — a second name pointing at the same backend — not a host with a |
| +// lifecycle of its own. Adopting one would stage a redundant copy of the |
| +// artifacts, mint a live/<alias> symlink nothing ever execs, try-restart a |
| +// carlos-app@<alias> unit that does not exist, and stamp Version onto a row |
| +// the console then renders as though it were a real instance. The canonical |
| +// route it aliases is adopted normally — that IS the deploy, and the alias |
| +// serves the new version the moment it lands. |
| func adoptable(rt registry.Route) bool { |
| - return rt.Kind == "instance" && rt.App != "" && (rt.Status == "" || rt.Status == "ready") |
| + return rt.Kind == "instance" && rt.App != "" && !rt.Platform && |
| + (rt.Status == "" || rt.Status == "ready") |
| } |
| // routeChannel is the channel a route follows; "" means stable. |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index 4f10b9019..ad98a5847 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -170,6 +170,55 @@ func TestPassSkipsUnadoptableRoutes(t *testing.T) { |
| } |
| } |
| +// TestPassSkipsPlatformMintedRoutes: a platform domain is an ALIAS for an |
| +// app's canonical route, not a lifecycle host of its own. Adopting one would |
| +// stage a second copy of the artifacts, mint a live/<alias> symlink no unit |
| +// ever execs, try-restart a carlos-app@<alias> unit that doesn't exist, and |
| +// stamp Version onto a row the console then renders as if it were a real |
| +// instance. The canonical route it aliases is adopted normally — that IS the |
| +// deploy. |
| +func TestPassSkipsPlatformMintedRoutes(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + // The canonical route and its platform alias, identical but for the flag. |
| + addRoute(t, reg, "myapp.example.com", "myapp", "canary/x", false) |
| + if err := reg.Upsert(registry.Route{Host: "myapp.bab.oncarlos.test", Kind: "instance", |
| + App: "myapp", Channel: "canary/x", Status: "ready", Platform: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + var restarted []string |
| + if err := Pass(ctx, reg, s, pub, opt, func(h string) error { |
| + restarted = append(restarted, h) |
| + return nil |
| + }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "myapp.bab.oncarlos.test"); v != "" { |
| + t.Errorf("platform alias was adopted (live = %q) — aliases are not lifecycle hosts", v) |
| + } |
| + for _, h := range restarted { |
| + if h == "myapp.bab.oncarlos.test" { |
| + t.Error("adopt try-restarted a platform alias's nonexistent unit") |
| + } |
| + } |
| + if rt, _ := reg.Get("myapp.bab.oncarlos.test"); rt != nil && rt.Version != "" { |
| + t.Errorf("adopt stamped Version %q onto a platform alias row", rt.Version) |
| + } |
| + // The canonical route still adopts: the alias must not have stolen its |
| + // pass (canaries bypass bake, so this lands immediately). |
| + if v, _ := LiveVersion(opt, "myapp.example.com"); v != "v1" { |
| + t.Errorf("canonical route live = %q, want v1 — skipping aliases must not skip real hosts", v) |
| + } |
| +} |
| + |
| func TestPassMissingPlatformArtifactSkips(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index acc6242f4..8a05fd2ec 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -158,6 +158,15 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| awake, asleep := 0, 0 |
| for _, r := range routes { |
| + // Platform-minted routes (internal/domains) are additional NAMES for |
| + // routes already counted below, not additional instances — counting |
| + // both would roughly double every fleet-wide number the moment a |
| + // deployment sets PLATFORM_APPS_DOMAIN, publishing a fleet larger |
| + // than the one that exists. Same reasoning as the opt-in and |
| + // null-propagation rules: the aggregate never overstates. |
| + if r.Platform { |
| + continue |
| + } |
| switch { |
| case r.Kind == "static": |
| counts.Static++ |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 8796f8b5e..ad2fb4b8a 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -60,6 +60,31 @@ func TestAwakeAsleepNullWhenUnreadable(t *testing.T) { |
| } |
| } |
| +// TestCollectBoxSkipsPlatformMintedRoutes: a platform domain is a second |
| +// NAME for a route already counted, not a second instance. Counting both |
| +// would roughly double every fleet-wide instance number the moment |
| +// PLATFORM_APPS_DOMAIN is set — the public status page overstating the |
| +// fleet, which is exactly the kind of quiet dishonesty the page's whole |
| +// design exists to prevent. |
| +func TestCollectBoxSkipsPlatformMintedRoutes(t *testing.T) { |
| + listening := func(string) *bool { v := true; return &v } |
| + routes := []registry.Route{ |
| + {Host: "kass.training", Kind: "instance", Status: "ready"}, |
| + {Host: "kass.bab.oncarlos.test", Kind: "instance", Status: "ready", Platform: true}, |
| + {Host: "site.custom", Kind: "static"}, |
| + {Host: "site.bab.oncarlos.test", Kind: "static", Platform: true}, |
| + {Host: "sleepy.custom", Kind: "instance", Hibernate: true, Socket: "/s.sock"}, |
| + {Host: "sleepy.bab.oncarlos.test", Kind: "instance", Hibernate: true, Socket: "/s.sock", Platform: true}, |
| + } |
| + b := status.CollectBox(routes, status.Probes{MachineID: "m", Listening: listening}, time.Now()) |
| + if b.Routes.Resident != 1 || b.Routes.Static != 1 || b.Routes.Hibernating != 1 { |
| + t.Errorf("counts = %+v, want exactly one of each: aliases must not inflate the fleet", b.Routes) |
| + } |
| + if b.Routes.Awake == nil || *b.Routes.Awake != 1 { |
| + t.Errorf("awake = %v, want 1 — the alias must not be counted awake twice", b.Routes.Awake) |
| + } |
| +} |
| + |
| func TestLabelStableAndOpaque(t *testing.T) { |
| a, b := status.Label("machine-1"), status.Label("machine-1") |
| if a != b || a == "" || strings.Contains(a, "machine") { |