home: per-country provisioning — route space create/manage by region (#1083)
Home has always talked to one cloud provisioner (LCHAT_CLOUD_URL/_TOKEN). To
place a user's space in a chosen country (the dev mesh's ie + fi provisioners),
home now optionally knows a set of regions and routes the whole space lifecycle
to the right one.
- Config: LCHAT_CLOUD_REGIONS (JSON [{code,name,url,token}]) +
LCHAT_CLOUD_DEFAULT_REGION. Empty keeps today's single-region behaviour
exactly; when set it supersedes LCHAT_CLOUD_URL and s.cloud becomes the
default region's provisioner (so demo and every fallback are unchanged).
- Create: POST /api/spaces takes an optional "region" — auto-assign to the
default when omitted, owner override when given, unknown code quietly
auto-assigns rather than failing. The region is home's routing concern; the
provisioner still receives only the opaque requester.
- List: GET /api/spaces fans out across every region and merges, so a user sees
spaces they hold anywhere; best-effort per region (one region down never
blanks the list, but an all-fail surfaces the gateway error).
- Manage: delete/checkout/portal route by the region label the host encodes
(<sqid>.<code>.…), so a space is always managed by the cloud that holds it.
- Features: GET /api/features advertises regions (code + name only — never a url
or token) + default_region, so a create UI can offer the choice. Single-region
home omits the key.
Back-compat: single-region resolvers all fall through to s.cloud, so every
existing home test passes unchanged. New region_test.go covers the parser,
region-routed create (override + auto-assign + unknown), list fan-out (+ one
region down), host to region management routing, and the features exposure.
The visible create-page region control is a deliberate follow-on slice; today
the choice rides the API "region" field. docs/cloud-provisioner.md updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
4 files changed,
+469
−16
docs/cloud-provisioner.md+16 −0home/main.go+20 −1home/provision.go+171 −15home/region_test.go+262 −0
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 92fa580..6454b14 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -203,6 +203,22 @@ POST /api/spaces/{host}/portal bearer: device link token |
| → relays cloud's 200 {url} / 404 / 409 / 501 (Manage on a paid space's row) |
| ``` |
| +🤖 **Per-country provisioning (optional, `LCHAT_CLOUD_REGIONS`).** By default |
| +home talks to one provisioner (`LCHAT_CLOUD_URL` + `LCHAT_CLOUD_TOKEN`). Setting |
| +`LCHAT_CLOUD_REGIONS` — a JSON array of `{code,name,url,token}` — plus |
| +`LCHAT_CLOUD_DEFAULT_REGION` lets a signed-in user's space be placed in one of |
| +several country provisioners (the dev mesh runs `ie` + `fi`; see |
| +`docs/dev-mesh.md`). It supersedes the single `LCHAT_CLOUD_URL`; the default |
| +region's provisioner also backs demo and every host-agnostic fallback. Routing: |
| +`POST /api/spaces` takes an optional **`region`** (empty = auto-assign to the |
| +default; an unknown code quietly auto-assigns rather than failing); `GET |
| +/api/spaces` fans out across every region and merges, so a user sees spaces they |
| +hold anywhere; host-specific calls (delete, checkout, portal) route by the region |
| +label the host encodes (`<sqid>.<code>.…`). `GET /api/features` advertises the |
| +regions — `code` + `name` only, never a url or token — so the create UI can offer |
| +the choice; single-region home omits the key. (The create-page region control is |
| +a follow-on; today the choice rides the `region` field.) |
| + |
| Creation and listing are link-token-authed (additive/reads) and creation is |
| rate-limited per IP; the real abuse bound is the provisioner's quota. **With |
| `LCHAT_POW_BITS` set on home (default off), creation also demands hashcash**: |
diff --git a/home/main.go b/home/main.go| index 617302d..8147c9b 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -50,8 +50,16 @@ type Server struct { |
| // surface of an open, anonymous registration endpoint. |
| limiter *rateLimiter |
| // cloud is the optional space provisioner client (see provision.go); nil |
| - // means this home server doesn't offer space creation. |
| + // means this home server doesn't offer space creation. When regions is |
| + // non-empty (per-country provisioning), this points at the DEFAULT region's |
| + // provisioner, so demo and every host-agnostic fallback keep working. |
| cloud *cloudProvisioner |
| + // regions, when LCHAT_CLOUD_REGIONS names more than one provisioner, lets a |
| + // signed-in user's new space be placed per country (docs/dev-mesh.md). Empty |
| + // means single-region: s.cloud is the only provisioner. defaultRegion is the |
| + // region code a create with no explicit choice lands in. |
| + regions []cloudRegion |
| + defaultRegion string |
| // pow gates space creation behind hashcash when LCHAT_POW_BITS > 0 |
| // (pow.go); nil = no gate. |
| pow *powGate |
| @@ -116,6 +124,17 @@ func main() { |
| if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { |
| log.Printf("space provisioning via %s", s.cloud.url) |
| } |
| + // Per-country provisioning: LCHAT_CLOUD_REGIONS (a JSON array of |
| + // {code,name,url,token}) plus LCHAT_CLOUD_DEFAULT_REGION. When set it |
| + // supersedes the single LCHAT_CLOUD_URL — s.cloud becomes the default |
| + // region's provisioner so demo and fallbacks are unchanged. |
| + if regions, defaultCode, err := parseCloudRegions(os.Getenv("LCHAT_CLOUD_REGIONS"), os.Getenv("LCHAT_CLOUD_DEFAULT_REGION")); err != nil { |
| + log.Fatalf("cloud regions: %v", err) |
| + } else if len(regions) > 0 { |
| + s.regions, s.defaultRegion = regions, defaultCode |
| + s.cloud = s.cloudForRegion(defaultCode) |
| + log.Printf("space provisioning across %d regions (default %s)", len(regions), defaultCode) |
| + } |
| if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 { |
| s.pow = newPowGate(bits, s.keys) |
| log.Printf("space creation gated by proof of work (%d bits)", bits) |
diff --git a/home/provision.go b/home/provision.go| index f5ceb40..9e80661 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -16,6 +16,7 @@ package main |
| import ( |
| "bytes" |
| "encoding/json" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| @@ -44,14 +45,141 @@ func newCloudProvisioner(url, token string) *cloudProvisioner { |
| } |
| } |
| +// cloudRegion is one provisioner home can place a space in, keyed by a short |
| +// code that also appears as the second DNS label of the hosts it serves |
| +// (e.g. "ie" in "8oe.ie.dev.11ch.at"). A home configured with more than one |
| +// (LCHAT_CLOUD_REGIONS) provisions per country; with none, the single |
| +// LCHAT_CLOUD_URL provisioner (s.cloud) is the only region. |
| +type cloudRegion struct { |
| + Code string // "ie" — matches the host's region label and the API `region` |
| + Name string // "Ireland" — the only user-facing string (shown in the picker) |
| + prov *cloudProvisioner |
| +} |
| + |
| +// regionConfig is the JSON shape of one LCHAT_CLOUD_REGIONS entry. token stays |
| +// server-side; only Code/Name are ever exposed (featuresHandler). |
| +type regionConfig struct { |
| + Code string `json:"code"` |
| + Name string `json:"name"` |
| + URL string `json:"url"` |
| + Token string `json:"token"` |
| +} |
| + |
| +// parseCloudRegions reads LCHAT_CLOUD_REGIONS (a JSON array of regionConfig) and |
| +// LCHAT_CLOUD_DEFAULT_REGION. Empty JSON → no regions (single-region mode, the |
| +// caller keeps its LCHAT_CLOUD_URL provisioner). The default code must name one |
| +// of the configured regions; empty defaults to the first. |
| +func parseCloudRegions(regionsJSON, defaultCode string) ([]cloudRegion, string, error) { |
| + regionsJSON = strings.TrimSpace(regionsJSON) |
| + if regionsJSON == "" { |
| + return nil, "", nil |
| + } |
| + var cfgs []regionConfig |
| + if err := json.Unmarshal([]byte(regionsJSON), &cfgs); err != nil { |
| + return nil, "", fmt.Errorf("parse LCHAT_CLOUD_REGIONS: %w", err) |
| + } |
| + regions := make([]cloudRegion, 0, len(cfgs)) |
| + seen := map[string]bool{} |
| + for _, c := range cfgs { |
| + if c.Code == "" { |
| + return nil, "", fmt.Errorf("a region is missing its code") |
| + } |
| + if seen[c.Code] { |
| + return nil, "", fmt.Errorf("region %q is listed twice", c.Code) |
| + } |
| + seen[c.Code] = true |
| + prov := newCloudProvisioner(c.URL, c.Token) |
| + if prov == nil { |
| + return nil, "", fmt.Errorf("region %q needs both url and token", c.Code) |
| + } |
| + name := c.Name |
| + if name == "" { |
| + name = c.Code |
| + } |
| + regions = append(regions, cloudRegion{Code: c.Code, Name: name, prov: prov}) |
| + } |
| + if defaultCode == "" { |
| + defaultCode = regions[0].Code |
| + } else if !seen[defaultCode] { |
| + return nil, "", fmt.Errorf("LCHAT_CLOUD_DEFAULT_REGION %q is not one of the configured regions", defaultCode) |
| + } |
| + return regions, defaultCode, nil |
| +} |
| + |
| +// cloudForRegion returns the provisioner for a region code, falling back to the |
| +// default provisioner (s.cloud) when the code is empty, unknown, or when home is |
| +// running single-region. So an unrecognised owner choice quietly auto-assigns |
| +// rather than failing. |
| +func (s *Server) cloudForRegion(code string) *cloudProvisioner { |
| + for i := range s.regions { |
| + if s.regions[i].Code == code { |
| + return s.regions[i].prov |
| + } |
| + } |
| + return s.cloud |
| +} |
| + |
| +// regionCodeForHost extracts the region a host lives in from its name — the |
| +// label after the leftmost (space-id) label, e.g. "8oe.ie.dev.11ch.at" → "ie" — |
| +// but only when that names a configured region. Single-region / prod hosts |
| +// ("8oe.11ch.at") carry no region label, so this returns "" and callers use the |
| +// default provisioner. |
| +func (s *Server) regionCodeForHost(host string) string { |
| + labels := strings.Split(host, ".") |
| + if len(labels) < 2 { |
| + return "" |
| + } |
| + for i := range s.regions { |
| + if s.regions[i].Code == labels[1] { |
| + return labels[1] |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// cloudForHost routes a host-specific operation (delete, checkout, portal) to |
| +// the provisioner of the region the host lives in, defaulting to s.cloud. |
| +func (s *Server) cloudForHost(host string) *cloudProvisioner { |
| + return s.cloudForRegion(s.regionCodeForHost(host)) |
| +} |
| + |
| +// allClouds is every distinct provisioner home can reach — all regions, or just |
| +// the single default when unpartitioned — used to fan a listing out so a user |
| +// sees the spaces they hold in every region. |
| +func (s *Server) allClouds() []*cloudProvisioner { |
| + if len(s.regions) == 0 { |
| + if s.cloud == nil { |
| + return nil |
| + } |
| + return []*cloudProvisioner{s.cloud} |
| + } |
| + out := make([]*cloudProvisioner, 0, len(s.regions)) |
| + for i := range s.regions { |
| + out = append(out, s.regions[i].prov) |
| + } |
| + return out |
| +} |
| + |
| // featuresHandler advertises which optional features this home server offers, |
| // so the page can hide UI it can't serve. Public: it reveals only a boolean, |
| // never a token or endpoint that would work without one. |
| func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) { |
| - writeJSON(w, http.StatusOK, map[string]any{ |
| + out := map[string]any{ |
| "spaces": s.cloud != nil, |
| "demo": s.demo != nil && s.cloud != nil, |
| - }) |
| + } |
| + // When home provisions per country, advertise the regions — code + name |
| + // only, never a url or token — and the default, so the create UI can offer |
| + // the choice. Absent → the client shows no region control (single-region). |
| + if len(s.regions) > 0 { |
| + regs := make([]map[string]string, len(s.regions)) |
| + for i, rg := range s.regions { |
| + regs[i] = map[string]string{"code": rg.Code, "name": rg.Name} |
| + } |
| + out["regions"] = regs |
| + out["default_region"] = s.defaultRegion |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| } |
| // challengeHandler: POST /api/spaces/challenge — mint a proof-of-work |
| @@ -108,15 +236,21 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| + // One read of the (optional) body: the region choice and, when the gate is |
| + // on, the solved proof of work ride together. A missing or empty body |
| + // decodes to the zero value — auto-assign the region, and (pow on) fall to |
| + // pow_required below. |
| + var req struct { |
| + Region string `json:"region"` |
| + Challenge string `json:"challenge"` |
| + Nonce string `json:"nonce"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| // The proof-of-work gate (pow.go): when enabled, creation carries a solved |
| // challenge. Checked AFTER the cheap gates so an ineligible caller never |
| // burns CPU for a refusal, and before the provisioner is ever dialed. |
| if s.pow != nil { |
| - var req struct { |
| - Challenge string `json:"challenge"` |
| - Nonce string `json:"nonce"` |
| - } |
| - if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || req.Challenge == "" { |
| + if req.Challenge == "" { |
| writeJSON(w, http.StatusForbidden, map[string]any{ |
| "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", |
| }) |
| @@ -129,7 +263,10 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| - status, body, err := s.cloud.createSpace(uid) |
| + // Owner override: an explicit region places the space there; empty |
| + // auto-assigns to the default region. An unknown code quietly falls back to |
| + // the default (cloudForRegion) rather than failing the create. |
| + status, body, err := s.cloudForRegion(req.Region).createSpace(uid) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
| "error": "create_failed", "message": "could not reach the space provisioner", |
| @@ -158,16 +295,35 @@ func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "unauthorized", http.StatusUnauthorized) |
| return |
| } |
| - if s.cloud == nil { |
| + clouds := s.allClouds() |
| + if len(clouds) == 0 { |
| writeJSON(w, http.StatusOK, map[string]any{"spaces": []any{}}) |
| return |
| } |
| - status, body, err := s.cloud.do("GET", "/api/instances?requester="+url.QueryEscape(uid), nil) |
| - if err != nil || status != http.StatusOK { |
| + // Fan the read out across every region and merge — a user's spaces may live |
| + // in more than one. Each host encodes its own region, so the client needs no |
| + // hint back; management calls route by host (cloudForHost). Best-effort per |
| + // region: one region being unreachable must not blank the list, but if every |
| + // region we tried failed, surface the gateway error rather than an empty list |
| + // that would read as "you have no spaces". |
| + path := "/api/instances?requester=" + url.QueryEscape(uid) |
| + spaces, gotOne, sawErr := []any{}, false, false |
| + for _, c := range clouds { |
| + status, body, err := c.do("GET", path, nil) |
| + if err != nil || status != http.StatusOK { |
| + sawErr = true |
| + continue |
| + } |
| + gotOne = true |
| + if arr, ok := body["spaces"].([]any); ok { |
| + spaces = append(spaces, arr...) |
| + } |
| + } |
| + if !gotOne && sawErr { |
| writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"}) |
| return |
| } |
| - writeJSON(w, http.StatusOK, body) |
| + writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces}) |
| } |
| // deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a |
| @@ -188,7 +344,7 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| host := r.PathValue("host") |
| - status, body, err := s.cloud.do("DELETE", |
| + status, body, err := s.cloudForHost(host).do("DELETE", |
| "/api/instances/"+url.PathEscape(host)+"?requester="+url.QueryEscape(uid), nil) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
| @@ -245,7 +401,7 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| "success_url": s.origin + "/?upgraded=" + url.QueryEscape(host), |
| "cancel_url": s.origin + "/", |
| }) |
| - status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload) |
| + status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
| "error": "checkout_failed", "message": "could not reach the space provisioner", |
| @@ -285,7 +441,7 @@ func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| "requester": uid, |
| "return_url": s.origin + "/", |
| }) |
| - status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload) |
| + status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
| "error": "portal_failed", "message": "could not reach the space provisioner", |
diff --git a/home/region_test.go b/home/region_test.go| new file mode 100644 |
| index 0000000..68933f7 |
| --- /dev/null |
| +++ b/home/region_test.go |
| @@ -0,0 +1,262 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// twoRegions wires s with an ie + fi provisioner pointed at the given fake |
| +// clouds, default ie — the per-country shape the dev mesh runs. |
| +func twoRegions(s *Server, ieURL, fiURL string) { |
| + s.regions = []cloudRegion{ |
| + {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(ieURL, "ie-tok")}, |
| + {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiURL, "fi-tok")}, |
| + } |
| + s.defaultRegion = "ie" |
| + s.cloud = s.cloudForRegion("ie") |
| +} |
| + |
| +func postSpacesJSON(t *testing.T, ts *httptest.Server, token, body string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func createdHost(t *testing.T, resp *http.Response) string { |
| + t.Helper() |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create status = %d, want 201", resp.StatusCode) |
| + } |
| + var m map[string]any |
| + json.NewDecoder(resp.Body).Decode(&m) |
| + host, _ := m["host"].(string) |
| + return host |
| +} |
| + |
| +func TestParseCloudRegions(t *testing.T) { |
| + // Empty → single-region: no regions, no default, no error. |
| + if regs, def, err := parseCloudRegions("", ""); err != nil || regs != nil || def != "" { |
| + t.Errorf("empty: got regs=%v def=%q err=%v", regs, def, err) |
| + } |
| + |
| + j := `[{"code":"ie","name":"Ireland","url":"https://cloud.ie","token":"a"},{"code":"fi","name":"Finland","url":"https://cloud.fi","token":"b"}]` |
| + regs, def, err := parseCloudRegions(j, "fi") |
| + if err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + if len(regs) != 2 || def != "fi" { |
| + t.Fatalf("regs=%d default=%q", len(regs), def) |
| + } |
| + if regs[0].Code != "ie" || regs[0].Name != "Ireland" || regs[0].prov == nil { |
| + t.Errorf("regs[0] = %+v", regs[0]) |
| + } |
| + |
| + // Empty default → the first region. |
| + if _, def2, err := parseCloudRegions(j, ""); err != nil || def2 != "ie" { |
| + t.Errorf("empty default = %q (err %v), want ie", def2, err) |
| + } |
| + // A missing name defaults to the code. |
| + if regs3, _, err := parseCloudRegions(`[{"code":"nl","url":"https://c","token":"t"}]`, ""); err != nil || regs3[0].Name != "nl" { |
| + t.Errorf("missing name should default to code: %+v (err %v)", regs3, err) |
| + } |
| + |
| + for _, bad := range []struct{ j, def, why string }{ |
| + {`[{"code":"ie","url":"https://c"}]`, "", "missing token"}, |
| + {`[{"code":"ie","name":"x","token":"t"}]`, "", "missing url"}, |
| + {`[{"code":"ie","url":"u","token":"t"}]`, "zz", "unknown default"}, |
| + {`[{"code":"ie","url":"u","token":"t"},{"code":"ie","url":"u","token":"t"}]`, "", "duplicate code"}, |
| + {`not json`, "", "bad json"}, |
| + {`[{"url":"u","token":"t"}]`, "", "missing code"}, |
| + } { |
| + if _, _, err := parseCloudRegions(bad.j, bad.def); err == nil { |
| + t.Errorf("%s: expected an error, got nil", bad.why) |
| + } |
| + } |
| +} |
| + |
| +func TestCreateSpaceRoutesByRegion(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + _, token := seedMember(t, s) |
| + |
| + // Each region's fake reports a host in its own country, so the create |
| + // response alone says which provisioner handled it — no capture ordering. |
| + ieCloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{ |
| + "host": "x.ie.example.com", "setup_link": "https://x.ie.example.com/join#S", |
| + }) |
| + fiCloud, fiCaptured, fiBody := fakeCloud(t, http.StatusCreated, map[string]any{ |
| + "host": "x.fi.example.com", "setup_link": "https://x.fi.example.com/join#S", |
| + }) |
| + twoRegions(s, ieCloud.URL, fiCloud.URL) |
| + |
| + // Owner override: an explicit region places the space there. |
| + if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"fi"}`)); host != "x.fi.example.com" { |
| + t.Errorf("region=fi landed on %q, want the fi cloud", host) |
| + } |
| + if got := fiCaptured.Header.Get("Authorization"); got != "Bearer fi-tok" { |
| + t.Errorf("fi create used %q, want the fi region's own token", got) |
| + } |
| + // The region is home's routing concern — the provisioner still receives only |
| + // the opaque requester, nothing else. |
| + var sent map[string]string |
| + json.Unmarshal(*fiBody, &sent) |
| + if _, leaked := sent["region"]; leaked || len(sent) != 1 { |
| + t.Errorf("provisioner saw %v, want just requester", sent) |
| + } |
| + |
| + // Auto-assign: no region → the default region (ie). |
| + if host := createdHost(t, postSpacesJSON(t, ts, token, `{}`)); host != "x.ie.example.com" { |
| + t.Errorf("no region landed on %q, want the default ie cloud", host) |
| + } |
| + // An unknown region quietly auto-assigns rather than failing. |
| + if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"zz"}`)); host != "x.ie.example.com" { |
| + t.Errorf("unknown region landed on %q, want the default ie cloud", host) |
| + } |
| +} |
| + |
| +func TestListSpacesFansOutAcrossRegions(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + _, token := seedUser(t, s) |
| + |
| + ieCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "spaces": []map[string]any{{"host": "a.ie.example.com"}}, |
| + }) |
| + fiCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "spaces": []map[string]any{{"host": "b.fi.example.com"}}, |
| + }) |
| + twoRegions(s, ieCloud.URL, fiCloud.URL) |
| + |
| + req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var m struct{ Spaces []struct{ Host string } } |
| + json.NewDecoder(resp.Body).Decode(&m) |
| + if len(m.Spaces) != 2 { |
| + t.Fatalf("merged spaces = %d, want 2 (one per region): %+v", len(m.Spaces), m.Spaces) |
| + } |
| + hosts := map[string]bool{m.Spaces[0].Host: true, m.Spaces[1].Host: true} |
| + if !hosts["a.ie.example.com"] || !hosts["b.fi.example.com"] { |
| + t.Errorf("merged hosts = %+v, want both regions", m.Spaces) |
| + } |
| +} |
| + |
| +func TestListSpacesSurvivesOneRegionDown(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + _, token := seedUser(t, s) |
| + |
| + up, _, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "spaces": []map[string]any{{"host": "a.ie.example.com"}}, |
| + }) |
| + down, _, _ := fakeCloud(t, http.StatusBadGateway, map[string]any{"error": "boom"}) |
| + twoRegions(s, up.URL, down.URL) // ie up, fi failing |
| + |
| + req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + // One region up → still 200 with that region's spaces (best-effort). |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("one region down: status = %d, want 200", resp.StatusCode) |
| + } |
| + var m struct{ Spaces []struct{ Host string } } |
| + json.NewDecoder(resp.Body).Decode(&m) |
| + if len(m.Spaces) != 1 || m.Spaces[0].Host != "a.ie.example.com" { |
| + t.Errorf("spaces = %+v, want just the healthy region's", m.Spaces) |
| + } |
| +} |
| + |
| +func TestManageRoutesByHostRegion(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + uid, _ := seedUser(t, s) |
| + |
| + ieCloud, ieCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true}) |
| + fiCloud, fiCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true}) |
| + twoRegions(s, ieCloud.URL, fiCloud.URL) |
| + |
| + del := func(host string) { |
| + req, _ := http.NewRequest("DELETE", ts.URL+"/api/spaces/"+host, nil) |
| + req.Header.Set("X-Ceremony-Proof", s.keys.mintCeremonyProof(uid)) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("delete %s = %d, want 200", host, resp.StatusCode) |
| + } |
| + } |
| + |
| + // A .fi host routes to the fi provisioner, and not the ie one. |
| + del("y.fi.example.com") |
| + if fiCap.Method != "DELETE" { |
| + t.Error("delete of a .fi host did not reach the fi provisioner") |
| + } |
| + if ieCap.Method != "" { |
| + t.Error("delete of a .fi host wrongly reached the ie provisioner") |
| + } |
| + // A .ie host routes to the ie provisioner. |
| + del("z.ie.example.com") |
| + if ieCap.Method != "DELETE" { |
| + t.Error("delete of a .ie host did not reach the ie provisioner") |
| + } |
| +} |
| + |
| +func TestFeaturesExposesRegions(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + |
| + // Single-region: no regions key at all. |
| + s.cloud = newCloudProvisioner("https://cloud.example.com", "tok") |
| + var f map[string]any |
| + getJSON(t, ts.URL+"/api/features", &f) |
| + if _, ok := f["regions"]; ok { |
| + t.Errorf("single-region features must not carry a regions key: %v", f) |
| + } |
| + |
| + // Multi-region: regions + default_region, code+name only — never url/token. |
| + twoRegions(s, "https://cloud.ie", "https://cloud.fi") |
| + var f2 map[string]any |
| + getJSON(t, ts.URL+"/api/features", &f2) |
| + if f2["default_region"] != "ie" { |
| + t.Errorf("default_region = %v, want ie", f2["default_region"]) |
| + } |
| + regs, ok := f2["regions"].([]any) |
| + if !ok || len(regs) != 2 { |
| + t.Fatalf("regions = %v, want 2", f2["regions"]) |
| + } |
| + first, _ := regs[0].(map[string]any) |
| + if first["code"] != "ie" || first["name"] != "Ireland" { |
| + t.Errorf("region[0] = %v", first) |
| + } |
| + if _, leaked := first["url"]; leaked { |
| + t.Errorf("features leaked a region url: %v", first) |
| + } |
| + if _, leaked := first["token"]; leaked { |
| + t.Errorf("features leaked a region token: %v", first) |
| + } |
| +} |
| + |
| +func getJSON(t *testing.T, url string, v any) { |
| + t.Helper() |
| + resp, err := http.Get(url) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + json.NewDecoder(resp.Body).Decode(v) |
| +} |