feat: derived route_aliases registry table (#151)
Gives the registry the derived table global hostname claims (Task 1, internal/domains/claims.go) materialize into. A later reconcile pass owns every row; the edge reloads on a table change, so Fingerprint covers alias/host/account/app — but not the dns_status/dns_checked_at/ cert_expires_at stamp columns, which stamp every 5 minutes and would force a pointless fleet-wide edge reload each pass if hashed (same reasoning as the existing routes SELECT's own omission of those columns).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+307
−3
internal/registry/registry.go+121 −3internal/registry/registry_test.go+186 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index a0778d0b8..124ce77a6 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -333,6 +333,22 @@ CREATE TABLE IF NOT EXISTS routes ( |
| return err |
| } |
| } |
| + // route_aliases is a derived table (a later reconcile pass owns every |
| + // row, materializing domains.Claim records into it) — a plain CREATE |
| + // TABLE IF NOT EXISTS, no legacy shape to carry forward. |
| + if _, err := r.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS route_aliases ( |
| + alias TEXT PRIMARY KEY, |
| + host TEXT NOT NULL, |
| + account TEXT NOT NULL DEFAULT '', |
| + app TEXT NOT NULL DEFAULT '', |
| + dns_status TEXT NOT NULL DEFAULT '', |
| + dns_checked_at INTEGER NOT NULL DEFAULT 0, |
| + cert_expires_at INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL DEFAULT 0 |
| +);`); err != nil { |
| + return err |
| + } |
| if err := r.migrateAccounts(); err != nil { |
| return err |
| } |
| @@ -1167,9 +1183,88 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| return out, rows.Err() |
| } |
| -// Fingerprint summarizes the routes, fleets and fleet_boxes tables over a |
| -// fresh connection, for the edge's cheap change poll. It is content-accurate: |
| -// it hashes the actual VALUES of the reload-relevant columns, not a |
| +// RouteAlias is one derived row of the route_aliases table: an extra |
| +// hostname that resolves to an existing route's host, materialized from a |
| +// domains.Claim by a later reconcile pass (this package owns the storage |
| +// shape only — nothing here decides which claims win). Alias is the extra |
| +// hostname being attached; Host is the route it points at. DNSStatus, |
| +// DNSCheckedAt and CertExpiresAt are box-stamped health state, same shape as |
| +// the identically-named columns on routes, and are deliberately excluded |
| +// from Fingerprint (see there) so a stamping pass alone never forces an edge |
| +// reload. |
| +type RouteAlias struct { |
| + Alias string |
| + Host string |
| + Account string |
| + App string |
| + DNSStatus string |
| + DNSCheckedAt int64 |
| + CertExpiresAt int64 |
| + CreatedAt int64 |
| +} |
| + |
| +// ListAliases returns every route alias, ordered by alias. |
| +func (r *Registry) ListAliases() ([]RouteAlias, error) { |
| + rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status, |
| +dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []RouteAlias |
| + for rows.Next() { |
| + var a RouteAlias |
| + if err := rows.Scan(&a.Alias, &a.Host, &a.Account, &a.App, &a.DNSStatus, |
| + &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// UpsertAlias inserts or updates an alias row by its hostname. Only host, |
| +// account and app are overwritten on conflict — the DNS/cert stamps and |
| +// created_at are reconcile-pass- and box-stamp-owned state that a repoint |
| +// must not reset out from under the passes that maintain them (the same |
| +// preserved-column shape Upsert uses for routes, applied here by simply |
| +// leaving those columns out of the SET list rather than a CASE guard, since |
| +// every writer of this table already knows it must not carry stamps). |
| +func (r *Registry) UpsertAlias(a RouteAlias) error { |
| + _, err := r.sql.Exec(` |
| +INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at) |
| +VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| +ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app`, |
| + a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt) |
| + return err |
| +} |
| + |
| +// DeleteAlias removes an alias by its hostname. Idempotent: an alias already |
| +// gone is a success, same contract as Delete on routes. |
| +func (r *Registry) DeleteAlias(alias string) error { |
| + _, err := r.sql.Exec(`DELETE FROM route_aliases WHERE alias = ?`, alias) |
| + return err |
| +} |
| + |
| +// SetAliasDNSStatus stamps an alias's DNS health verdict + check time, |
| +// narrowly (same rationale as SetDNSStatus on routes: it lands on its own |
| +// cadence and must not race a concurrent UpsertAlias repoint). |
| +func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error { |
| + _, err := r.sql.Exec(`UPDATE route_aliases SET dns_status = ?, dns_checked_at = ? WHERE alias = ?`, status, at, alias) |
| + return err |
| +} |
| + |
| +// SetAliasCertExpiresAt stamps an alias's cached-cert NotAfter, narrowly. |
| +// 0 = unknown (same rationale as SetCertExpiresAt on routes). |
| +func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error { |
| + _, err := r.sql.Exec(`UPDATE route_aliases SET cert_expires_at = ? WHERE alias = ?`, at, alias) |
| + return err |
| +} |
| + |
| +// Fingerprint summarizes the routes, fleets, fleet_boxes and route_aliases |
| +// tables over a fresh connection, for the edge's cheap change poll. It is |
| +// content-accurate: it hashes the actual VALUES of the reload-relevant |
| +// columns, not a |
| // length-weighted aggregate, so two DIFFERENT values of the SAME length |
| // (channel canary/1 -> canary/2, a git short-sha version bump, a rotated |
| // sha256 token hash, a failover restamp box-a -> box-b) change the result |
| @@ -1293,5 +1388,28 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| return "", err |
| } |
| + // route_aliases: only alias/host/account/app decide which hostnames the |
| + // edge routes and where. dns_status/dns_checked_at/cert_expires_at are |
| + // deliberately excluded here for the same reason the routes SELECT above |
| + // omits its own dns_status/cert_expires_at columns — a box-side pass |
| + // stamps them on a 5-minute cadence, and hashing a value that moves on |
| + // that cadence would force a pointless fleet-wide edge reload every pass. |
| + fmt.Fprint(h, "\x1droute_aliases\x1e") |
| + arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer arows.Close() |
| + for arows.Next() { |
| + var alias, host, account, app string |
| + if err := arows.Scan(&alias, &host, &account, &app); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app) |
| + } |
| + if err := arows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index c537e3415..0f8717b7b 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1834,3 +1834,189 @@ func TestSetPlacementUnknownHostErrors(t *testing.T) { |
| t.Fatal("SetPlacement on an unknown host: got nil error, want one") |
| } |
| } |
| + |
| +// TestRouteAliasCRUD pins UpsertAlias/ListAliases/DeleteAlias's contract: a |
| +// re-upsert repoints host/account/app but must NOT reset the DNS/cert stamps |
| +// or created_at — those are reconcile-pass-owned and box-stamped state, the |
| +// same shape as Upsert's preserved columns on routes. |
| +func TestRouteAliasCRUD(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertAlias(RouteAlias{ |
| + Alias: "extra.example.com", Host: "app.example.com", |
| + Account: "bab", App: "myapp", CreatedAt: 1000, |
| + }); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + got, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases = %+v, want exactly one row", got) |
| + } |
| + if got[0].Alias != "extra.example.com" || got[0].Host != "app.example.com" || |
| + got[0].Account != "bab" || got[0].App != "myapp" || got[0].CreatedAt != 1000 { |
| + t.Fatalf("round-tripped alias = %+v, want the upserted values", got[0]) |
| + } |
| + |
| + // Stamp the row the way a box-side pass would, THEN re-upsert a repoint — |
| + // the stamps and created_at must survive. |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 2000); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + if err := reg.SetAliasCertExpiresAt("extra.example.com", 3000); err != nil { |
| + t.Fatalf("SetAliasCertExpiresAt: %v", err) |
| + } |
| + if err := reg.UpsertAlias(RouteAlias{ |
| + Alias: "extra.example.com", Host: "other.example.com", |
| + Account: "bad", App: "otherapp", CreatedAt: 9999, |
| + }); err != nil { |
| + t.Fatalf("re-UpsertAlias: %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases after re-upsert = %+v, want exactly one row", got) |
| + } |
| + a := got[0] |
| + if a.Host != "other.example.com" || a.Account != "bad" || a.App != "otherapp" { |
| + t.Fatalf("re-upsert did not repoint host/account/app: %+v", a) |
| + } |
| + if a.DNSStatus != "pointing" || a.DNSCheckedAt != 2000 || a.CertExpiresAt != 3000 { |
| + t.Fatalf("re-upsert clobbered stamps: %+v", a) |
| + } |
| + if a.CreatedAt != 1000 { |
| + t.Fatalf("re-upsert clobbered created_at: got %d, want the FIRST insert's 1000", a.CreatedAt) |
| + } |
| + |
| + // Delete is idempotent: gone is success, called twice is still success. |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias: %v", err) |
| + } |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias (already gone): %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("ListAliases after delete = %+v, want none", got) |
| + } |
| +} |
| + |
| +// TestRouteAliasStamps pins SetAliasDNSStatus/SetAliasCertExpiresAt as narrow |
| +// UPDATEs that persist independently of UpsertAlias — same rationale as |
| +// SetDNSStatus/SetCertExpiresAt on routes. |
| +func TestRouteAliasStamps(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "elsewhere", 111); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + if err := reg.SetAliasCertExpiresAt("extra.example.com", 222); err != nil { |
| + t.Fatalf("SetAliasCertExpiresAt: %v", err) |
| + } |
| + got, err := reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 { |
| + t.Fatalf("ListAliases = %+v, want exactly one row", got) |
| + } |
| + if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 { |
| + t.Fatalf("stamps did not persist: %+v", got[0]) |
| + } |
| + |
| + // A same-host UpsertAlias (no repoint) must not disturb the stamps either. |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + got, err = reg.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 { |
| + t.Fatalf("UpsertAlias disturbed stamps that survived a plain re-upsert: %+v", got[0]) |
| + } |
| +} |
| + |
| +// TestFingerprintCoversAliases pins the fingerprint contract from the task |
| +// brief: an alias write moves the fingerprint (so the edge reloads and |
| +// starts routing it), but a STAMP write never does — stamping runs on a |
| +// 5-minute cadence and hashing it would force a pointless fleet-wide edge |
| +// reload every pass (same reasoning as the routes section's own omission of |
| +// dns_status/cert_expires_at). |
| +func TestFingerprintCoversAliases(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + fp1, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com", Account: "bab", App: "myapp"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + fp2, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp2 == fp1 { |
| + t.Fatal("Fingerprint blind to an alias UpsertAlias") |
| + } |
| + |
| + if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 12345); err != nil { |
| + t.Fatalf("SetAliasDNSStatus: %v", err) |
| + } |
| + fp3, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp3 != fp2 { |
| + t.Fatalf("Fingerprint moved on an alias stamp (%s -> %s) — the edge would reload for a change that does not touch routing", fp2, fp3) |
| + } |
| + |
| + if err := reg.DeleteAlias("extra.example.com"); err != nil { |
| + t.Fatalf("DeleteAlias: %v", err) |
| + } |
| + fp4, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp4 == fp2 { |
| + t.Fatal("Fingerprint blind to a DeleteAlias") |
| + } |
| +} |
| + |
| +// TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the |
| +// new table: opening an already-migrated registry a second time (CREATE |
| +// TABLE IF NOT EXISTS route_aliases) must not error. |
| +func TestMigrateIdempotentWithAliases(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + r1, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("first Open: %v", err) |
| + } |
| + if err := r1.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil { |
| + t.Fatalf("UpsertAlias: %v", err) |
| + } |
| + if err := r1.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + r2, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("second Open over the same path: %v", err) |
| + } |
| + defer r2.Close() |
| + got, err := r2.ListAliases() |
| + if err != nil { |
| + t.Fatalf("ListAliases: %v", err) |
| + } |
| + if len(got) != 1 || got[0].Alias != "extra.example.com" { |
| + t.Fatalf("route_aliases row lost across a second Open: %+v", got) |
| + } |
| +} |