carlos / platform Public

registry: accounts + domain-health columns — domains become data, not lore

Additive accounts table (ops pinned at id 1 by migration, so its sqid is
identical on every deployment) and five additive route columns: platform,
dns_status, dns_checked_at, cert_expires_at, account. Health stamps go
through narrow UPDATEs so the every-few-minutes reconcile pass can never
clobber a concurrent deploy's column writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell beb91f4d101ea5617e4362ddc70640d38ff83d1c parent 1e1f048
2 files changed, +178 −6
  • internal/registry/registry.go +99 −6
  • internal/registry/registry_test.go +79 −0
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 {