eleven / ocho Public

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>
Paul Campbell 48c5eca3db6b98f814db69d455dbfb8fb8978434 parent 069d36c
3 files changed, +237 −34
  • control.go +59 −25
  • control_test.go +154 −0
  • registry.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
}