| 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 |
| } |
| |