server: provisioner stamps home_region at create (multi-edge Phase 1, slice 2) (#1094)
Slice 1 taught the mesh to place a space by its home_region, but nothing set that field — it was always "". This makes the cloud provisioner stamp it, so new spaces get a region and slice-1 placement takes effect. The chain, config -> payload -> controlCreateReq -> addInstanceOpts.HomeRegion (the registry recording downstream already existed): - cloud: read LCHAT_CLOUD_REGION (new --region flag) onto cloudServer.region, and include it as home_region in the controlCreate payload. Both the regular and demo creates pass through controlCreate, so both are covered uniformly. - controld: add HomeRegion to controlCreateReq and pass it (TrimSpace'd) into addInstanceOpts.HomeRegion in createHandler. - cli: `lchat add-instance --home-region <r>` sets HomeRegion directly, for operators and canaries. Addressing is untouched — the host still forms from --base-domain exactly as today; region is a mutable property, never part of the name (region-neutral hosts + the id-ledger question are a later slice). Back-compat is byte-for-byte: LCHAT_CLOUD_REGION unset and no --home-region => home_region stays "" => spaces hash across the whole fleet as before. Inert until configured. Tests: cloud forwards a configured region through to control on regular AND demo creates (and leaves it empty when unset); controld's create lands home_region on the registry route; `add-instance --home-region` records it via the real CLI entry point.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
6 files changed,
+193
−9
cloud.go+26 −9cloud_test.go+91 −0control.go+2 −0control_test.go+24 −0controld.go+12 −0controld_test.go+38 −0
diff --git a/cloud.go b/cloud.go| index d22ca76..2577369 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -75,14 +75,21 @@ type cloudServer struct { |
| db *cloudDB |
| adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke |
| controlToken string |
| - control *http.Client // dials the control daemon's unix socket |
| - baseDomain string // spaces land at <slug>.<baseDomain> |
| - quota int // max spaces per (caller, requester) |
| - reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off) |
| - reapDryRun bool // log what the reaper would reclaim, delete nothing |
| - reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited) |
| - billing *stripeBilling // optional space upgrades (billing.go); nil = not offered |
| - storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| + control *http.Client // dials the control daemon's unix socket |
| + baseDomain string // spaces land at <slug>.<baseDomain> |
| + // region (LCHAT_CLOUD_REGION) is this box's placement region, stamped as |
| + // every new space's home_region so the mesh places it by region (multi-edge |
| + // Phase 1, docs/multi-edge.md). Empty = the single implicit region: spaces |
| + // are born with home_region="" and hash across the whole fleet, exactly as |
| + // before regions existed. Addressing is unaffected — the host still forms |
| + // from baseDomain; region is a mutable property, never part of the name. |
| + region string |
| + quota int // max spaces per (caller, requester) |
| + reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off) |
| + reapDryRun bool // log what the reaper would reclaim, delete nothing |
| + reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited) |
| + billing *stripeBilling // optional space upgrades (billing.go); nil = not offered |
| + storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| // replicationClasses maps tier keys (+ "free") to durability-ladder classes |
| // (cloudreplication.go); empty = classes never pushed, every space streams. |
| replicationClasses map[string]string |
| @@ -299,7 +306,12 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| // entirely, so they never replicate (docs/demo-spaces.md: no backups). |
| func (s *cloudServer) controlCreate(host, entitlement string) (int, map[string]any, error) { |
| payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entitlement, |
| - Replication: s.replicationFor(entitlement, "")}) |
| + Replication: s.replicationFor(entitlement, ""), |
| + // Stamp this box's region as the new space's home_region (multi-edge |
| + // Phase 1) — the same on every create, regular or demo, since both reach |
| + // here. Empty when LCHAT_CLOUD_REGION is unset: the space stays |
| + // region-agnostic, today's behaviour unchanged. |
| + HomeRegion: s.region}) |
| req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload)) |
| if err != nil { |
| return 0, nil, err |
| @@ -475,6 +487,7 @@ func runCloud(args []string) { |
| socketPath := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr") |
| dbPath := fs.String("db", envOr("LCHAT_CLOUD_DB", "cloud.db"), "provisioner SQLite path") |
| baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)") |
| + region := fs.String("region", os.Getenv("LCHAT_CLOUD_REGION"), "placement region stamped as every new space's home_region (multi-edge, docs/multi-edge.md); empty = the single implicit region (home_region stays \"\")") |
| quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 1), "max free spaces per caller+requester (paid spaces never count)") |
| controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket") |
| reapAfterDays := fs.Int("reap-after-days", envInt("LCHAT_CLOUD_REAP_AFTER_DAYS", 0), "reclaim never-claimed (zero-member) spaces older than this many days; 0 disables the reaper") |
| @@ -548,6 +561,7 @@ func runCloud(args []string) { |
| controlToken: controlToken, |
| control: controlClient(*controlSocket), |
| baseDomain: *baseDomain, |
| + region: strings.TrimSpace(*region), |
| quota: *quota, |
| reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour, |
| reapDryRun: *reapDryRun, |
| @@ -571,6 +585,9 @@ func runCloud(args []string) { |
| if s.usageURL != "" { |
| log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL) |
| } |
| + if s.region != "" { |
| + log.Printf("cloud: region %q — new spaces are stamped home_region=%q", s.region, s.region) |
| + } |
| aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES"))) |
| if err != nil { |
| die("cloud: %v", err) |
diff --git a/cloud_test.go b/cloud_test.go| index 529ed3d..75d9058 100644 |
| --- a/cloud_test.go |
| +++ b/cloud_test.go |
| @@ -571,3 +571,94 @@ func TestSpaceSqidRoundTrip(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// regionTracker records the home_region of every create the fake control daemon |
| +// receives, so a test can assert what the cloud stamped. |
| +type regionTracker struct { |
| + mu sync.Mutex |
| + regions []string |
| +} |
| + |
| +func (tr *regionTracker) record(r string) { |
| + tr.mu.Lock() |
| + tr.regions = append(tr.regions, r) |
| + tr.mu.Unlock() |
| +} |
| + |
| +func (tr *regionTracker) last() string { |
| + tr.mu.Lock() |
| + defer tr.mu.Unlock() |
| + if len(tr.regions) == 0 { |
| + return "<none>" |
| + } |
| + return tr.regions[len(tr.regions)-1] |
| +} |
| + |
| +// regionCapturingControl answers every create 201 and records its home_region. |
| +func regionCapturingControl(t *testing.T, tr *regionTracker) string { |
| + return fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| + var req controlCreateReq |
| + json.NewDecoder(r.Body).Decode(&req) |
| + tr.record(req.HomeRegion) |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S", |
| + }) |
| + }) |
| +} |
| + |
| +// createSpaceKind is createSpace with an explicit kind ("" free, "demo"). |
| +func createSpaceKind(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { |
| + t.Helper() |
| + body, _ := json.Marshal(map[string]string{"requester": requester, "kind": kind}) |
| + req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body)) |
| + if token != "" { |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +// With LCHAT_CLOUD_REGION set (s.region), a cloud create stamps that region as |
| +// the new space's home_region in the control payload — the same on a regular |
| +// create and a demo, since both pass through controlCreate (multi-edge Phase 1). |
| +func TestCloudStampsHomeRegion(t *testing.T) { |
| + tr := ®ionTracker{} |
| + srv, s := newTestCloud(t, regionCapturingControl(t, tr)) |
| + s.region = "ie" |
| + s.demoTTL = 24 * time.Hour // let the kind=demo branch run |
| + s.demoMaxLive = 10 // ...without tripping the live-demo cap (default 0) |
| + tok := mintCaller(t, srv, "home") |
| + |
| + if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("free create: status %d", resp.StatusCode) |
| + } |
| + if got := tr.last(); got != "ie" { |
| + t.Errorf("free create home_region = %q, want ie", got) |
| + } |
| + |
| + if resp := createSpaceKind(t, srv, tok, "user-2", entDemo); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo create: status %d", resp.StatusCode) |
| + } |
| + if got := tr.last(); got != "ie" { |
| + t.Errorf("demo create home_region = %q, want ie", got) |
| + } |
| +} |
| + |
| +// With no region configured (the default), a create leaves home_region empty — |
| +// byte-for-byte today's region-agnostic behaviour, the non-negotiable back-compat. |
| +func TestCloudNoRegionLeavesHomeRegionEmpty(t *testing.T) { |
| + tr := ®ionTracker{} |
| + srv, _ := newTestCloud(t, regionCapturingControl(t, tr)) // s.region == "" |
| + tok := mintCaller(t, srv, "home") |
| + |
| + if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create: status %d", resp.StatusCode) |
| + } |
| + if got := tr.last(); got != "" { |
| + t.Errorf("home_region = %q, want empty (region-agnostic)", got) |
| + } |
| +} |
diff --git a/control.go b/control.go| index 6d9a397..aeb0dc8 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -362,6 +362,7 @@ func runAddInstance(args []string) { |
| homeOrigin := fs.String("home-origin", "", "per-instance LCHAT_HOME_ORIGIN") |
| pushRelayURL := fs.String("push-relay-url", "", "per-instance LCHAT_PUSH_RELAY_URL") |
| pushRelayToken := fs.String("push-relay-token", "", "per-instance LCHAT_PUSH_RELAY_TOKEN") |
| + homeRegion := fs.String("home-region", "", "placement region recorded as the instance's home_region (multi-edge, docs/multi-edge.md) — the mesh owns the space on a box whose LCHAT_MESH_REGION matches; empty = region-agnostic (today's behaviour)") |
| hibernate := fs.Bool("hibernate", false, "register as activator-managed (no systemd unit; the router wakes it on demand — requires the router to run with LCHAT_HIBERNATE_BUCKET)") |
| replication := fs.String("replication", "", "replication class on the activator's S3 replica: stream (default), batched, or daily — the per-plan durability ladder; inert without --hibernate") |
| canary := fs.Bool("canary", false, "mark as a throwaway test instance (a per-session canary) — a deliberate signal, never inferred from the hostname; lets fleet ops and a future scoped reset treat it as disposable") |
| @@ -378,6 +379,7 @@ func runAddInstance(args []string) { |
| HomeOrigin: *homeOrigin, |
| PushRelayURL: *pushRelayURL, |
| PushRelayToken: *pushRelayToken, |
| + HomeRegion: strings.TrimSpace(*homeRegion), |
| Hibernate: *hibernate, |
| Canary: *canary, |
| Replication: strings.ToLower(strings.TrimSpace(*replication)), |
diff --git a/control_test.go b/control_test.go| index dcc1433..65612c4 100644 |
| --- a/control_test.go |
| +++ b/control_test.go |
| @@ -191,6 +191,30 @@ func TestRegisterThenMaterializeMatchesAddInstance(t *testing.T) { |
| // 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. |
| +// `lchat add-instance --home-region <r>` records the region on the new route |
| +// (multi-edge Phase 1): the flag flows through addInstanceOpts.HomeRegion into |
| +// the registry row the mesh later places by, with surrounding whitespace |
| +// trimmed. Exercises the real CLI entry point (runAddInstance) over the shared |
| +// controlTestEnv fakes. |
| +func TestAddInstanceHomeRegionFlag(t *testing.T) { |
| + _ = controlTestEnv(t) // fakes: systemd (binds the socket), self-stage, dirs, public-ready |
| + regPath := filepath.Join(t.TempDir(), "registry.db") |
| + |
| + runAddInstance([]string{"region.example.com", "--home-region", " ie ", "--registry", regPath}) |
| + |
| + reg, err := openRegistry(regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := reg.get("region.example.com") |
| + if err != nil || rt == nil { |
| + t.Fatalf("route = %+v, err %v", rt, err) |
| + } |
| + if rt.HomeRegion != "ie" { |
| + t.Errorf("home_region = %q, want ie (flag value, trimmed)", rt.HomeRegion) |
| + } |
| +} |
| + |
| func TestRegisterAcceptsRegionProvider(t *testing.T) { |
| reg := controlTestEnv(t) |
diff --git a/controld.go b/controld.go| index 1522111..fe594f7 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -101,6 +101,13 @@ type controlCreateReq struct { |
| // Replication classes the space at birth (registry.go's durability ladder) |
| // so a free space never spends a single awake window on the streaming rung. |
| Replication string `json:"replication,omitempty"` |
| + // HomeRegion is the space's placement region (multi-edge Phase 1, |
| + // docs/multi-edge.md): the mesh owns a space on a box whose LCHAT_MESH_REGION |
| + // matches its home_region (mesh.candidatesFor), so relocating a space is just |
| + // a home_region flip. The cloud provisioner stamps its own LCHAT_CLOUD_REGION |
| + // here at create; "" (the default) keeps the space region-agnostic — hashed |
| + // across the whole fleet exactly as before regions existed. |
| + HomeRegion string `json:"home_region,omitempty"` |
| } |
| // controlErr is the wire error shape: a stable machine code plus a human |
| @@ -578,6 +585,11 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) { |
| PushRelayToken: req.PushRelayToken, |
| Entitlement: req.Entitlement, |
| Replication: strings.ToLower(strings.TrimSpace(req.Replication)), |
| + // Placement region (multi-edge Phase 1): recorded on the registry row, |
| + // where the mesh reads it to place the space (control.go registerInstance |
| + // → registry.go). TrimSpace only — region is matched case-sensitively |
| + // against LCHAT_MESH_REGION (mesh.go), never lower-cased. |
| + HomeRegion: strings.TrimSpace(req.HomeRegion), |
| // Hibernate-by-default (#550): with LCHAT_CONTROL_HIBERNATE_CREATES=1 |
| // every provisioner-created space is born activator-managed — the |
| // create's public-readiness probe is its one (first) wake, minting the |
diff --git a/controld_test.go b/controld_test.go| index 9710cdc..9b6c2d3 100644 |
| --- a/controld_test.go |
| +++ b/controld_test.go |
| @@ -261,6 +261,44 @@ func TestControldCreateBornClassed(t *testing.T) { |
| } |
| } |
| +// A create carrying home_region records it on the route (multi-edge Phase 1): |
| +// the field crosses the socket in controlCreateReq → addInstanceOpts.HomeRegion |
| +// → the registry row the mesh later places by (mesh.candidatesFor). The value is |
| +// trimmed but not otherwise touched; omitted leaves the space region-agnostic. |
| +func TestControldCreateStampsHomeRegion(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + srv, token := newTestControld(t, reg, 40) |
| + |
| + create := func(host, region string) { |
| + t.Helper() |
| + payload := map[string]string{"host": host} |
| + if region != "" { |
| + payload["home_region"] = region |
| + } |
| + body, _ := json.Marshal(payload) |
| + req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create %s: status = %d, want 201", host, resp.StatusCode) |
| + } |
| + } |
| + |
| + create("ie-space.11ch.at", " ie ") // trimmed crossing the boundary |
| + if rt, _ := reg.get("ie-space.11ch.at"); rt == nil || rt.HomeRegion != "ie" { |
| + t.Fatalf("route = %+v, want home_region=ie", rt) |
| + } |
| + // Omitted ⇒ region-agnostic, exactly today's behaviour (home_region ""). |
| + create("agnostic.11ch.at", "") |
| + if rt, _ := reg.get("agnostic.11ch.at"); rt == nil || rt.HomeRegion != "" { |
| + t.Fatalf("route = %+v, want empty home_region", rt) |
| + } |
| +} |
| + |
| func TestControldRequiresToken(t *testing.T) { |
| reg := controlTestEnv(t) |
| srv, _ := newTestControld(t, reg, 40) |