carlos / platform Public

feat(registry): provisioned + seed route columns

Adds Route.Provisioned and Route.Seed, the storage two later tasks depend
on: Provisioned marks a row internal/provision's reconcile pass minted (its
own auto-removal authority flag, kept separate from Platform because the two
mean opposite things to their consumers), Seed carries the app's first-boot
init argv as a JSON array for the activator's restore-on-miss wake path.
SetSeed follows the SetChannel contract (errors on zero rows) so the
reconciler notices a vanished row rather than silently no-opping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 046c336a8a47c17e587203c1a10a602b50049948 parent c82f69d
2 files changed, +150 −6
  • internal/registry/registry.go +47 −6
  • internal/registry/registry_test.go +103 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index d3431f19e..af45a6fab 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -86,6 +86,20 @@ type Route struct {
// auto-removed by reconciliation; custom domains are operator/owner
// property and never touched.
Platform bool
+ // Provisioned marks a route the instance-provisioning reconcile pass
+ // (internal/provision) minted from a bucket instance record. Only
+ // provisioned routes are ever auto-removed by that pass — the same
+ // authority rule Platform carries for the domains pass, kept as a
+ // SEPARATE flag because Platform means "alias to another route's
+ // upstream" to three consumers (config sync, upstream picking, apex
+ // candidacy) and a provisioned instance is the opposite: it owns a
+ // process.
+ Provisioned bool
+ // Seed is the app's first-boot init argv as a JSON array (e.g.
+ // ["seed"]), materialized from the app's instances.json by the
+ // provision pass; "" = no seed. Run by the activator only on a wake
+ // where restore-on-miss found no replica (backend_exec.go).
+ Seed string
// 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.
@@ -179,6 +193,8 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN environment TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN provisioned INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN seed TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -344,20 +360,22 @@ func (r *Registry) Accounts() ([]Account, error) {
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, platform, dns_status,
-dns_checked_at, cert_expires_at, account, environment, created_at`
+dns_checked_at, cert_expires_at, account, environment, provisioned, seed,
+created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
- var hib, can, sn, plat int
+ var hib, can, sn, plat, prov 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, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
- &t.Environment, &t.CreatedAt)
+ &t.Environment, &prov, &t.Seed, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
t.Platform = plat != 0
+ t.Provisioned = prov != 0
return t, err
}
@@ -442,7 +460,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,
@@ -455,12 +473,14 @@ ON CONFLICT(host) DO UPDATE SET
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,
- environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END`,
+ environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END,
+ provisioned=excluded.provisioned, seed=excluded.seed`,
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), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
- t.CertExpiresAt, t.Account, t.Environment, t.CreatedAt)
+ t.CertExpiresAt, t.Account, t.Environment, b2i(t.Provisioned), t.Seed,
+ t.CreatedAt)
return err
}
@@ -687,6 +707,27 @@ func (r *Registry) SetNextDue(host string, due int64) error {
return err
}
+// SetSeed stamps a route's first-boot init argv as a narrow UPDATE — same
+// rationale as SetAccount (a read-modify-write Upsert racing a deploy's
+// SetVersions or the activator's SetNextDue would clobber it). Unlike
+// SetAccount it errors when no row matched (the SetChannel contract): the
+// instance-provisioning reconcile pass re-stamps this every tick and must
+// notice a row that vanished under it rather than silently doing nothing.
+func (r *Registry) SetSeed(host, seed string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET seed = ? WHERE host = ?`, seed, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// DueInstances returns hibernatable instance routes whose next_due has
// arrived (0 means no schedule and never matches).
func (r *Registry) DueInstances(now int64) ([]Route, error) {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index aba9dc0b4..9f43b3289 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1,8 +1,10 @@
package registry
import (
+ "database/sql"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -724,6 +726,107 @@ func TestSetChannelsAndEnvironmentsAllOrNothing(t *testing.T) {
}
}
+// Provisioned + Seed round-trip through Upsert/Get, and SetSeed is the
+// reconciler's narrow-update door (same contract as SetChannel: errors on an
+// unmatched host so a vanished row is noticed, not silently no-opped).
+func TestProvisionedSeedRoundTrip(t *testing.T) {
+ r := openTestRegistry(t)
+ if err := r.Upsert(Route{Host: "a.go.tito.io", Kind: "instance", Provisioned: true, Seed: `["seed"]`}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := r.Get("a.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatal("Get returned nil for an upserted route")
+ }
+ if !got.Provisioned || got.Seed != `["seed"]` {
+ t.Fatalf("provisioned/seed did not round-trip: %+v", got)
+ }
+
+ if err := r.SetSeed("a.go.tito.io", `["seed","-v"]`); err != nil {
+ t.Fatal(err)
+ }
+ got, err = r.Get("a.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Seed != `["seed","-v"]` {
+ t.Fatalf("SetSeed did not update: %+v", got)
+ }
+ if !got.Provisioned {
+ t.Fatalf("SetSeed clobbered an unrelated column: %+v", got)
+ }
+
+ err = r.SetSeed("nope.example.com", `["x"]`)
+ if err == nil {
+ t.Fatal("SetSeed on an unknown host: got nil error, want one naming the host")
+ }
+ if !strings.Contains(err.Error(), "nope.example.com") {
+ t.Fatalf("SetSeed unknown-host error = %q, want it to name the host", err.Error())
+ }
+}
+
+// Opening a DB created before this change (no provisioned/seed columns) must
+// migrate cleanly — the ALTER list is additive, duplicate-column errors are
+// swallowed, and pre-existing rows default provisioned=false seed="" rather
+// than failing to open.
+func TestProvisionedSeedMigratesFromPreChangeDB(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+CREATE TABLE routes (
+ host TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ socket TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'ready',
+ last_error TEXT NOT NULL DEFAULT '',
+ db_path TEXT NOT NULL DEFAULT '',
+ version TEXT NOT NULL DEFAULT '',
+ prev_version TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO routes (host, kind, created_at) VALUES (?, ?, ?)`,
+ "old.example.com", "instance", 1234); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ r, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open over a pre-change DB: %v", err)
+ }
+ defer r.Close()
+ got, err := r.Get("old.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatal("pre-existing row vanished on migrate")
+ }
+ if got.Provisioned || got.Seed != "" {
+ t.Fatalf("pre-existing row should default provisioned=false seed=\"\": %+v", got)
+ }
+
+ // Reopening again (the ALTER already applied) must stay idempotent.
+ r2, err := Open(path)
+ if err != nil {
+ t.Fatalf("second Open: %v", err)
+ }
+ defer r2.Close()
+ if got2, err := r2.Get("old.example.com"); err != nil || got2 == nil {
+ t.Fatalf("second Open lost the row: %+v, %v", got2, err)
+ }
+}
+
// Review IMPORTANT 3: Upsert must not clear an existing row's Environment.
// `carlos add` (and any other full-row Upsert caller) does not know about
// environment bindings and passes the zero value, so a re-run on a bound host