eleven / messenger Public

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>
Paul Campbell bc9b6af3246bcf8656d546ca8f2668f2a9c617ad parent 56e5d2c
6 files changed, +193 −9
  • cloud.go +26 −9
  • cloud_test.go +91 −0
  • control.go +2 −0
  • control_test.go +24 −0
  • controld.go +12 −0
  • controld_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 := &regionTracker{}
+ 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 := &regionTracker{}
+ 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)