server: split add-instance into register + materialize; registry gains home_region/provider (#550 Phase 0 items 5+6) (#625)
Pure refactor + additive schema, zero behavior change — the provisioning seams from the planet-scale plan (#550 Part 12 backlog): - addInstance is now registerInstance (validate host, mint/import token, write the "creating" registry row — pure metadata, no capacity) followed by materializeInstance (env file, staged binary, unit start, health, router reload, setup invite — the half that consumes capacity). The composed path and controld's capacity cap are untouched; the seam is what later lets "create a space" be just a row + prefix with materialization deferred to first wake (#550 Part 4 v2). - routes table gains home_region + provider (TEXT NOT NULL DEFAULT '', via the existing guarded ALTER idiom, safe over live registry DBs). "" = the single implicit region/provider; register() plumbs them through so a caller could set them, nothing reads them yet. Tests: register-then-materialize ends field-for-field where add-instance does; the migration adds defaulted columns over a pre-seam DB file and stays idempotent; register records region/provider and materialize preserves them.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+237
−34
control.go+59 −25control_test.go+154 −0registry.go+24 −9
diff --git a/control.go b/control.go| index 8fa36d0..2084331 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -115,6 +115,8 @@ type addInstanceOpts struct { |
| HomeOrigin string |
| PushRelayURL string |
| PushRelayToken string |
| + HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region |
| + Provider string // co-op seam (#550 Part 10): "" = the single implicit provider |
| UnitTmpl string // default "lchat@" |
| RouterUnit string // default "lchat-router" |
| } |
| @@ -130,36 +132,43 @@ type addInstanceResult struct { |
| } |
| // addInstance is the shared creation core behind `lchat add-instance` and the |
| -// control daemon (controld.go): mint token + env file, register the route, stage |
| -// + link the binary, start the unit, health-check, reload the router. |
| +// control daemon (controld.go). It is deliberately two named steps — register |
| +// (the row) then materialize (the machinery) — because the planet-scale plan |
| +// (#550 Part 4 v2 / Part 5) eventually makes "create a space" *just* register: |
| +// a registry row plus a storage prefix, microseconds and uncapped, with |
| +// materialization deferred to the first wake (hibernation kills the day-one |
| +// blip). Today the two always run back to back and the caller's capacity cap |
| +// (controld.atCapacity) guards the pair, exactly as it did before the split. |
| func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error) { |
| + route, token, err := registerInstance(reg, opts) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return materializeInstance(reg, route, token, opts) |
| +} |
| + |
| +// registerInstance records that the space exists: validate the host, mint (or |
| +// import) the admin token, and write the registry row (status "creating"). |
| +// Pure metadata — no env file, no unit, no process — so it never consumes box |
| +// capacity. Placement (HomeRegion/Provider) is recorded here because it's a |
| +// property of the space, not of any one materialization. |
| +func registerInstance(reg *Registry, opts addInstanceOpts) (Route, string, error) { |
| host := strings.ToLower(strings.TrimSpace(opts.Host)) |
| if !validHost(host) { |
| - return nil, fmt.Errorf("%w: %q (want a hostname like chat.example.com)", errInvalidHost, host) |
| + return Route{}, "", fmt.Errorf("%w: %q (want a hostname like chat.example.com)", errInvalidHost, host) |
| } |
| socket := filepath.Join(instanceSocketDir, host+".sock") |
| if err := checkSocketLen(socket); err != nil { |
| - return nil, fmt.Errorf("%w: %v", errInvalidHost, err) |
| - } |
| - unitTmpl := opts.UnitTmpl |
| - if unitTmpl == "" { |
| - unitTmpl = "lchat@" |
| - } |
| - routerUnit := opts.RouterUnit |
| - if routerUnit == "" { |
| - routerUnit = "lchat-router" |
| + return Route{}, "", fmt.Errorf("%w: %v", errInvalidHost, err) |
| } |
| if existing, err := reg.get(host); err != nil { |
| - return nil, err |
| + return Route{}, "", err |
| } else if existing != nil && existing.Status == "ready" { |
| - return nil, fmt.Errorf("%w: %q", errHostExists, host) |
| + return Route{}, "", fmt.Errorf("%w: %q", errHostExists, host) |
| } |
| // A prior failed/creating attempt is overwritten on retry. |
| - // The instance stores only the token's hash, so this is the one chance to |
| - // capture the plaintext. Persist it (and per-instance config) to an |
| - // EnvironmentFile the unit reads — that keeps it recoverable via `admin-link`. |
| // An imported AdminToken (migration): the instance's boot rewrites |
| // admin_token_hash from the env, so passing the old token over a pre-placed |
| // migrated DB leaves the hash — and every saved admin link — intact. |
| @@ -167,9 +176,41 @@ func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error |
| if token == "" { |
| var err error |
| if token, err = randToken(); err != nil { |
| - return nil, fmt.Errorf("token: %v", err) |
| + return Route{}, "", fmt.Errorf("token: %v", err) |
| } |
| } |
| + |
| + route := Route{Host: host, Kind: "instance", Socket: socket, |
| + DBPath: filepath.Join(instanceDataDir, host+".db"), Status: "creating", |
| + AppStoreURL: opts.AppStoreURL, HomeOrigin: opts.HomeOrigin, PushRelayURL: opts.PushRelayURL, |
| + HomeRegion: opts.HomeRegion, Provider: opts.Provider} |
| + if err := reg.upsert(route); err != nil { |
| + return Route{}, "", fmt.Errorf("register: %v", err) |
| + } |
| + return route, token, nil |
| +} |
| + |
| +// materializeInstance turns a registered row into a running instance: env file, |
| +// staged binary + live symlink, systemd unit, health check, router reload, |
| +// setup invite. This is the half that consumes real capacity (a process, a |
| +// socket, disk), which is why the capacity cap conceptually belongs to it — and |
| +// the seam exists so it can later run at first *wake* rather than at create |
| +// (#550 Part 4 v2). |
| +func materializeInstance(reg *Registry, route Route, token string, opts addInstanceOpts) (*addInstanceResult, error) { |
| + host := route.Host |
| + socket := route.Socket |
| + unitTmpl := opts.UnitTmpl |
| + if unitTmpl == "" { |
| + unitTmpl = "lchat@" |
| + } |
| + routerUnit := opts.RouterUnit |
| + if routerUnit == "" { |
| + routerUnit = "lchat-router" |
| + } |
| + |
| + // The instance stores only the token's hash, so this is the one chance to |
| + // capture the plaintext. Persist it (and per-instance config) to an |
| + // EnvironmentFile the unit reads — that keeps it recoverable via `admin-link`. |
| env := map[string]string{"LCHAT_ADMIN_TOKEN": token} |
| for k, v := range map[string]string{ |
| "LCHAT_APP_STORE_URL": opts.AppStoreURL, |
| @@ -186,13 +227,6 @@ func addInstance(reg *Registry, opts addInstanceOpts) (*addInstanceResult, error |
| return nil, fmt.Errorf("write env file: %v", err) |
| } |
| - dbPath := filepath.Join(instanceDataDir, host+".db") |
| - route := Route{Host: host, Kind: "instance", Socket: socket, DBPath: dbPath, Status: "creating", |
| - AppStoreURL: opts.AppStoreURL, HomeOrigin: opts.HomeOrigin, PushRelayURL: opts.PushRelayURL} |
| - if err := reg.upsert(route); err != nil { |
| - return nil, fmt.Errorf("register: %v", err) |
| - } |
| - |
| // The instance's unit execs /opt/lchat/live/<host>; stage the running control |
| // binary and point that symlink at it, so ExecStart resolves on first start. |
| // A fresh instance therefore runs whatever build the operator's `lchat` is. |
diff --git a/control_test.go b/control_test.go| index 8f5537b..b9ac305 100644 |
| --- a/control_test.go |
| +++ b/control_test.go |
| @@ -1,9 +1,11 @@ |
| package main |
| import ( |
| + "database/sql" |
| "flag" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "testing" |
| ) |
| @@ -115,3 +117,155 @@ func TestRegistryGet(t *testing.T) { |
| t.Fatalf("after delete, get = %v, %v; want nil, nil", got, err) |
| } |
| } |
| + |
| +// The register/materialize seam must be pure refactor: running the two named |
| +// steps by hand ends in exactly the state `lchat add-instance` produces. |
| +func TestRegisterThenMaterializeMatchesAddInstance(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + |
| + composed, err := addInstance(reg, addInstanceOpts{Host: "composed.example.com"}) |
| + if err != nil { |
| + t.Fatalf("addInstance: %v", err) |
| + } |
| + |
| + route, token, err := registerInstance(reg, addInstanceOpts{Host: "split.example.com"}) |
| + if err != nil { |
| + t.Fatalf("register: %v", err) |
| + } |
| + // After register alone the space exists but nothing runs: a "creating" row, |
| + // no env file (the token's only persistent home is written by materialize). |
| + if rt, err := reg.get("split.example.com"); err != nil || rt == nil || rt.Status != "creating" { |
| + t.Fatalf("after register, route = %+v, err %v; want status creating", rt, err) |
| + } |
| + if _, err := os.Stat(filepath.Join(instanceDataDir, "split.example.com.env")); !os.IsNotExist(err) { |
| + t.Error("register wrote the env file — that's materialize's job") |
| + } |
| + split, err := materializeInstance(reg, route, token, addInstanceOpts{Host: "split.example.com"}) |
| + if err != nil { |
| + t.Fatalf("materialize: %v", err) |
| + } |
| + |
| + // Both paths yield a ready route with the same host-independent shape… |
| + for _, host := range []string{"composed.example.com", "split.example.com"} { |
| + res := composed |
| + if host == "split.example.com" { |
| + res = split |
| + } |
| + rt, err := reg.get(host) |
| + if err != nil || rt == nil { |
| + t.Fatalf("get(%s): %+v, %v", host, rt, err) |
| + } |
| + if rt.Status != "ready" || rt.Kind != "instance" || rt.Version == "" { |
| + t.Errorf("%s: route = %+v, want ready instance with a version", host, *rt) |
| + } |
| + // …an env file holding the admin token the result reports… |
| + env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) |
| + if err != nil { |
| + t.Fatalf("read env %s: %v", host, err) |
| + } |
| + if env["LCHAT_ADMIN_TOKEN"] != res.AdminToken { |
| + t.Errorf("%s: env token != result token", host) |
| + } |
| + // …and a setup link minted against the started instance. |
| + if !strings.HasPrefix(res.SetupLink, "https://"+host+"/join/setup#") { |
| + t.Errorf("%s: setup link = %q", host, res.SetupLink) |
| + } |
| + } |
| + |
| + // Field-for-field, the two routes differ only in what derives from the host |
| + // (paths, sockets) and the create timestamp. |
| + a, _ := reg.get("composed.example.com") |
| + b, _ := reg.get("split.example.com") |
| + for _, rt := range []*Route{a, b} { |
| + rt.Host, rt.Socket, rt.DBPath, rt.CreatedAt = "", "", "", 0 |
| + } |
| + if !reflect.DeepEqual(*a, *b) { |
| + t.Errorf("routes diverge:\n composed %+v\n split %+v", *a, *b) |
| + } |
| +} |
| + |
| +// register() carries the geo/co-op placement seams onto the row, and |
| +// materialize preserves them through its ready upsert. Nothing else may key on |
| +// them yet — this test pins only the plumbing. |
| +func TestRegisterAcceptsRegionProvider(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + |
| + opts := addInstanceOpts{Host: "geo.example.com", HomeRegion: "eu-central-1", Provider: "hetzner"} |
| + route, token, err := registerInstance(reg, opts) |
| + if err != nil { |
| + t.Fatalf("register: %v", err) |
| + } |
| + if rt, _ := reg.get("geo.example.com"); rt == nil || rt.HomeRegion != "eu-central-1" || rt.Provider != "hetzner" { |
| + t.Fatalf("after register, route = %+v, want home_region/provider recorded", rt) |
| + } |
| + if _, err := materializeInstance(reg, route, token, opts); err != nil { |
| + t.Fatalf("materialize: %v", err) |
| + } |
| + rt, _ := reg.get("geo.example.com") |
| + if rt == nil || rt.Status != "ready" || rt.HomeRegion != "eu-central-1" || rt.Provider != "hetzner" { |
| + t.Errorf("after materialize, route = %+v, want placement preserved on the ready row", rt) |
| + } |
| +} |
| + |
| +// A registry created before the geo/co-op columns existed must gain them on |
| +// open, with existing rows reading as the implicit ""/"" placement — live boxes |
| +// carry such DBs, so the migration has to be safe over real data. |
| +func TestRegistryMigratesGeoProviderColumns(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "reg.db") |
| + |
| + // Build the pre-seam schema by hand and seed a row, as a live box would have. |
| + old, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := old.Exec(` |
| +CREATE TABLE routes ( |
| + host TEXT PRIMARY KEY, |
| + kind TEXT NOT NULL, |
| + socket TEXT NOT NULL, |
| + 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 '', |
| + app_store_url TEXT NOT NULL DEFAULT '', |
| + home_origin TEXT NOT NULL DEFAULT '', |
| + push_relay_url TEXT NOT NULL DEFAULT '', |
| + push_relay_token TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL |
| +); |
| +INSERT INTO routes (host, kind, socket, created_at) VALUES ('old.example.com', 'instance', '/run/lchat/old.sock', 1700000000); |
| +`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := old.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg, err := openRegistry(path) |
| + if err != nil { |
| + t.Fatalf("open over pre-seam registry: %v", err) |
| + } |
| + rt, err := reg.get("old.example.com") |
| + if err != nil || rt == nil { |
| + t.Fatalf("get pre-existing row: %+v, %v", rt, err) |
| + } |
| + if rt.HomeRegion != "" || rt.Provider != "" { |
| + t.Errorf("pre-existing row: home_region=%q provider=%q, want the implicit \"\"/\"\"", rt.HomeRegion, rt.Provider) |
| + } |
| + |
| + // New values round-trip through the migrated table. |
| + rt.HomeRegion, rt.Provider = "us-east-1", "aws" |
| + if err := reg.upsert(*rt); err != nil { |
| + t.Fatalf("upsert with placement: %v", err) |
| + } |
| + got, err := reg.get("old.example.com") |
| + if err != nil || got == nil || got.HomeRegion != "us-east-1" || got.Provider != "aws" { |
| + t.Errorf("round-trip = %+v, %v", got, err) |
| + } |
| + |
| + // migrate is idempotent — reopening must not trip on the added columns. |
| + if _, err := openRegistry(path); err != nil { |
| + t.Errorf("reopen after migration: %v", err) |
| + } |
| +} |
diff --git a/registry.go b/registry.go| index b969f68..330717b 100644 |
| --- a/registry.go |
| +++ b/registry.go |
| @@ -28,7 +28,13 @@ type Route struct { |
| HomeOrigin string |
| PushRelayURL string |
| PushRelayToken string |
| - CreatedAt int64 |
| + // HomeRegion + Provider are the geo and co-op seams (#550 Parts 7 + 10): |
| + // where a space lives and who runs it. Schema-only today — "" means the |
| + // single implicit region/provider (this box), and nothing keys behavior on |
| + // them yet; register() just records what a caller sets. |
| + HomeRegion string |
| + Provider string |
| + CreatedAt int64 |
| } |
| func openRegistry(path string) (*Registry, error) { |
| @@ -71,6 +77,8 @@ CREATE TABLE IF NOT EXISTS routes ( |
| home_origin TEXT NOT NULL DEFAULT '', |
| push_relay_url TEXT NOT NULL DEFAULT '', |
| push_relay_token TEXT NOT NULL DEFAULT '', |
| + home_region TEXT NOT NULL DEFAULT '', |
| + provider TEXT NOT NULL DEFAULT '', |
| created_at INTEGER NOT NULL |
| );`); err != nil { |
| return err |
| @@ -80,6 +88,8 @@ CREATE TABLE IF NOT EXISTS routes ( |
| // "duplicate column" error so this stays idempotent. |
| for _, stmt := range []string{ |
| `ALTER TABLE routes ADD COLUMN prev_version TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN home_region TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN provider TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -92,7 +102,8 @@ CREATE TABLE IF NOT EXISTS routes ( |
| func (r *Registry) list() ([]Route, error) { |
| rows, err := r.sql.Query(` |
| SELECT host, kind, socket, status, last_error, db_path, version, prev_version, |
| - app_store_url, home_origin, push_relay_url, push_relay_token, created_at |
| + app_store_url, home_origin, push_relay_url, push_relay_token, |
| + home_region, provider, created_at |
| FROM routes ORDER BY host`) |
| if err != nil { |
| return nil, err |
| @@ -103,7 +114,7 @@ FROM routes ORDER BY host`) |
| var t Route |
| if err := rows.Scan(&t.Host, &t.Kind, &t.Socket, &t.Status, &t.LastError, |
| &t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin, |
| - &t.PushRelayURL, &t.PushRelayToken, &t.CreatedAt); err != nil { |
| + &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.CreatedAt); err != nil { |
| return nil, err |
| } |
| out = append(out, t) |
| @@ -116,10 +127,11 @@ func (r *Registry) get(host string) (*Route, error) { |
| var t Route |
| err := r.sql.QueryRow(` |
| SELECT host, kind, socket, status, last_error, db_path, version, prev_version, |
| - app_store_url, home_origin, push_relay_url, push_relay_token, created_at |
| + app_store_url, home_origin, push_relay_url, push_relay_token, |
| + home_region, provider, created_at |
| FROM routes WHERE host = ?`, host).Scan(&t.Host, &t.Kind, &t.Socket, &t.Status, |
| &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.AppStoreURL, &t.HomeOrigin, |
| - &t.PushRelayURL, &t.PushRelayToken, &t.CreatedAt) |
| + &t.PushRelayURL, &t.PushRelayToken, &t.HomeRegion, &t.Provider, &t.CreatedAt) |
| if err == sql.ErrNoRows { |
| return nil, nil |
| } |
| @@ -151,15 +163,18 @@ func (r *Registry) upsert(t Route) error { |
| } |
| _, err := r.sql.Exec(` |
| INSERT INTO routes (host, kind, socket, status, last_error, db_path, version, prev_version, |
| - app_store_url, home_origin, push_relay_url, push_relay_token, created_at) |
| -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |
| + app_store_url, home_origin, push_relay_url, push_relay_token, |
| + home_region, provider, created_at) |
| +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| ON CONFLICT(host) DO UPDATE SET |
| kind=excluded.kind, socket=excluded.socket, status=excluded.status, |
| last_error=excluded.last_error, db_path=excluded.db_path, version=excluded.version, |
| prev_version=excluded.prev_version, |
| app_store_url=excluded.app_store_url, home_origin=excluded.home_origin, |
| - push_relay_url=excluded.push_relay_url, push_relay_token=excluded.push_relay_token`, |
| + push_relay_url=excluded.push_relay_url, push_relay_token=excluded.push_relay_token, |
| + home_region=excluded.home_region, provider=excluded.provider`, |
| t.Host, t.Kind, t.Socket, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion, |
| - t.AppStoreURL, t.HomeOrigin, t.PushRelayURL, t.PushRelayToken, t.CreatedAt) |
| + t.AppStoreURL, t.HomeOrigin, t.PushRelayURL, t.PushRelayToken, |
| + t.HomeRegion, t.Provider, t.CreatedAt) |
| return err |
| } |