carlos / platform Public

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>
Paul Campbell 5bbf4eed7222579f606282193289285ca148138e parent 02da012parent 8c0e482
20 files changed, +1856 −30
  • README.md +70 −0
  • cmd/console/main.go +16 −0
  • e2e_test.go +42 −0
  • internal/console/server.go +72 −2
  • internal/console/server_test.go +149 −1
  • internal/console/templates/app.html +12 −11
  • internal/console/view.go +52 −3
  • internal/console/view_test.go +5 −5
  • internal/domains/reconcile.go +437 −0
  • internal/domains/reconcile_test.go +425 −0
  • internal/domains/sqid.go +34 −0
  • internal/domains/sqid_test.go +42 −0
  • internal/host/agent.go +170 −0
  • internal/host/host_test.go +57 −0
  • internal/registry/registry.go +99 −6
  • internal/registry/registry_test.go +79 −0
  • internal/release/adopt.go +12 −2
  • internal/release/adopt_test.go +49 −0
  • internal/status/snapshot.go +9 −0
  • internal/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&#39;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") {