server: cloud mints space ids from the global ledger behind an opt-in (#1109)
Region-neutral minting (multi-edge Phase 1, docs/multi-edge.md): the cloud provisioner can draw a new space's HOSTNAME id from the shared, global, S3-backed id ledger (internal/idalloc) instead of its per-cloud.db PK. This is what lets two region clouds mint into ONE region-neutral namespace (<sqid>.<baseDomain>, no country label) without colliding, since the global counter is unique across the whole fleet. Activation is a DELIBERATE opt-in: LCHAT_CLOUD_ID_LEDGER (truthy) turns the ledger on; the counter's *location* then comes from the shared LCHAT_HIBERNATE_* bucket (the mesh/activator S3 substrate) so it lives in the one bucket every cloud + home reaches. The flag NEVER activates merely because the bucket is present — that separation is load-bearing: the counter starts low, so a silent activation would mint sqids colliding with existing spaces' hostnames. Flag set but no bucket => fail fast at startup (never silent-off). newCloudLedger is the one place that decides this. The fork itself is spaceHostID(): with s.ids set it Allocate()s the next global id; nil (the default) returns the space's own cloud.db PK, so the hostname is byte-for-byte what it is today. Both the regular (createSpaceHandler) and the demo (createDemoSpace) create paths route through it. The cloud.db PK stays the row's key for quota, entitlement, usage and billing, which never derive anything from the hostname's encoded id (host->space lookups key on the host column, Stripe metadata carries the PK). So decoupling the hostname id from the PK leaves all that accounting intact; only the address's id source moves. Back-compat is total: no opt-in => allocator nil => today's cloud.db-PK behaviour, so production's single provisioner, self-hosters, and demo all keep working exactly as now. Inert until a deployment sets LCHAT_CLOUD_ID_LEDGER. Operational note (NOT implemented here): before enabling the flag for a namespace that already has spaces, the operator must first seed the counter object above that namespace's max existing id, or ids 1,2,3... re-mint live hostnames. Tests (cloud_ids_test.go, over the real newCloudLedger gate + an httptest S3 CAS fake): with the ledger enabled a free create + a demo create take the hostname id from the global counter (distinct, increasing, decoupled from the local PK); the free quota + demo entitlement bookkeeping still holds under the ledger path; the bucket present WITHOUT the flag does not activate the ledger (hostnames stay local-PK); and the flag without a bucket errors.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
3 files changed,
+402
−5
cloud.go+92 −3cloud_ids_test.go+296 −0demo.go+14 −2
diff --git a/cloud.go b/cloud.go| index 2577369..2787224 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -25,6 +25,8 @@ import ( |
| "time" |
| sqids "github.com/sqids/sqids-go" |
| + |
| + "lchat/internal/idalloc" |
| ) |
| // spaceUnavailableMsg is the member-facing "we're full" message, relayed |
| @@ -83,7 +85,18 @@ type cloudServer struct { |
| // 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 |
| + region string |
| + // ids is the shared, global, S3-backed id allocator (internal/idalloc) |
| + // behind region-neutral space addressing (multi-edge Phase 1). Set only when |
| + // the deliberate opt-in LCHAT_CLOUD_ID_LEDGER is on (its counter then lives in |
| + // the shared LCHAT_HIBERNATE_* bucket, the mesh's S3 substrate). When set, a |
| + // new space's HOSTNAME id is drawn from the fleet-wide counter instead of this |
| + // cloud.db's local PK, so two region clouds minting into ONE region-neutral |
| + // namespace (<sqid>.<baseDomain>, no country label) can never collide. Nil — |
| + // production's single provisioner, every self-hoster, any box without the flag |
| + // — keeps today's cloud.db-PK hostname, byte for byte. Only the address's id |
| + // source moves; the PK stays the ledger key. |
| + ids *idalloc.Allocator |
| 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 |
| @@ -167,6 +180,28 @@ func (s *cloudServer) admin(next http.HandlerFunc) http.HandlerFunc { |
| } |
| } |
| +// spaceHostID returns the id that feeds a new space's sqid hostname — the whole |
| +// of this slice's fork. With the shared global ledger enabled (s.ids != nil, via |
| +// the deliberate LCHAT_CLOUD_ID_LEDGER opt-in) it draws the next fleet-unique id |
| +// from idalloc, so two region clouds minting into ONE region-neutral namespace |
| +// never collide. Without it — the default everywhere the flag isn't set: |
| +// production's single provisioner, self-hosters, any un-flagged box — it returns |
| +// the space's own cloud.db PK, so the hostname is byte-for-byte what it is today. |
| +// The PK stays the ledger key either way (quota, entitlement, usage, billing all |
| +// key on it and never on this id), so only the address's id source moves. |
| +func (s *cloudServer) spaceHostID(ctx context.Context, pk int64) (int64, error) { |
| + if s.ids == nil { |
| + return pk, nil |
| + } |
| + gid, err := s.ids.Allocate(ctx) |
| + if err != nil { |
| + return 0, err |
| + } |
| + // idalloc hands out uint64; the sqid/PK id space is int64. The counter is |
| + // 1-based and gaps are allowed, so int64 has decades of headroom. |
| + return int64(gid), nil |
| +} |
| + |
| // createSpaceHandler provisions one instance for a requester: reserve the next |
| // id (quota-checked), turn it into a sqid hostname, ask the control daemon to |
| // create it, then finalize the ledger. No slug re-roll loop — the id is unique |
| @@ -205,7 +240,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| // Demos branch off past the shared breaker: their own cap + TTL bound them |
| // instead of the free-space quota (demo.go). |
| if req.Kind == entDemo { |
| - s.createDemoSpace(w, callerID, req.Requester) |
| + s.createDemoSpace(r.Context(), w, callerID, req.Requester) |
| return |
| } |
| @@ -250,7 +285,17 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| - slug, err := spaceSlug(id) |
| + // The hostname's id: the shared global ledger when configured, else this |
| + // space's cloud.db PK (id) — today's behaviour unchanged. On failure the |
| + // pending row is rolled back like any other, so no quota slot leaks. |
| + hostID, err := s.spaceHostID(r.Context(), id) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + log.Printf("cloud: id ledger allocate for id %d: %v", id, err) |
| + writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable") |
| + return |
| + } |
| + slug, err := spaceSlug(hostID) |
| if err != nil { |
| s.db.deleteSpaceByID(id) |
| writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") |
| @@ -479,6 +524,40 @@ func readControlToken() (string, error) { |
| return "", fmt.Errorf("no control token: need systemd LoadCredential=control-token:… or LCHAT_CONTROL_TOKEN_FILE") |
| } |
| +// newCloudLedger builds the global id allocator (internal/idalloc) behind |
| +// region-neutral minting — but ONLY on the deliberate LCHAT_CLOUD_ID_LEDGER |
| +// opt-in (truthy 1/true). It must never activate merely because |
| +// LCHAT_HIBERNATE_BUCKET is present: that var exists for a different reason (the |
| +// mesh/activator S3 substrate), and the counter starts low, so a silent |
| +// activation would mint sqids colliding with existing spaces' hostnames. The |
| +// flag is the switch; the *location* still comes from the shared LCHAT_HIBERNATE_* |
| +// bucket, because the counter MUST live in the one bucket every cloud + home |
| +// reaches or it isn't a global counter. |
| +// |
| +// Off (the default: production's single provisioner, self-hosters, any un-flagged |
| +// box) → (nil, nil): cloud.db-PK hostnames, byte-for-byte today's behaviour. On |
| +// but no bucket → a hard error (misconfig, fail fast — never silent-off). |
| +// |
| +// ⚠️ Before flipping this on for a namespace that ALREADY has spaces, the |
| +// operator MUST first seed the counter object above that namespace's max existing |
| +// id, or ids 1,2,3… re-mint live hostnames. Enabling the flag and seeding the |
| +// counter are one action, not two. |
| +func newCloudLedger(ctx context.Context) (*idalloc.Allocator, error) { |
| + if v := strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_ID_LEDGER"))); v != "1" && v != "true" { |
| + return nil, nil |
| + } |
| + bucket := strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_BUCKET")) |
| + if bucket == "" { |
| + return nil, fmt.Errorf("LCHAT_CLOUD_ID_LEDGER is set but LCHAT_HIBERNATE_BUCKET is empty — the global id counter needs the shared bucket to live in") |
| + } |
| + return idalloc.New(ctx, idalloc.Config{ |
| + Bucket: bucket, |
| + Prefix: strings.Trim(strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_PREFIX")), "/"), |
| + Region: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_REGION")), |
| + Endpoint: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_ENDPOINT")), |
| + }) |
| +} |
| + |
| // runCloud: `lchat cloud` — serve the public provisioning API (fronted by the |
| // router as a 'service' route, e.g. at cloud.elevenmessenger.com). |
| func runCloud(args []string) { |
| @@ -520,6 +599,12 @@ func runCloud(args []string) { |
| if err != nil { |
| die("cloud: open db: %v", err) |
| } |
| + // The shared global id ledger (multi-edge Phase 1), built only on the |
| + // deliberate LCHAT_CLOUD_ID_LEDGER opt-in — see newCloudLedger. |
| + ids, err := newCloudLedger(context.Background()) |
| + if err != nil { |
| + die("cloud: id ledger: %v", err) |
| + } |
| storageCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS")) |
| if err != nil { |
| die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err) |
| @@ -562,6 +647,7 @@ func runCloud(args []string) { |
| control: controlClient(*controlSocket), |
| baseDomain: *baseDomain, |
| region: strings.TrimSpace(*region), |
| + ids: ids, |
| quota: *quota, |
| reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour, |
| reapDryRun: *reapDryRun, |
| @@ -588,6 +674,9 @@ func runCloud(args []string) { |
| if s.region != "" { |
| log.Printf("cloud: region %q — new spaces are stamped home_region=%q", s.region, s.region) |
| } |
| + if s.ids != nil { |
| + log.Printf("cloud: global id ledger on — space hostnames draw from the shared counter (region-neutral namespace)") |
| + } |
| aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES"))) |
| if err != nil { |
| die("cloud: %v", err) |
diff --git a/cloud_ids_test.go b/cloud_ids_test.go| new file mode 100644 |
| index 0000000..40fb741 |
| --- /dev/null |
| +++ b/cloud_ids_test.go |
| @@ -0,0 +1,296 @@ |
| +package main |
| + |
| +// Region-neutral minting (multi-edge Phase 1, docs/multi-edge.md): the cloud |
| +// draws a new space's HOSTNAME id from the shared global ledger (internal/idalloc) |
| +// when configured, else from its own cloud.db PK — today's behaviour, byte for |
| +// byte, on any box without the shared bucket. These tests pin both sides of that |
| +// fork, for the regular AND the demo create path, and prove the cloud.db quota / |
| +// entitlement bookkeeping is untouched when the hostname id comes from the ledger. |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/idalloc" |
| +) |
| + |
| +// fakeLedgerS3 is a minimal, correct compare-and-swap S3 over one object — just |
| +// enough for idalloc's counter: path-style GET (404 when absent) and conditional |
| +// PUT honouring If-None-Match / If-Match with a real 412. Modelled on idalloc's |
| +// own test fake, kept local since that one isn't exported. |
| +type fakeLedgerS3 struct { |
| + body []byte |
| + etag string |
| + present bool |
| + seq int |
| +} |
| + |
| +func (f *fakeLedgerS3) handler(w http.ResponseWriter, r *http.Request) { |
| + switch r.Method { |
| + case http.MethodGet: |
| + if !f.present { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusNotFound) |
| + io.WriteString(w, `<Error><Code>NoSuchKey</Code><Message>no such key</Message></Error>`) |
| + return |
| + } |
| + w.Header().Set("ETag", f.etag) |
| + w.WriteHeader(http.StatusOK) |
| + w.Write(f.body) |
| + case http.MethodPut: |
| + if r.Header.Get("If-None-Match") == "*" && f.present { |
| + f.precondition412(w) |
| + return |
| + } |
| + if m := r.Header.Get("If-Match"); m != "" && (!f.present || m != f.etag) { |
| + f.precondition412(w) |
| + return |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + f.seq++ |
| + f.body, f.etag, f.present = body, fmt.Sprintf("\"e%d\"", f.seq), true |
| + w.Header().Set("ETag", f.etag) |
| + w.WriteHeader(http.StatusOK) |
| + default: |
| + w.WriteHeader(http.StatusNotImplemented) |
| + } |
| +} |
| + |
| +func (f *fakeLedgerS3) precondition412(w http.ResponseWriter) { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusPreconditionFailed) |
| + io.WriteString(w, `<Error><Code>PreconditionFailed</Code><Message>precondition failed</Message></Error>`) |
| +} |
| + |
| +// ledgerEnv points the deliberate LCHAT_CLOUD_ID_LEDGER opt-in + the shared |
| +// LCHAT_HIBERNATE_* bucket at an httptest S3 fake, and returns its URL — so tests |
| +// enable the ledger through the SAME gate + env runCloud uses, never by reaching |
| +// past it. Static AWS creds satisfy the SDK signer offline (as in idalloc_test.go). |
| +func ledgerEnv(t *testing.T) string { |
| + t.Helper() |
| + fake := &fakeLedgerS3{} |
| + srv := httptest.NewServer(http.HandlerFunc(fake.handler)) |
| + t.Cleanup(srv.Close) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") |
| + t.Setenv("AWS_REGION", "auto") |
| + t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") |
| + t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") |
| + t.Setenv("LCHAT_HIBERNATE_PREFIX", "pfx") |
| + t.Setenv("LCHAT_HIBERNATE_ENDPOINT", srv.URL) |
| + return srv.URL |
| +} |
| + |
| +// newFakeLedger builds the allocator through newCloudLedger — the real gate — so |
| +// the cloud sees exactly the allocator a flagged, bucketed box would construct. |
| +func newFakeLedger(t *testing.T) *idalloc.Allocator { |
| + t.Helper() |
| + ledgerEnv(t) |
| + a, err := newCloudLedger(context.Background()) |
| + if err != nil { |
| + t.Fatalf("newCloudLedger: %v", err) |
| + } |
| + if a == nil { |
| + t.Fatal("newCloudLedger returned nil with the flag + bucket set") |
| + } |
| + return a |
| +} |
| + |
| +// hostLabelID cuts the sqid label off a host and decodes it to its id. |
| +func hostLabelID(t *testing.T, host string) int64 { |
| + t.Helper() |
| + label, _, ok := strings.Cut(host, ".") |
| + if !ok { |
| + t.Fatalf("host %q has no label", host) |
| + } |
| + id, ok := spaceID(label) |
| + if !ok { |
| + t.Fatalf("label %q of host %q is not a canonical sqid", label, host) |
| + } |
| + return id |
| +} |
| + |
| +// With the ledger configured, a regular AND a demo create take the hostname id |
| +// from the global allocator, not the cloud.db PK — and two creates get distinct, |
| +// increasing global ids. The counter is pre-advanced so ledger ids diverge from |
| +// the local PK sequence (both start at 1), making the source unambiguous. |
| +func TestCloudLedgerHostnamesUseGlobalIds(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + alloc := newFakeLedger(t) |
| + ctx := context.Background() |
| + for i := 0; i < 5; i++ { |
| + if _, err := alloc.Allocate(ctx); err != nil { |
| + t.Fatalf("pre-advance %d: %v", i, err) |
| + } |
| + } |
| + // The counter's high-water is now 5, so the server's next draws are 6, 7, 8. |
| + s.ids = alloc |
| + tok := mintCaller(t, srv, "home") |
| + |
| + // Two free creates: hostnames decode to the global ids 6 then 7. |
| + var hosts []string |
| + for i := 0; i < 2; i++ { |
| + resp := createSpace(t, srv, tok, "u") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) |
| + } |
| + hosts = append(hosts, decodeBody(t, resp)["host"].(string)) |
| + } |
| + if got := hostLabelID(t, hosts[0]); got != 6 { |
| + t.Errorf("first ledger hostname id = %d, want 6 (global counter, not PK 1)", got) |
| + } |
| + if got := hostLabelID(t, hosts[1]); got != 7 { |
| + t.Errorf("second ledger hostname id = %d, want 7 (strictly increasing)", got) |
| + } |
| + // The cloud.db PK is still the LOCAL sequence and decoupled from the address: |
| + // the first space's row is PK 1 even though its hostname encodes 6. |
| + if pk, _, _, ok, err := s.db.spaceByHost(hosts[0]); err != nil || !ok || pk != 1 { |
| + t.Errorf("first space PK = %d (ok=%v err=%v), want local PK 1 — hostname id must not be the PK", pk, ok, err) |
| + } |
| + |
| + // A demo create draws from the same global counter (next is 8). |
| + resp := createKind(t, srv, tok, "d", "demo") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo create: HTTP %d", resp.StatusCode) |
| + } |
| + demoHost := decodeBody(t, resp)["host"].(string) |
| + if got := hostLabelID(t, demoHost); got != 8 { |
| + t.Errorf("demo ledger hostname id = %d, want 8 (shared global counter)", got) |
| + } |
| +} |
| + |
| +// With NO allocator (the default: production, self-hosters, any un-bucketed box), |
| +// both the regular and demo paths build the hostname from the cloud.db PK exactly |
| +// as today — the hostname id equals the row's PK. |
| +func TestCloudNoLedgerUsesLocalPK(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) // s.ids stays nil |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + tok := mintCaller(t, srv, "home") |
| + |
| + // Fresh DB → PKs 1, 2, …; a free then a demo create both encode their own PK. |
| + free := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) |
| + demo := decodeBody(t, mustCreate(t, srv, tok, "u", "demo"))["host"].(string) |
| + for _, tc := range []struct { |
| + name, host string |
| + }{{"free", free}, {"demo", demo}} { |
| + pk, _, _, ok, err := s.db.spaceByHost(tc.host) |
| + if err != nil || !ok { |
| + t.Fatalf("%s spaceByHost(%q): ok=%v err=%v", tc.name, tc.host, ok, err) |
| + } |
| + if got := hostLabelID(t, tc.host); got != pk { |
| + t.Errorf("%s hostname id = %d, want cloud.db PK %d (no-ledger path unchanged)", tc.name, got, pk) |
| + } |
| + } |
| +} |
| + |
| +// Quota + entitlement bookkeeping is untouched when the hostname id comes from |
| +// the ledger: the free quota still counts cloud.db rows (not global ids), the |
| +// (quota+1)th free create is refused, and a demo still slips past with a demo |
| +// entitlement recorded in the ledger. |
| +func TestCloudLedgerPreservesQuotaAndEntitlement(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) // harness quota = 3 |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + s.ids = newFakeLedger(t) |
| + tok := mintCaller(t, srv, "home") |
| + |
| + for i := 0; i < s.quota; i++ { |
| + if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) |
| + } |
| + } |
| + if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("over-quota free create: HTTP %d, want 409 (quota counts cloud.db rows, not ledger ids)", resp.StatusCode) |
| + } |
| + // A demo bypasses the full free quota, and its ledger row is a demo. |
| + resp := createKind(t, srv, tok, "bob", "demo") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode) |
| + } |
| + demoHost := decodeBody(t, resp)["host"].(string) |
| + var ent string |
| + if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, demoHost).Scan(&ent); err != nil || ent != entDemo { |
| + t.Fatalf("ledger demo entitlement = %q err=%v, want %q", ent, err, entDemo) |
| + } |
| + // The demo didn't consume a free slot: a following free create is still |
| + // refused at quota, proving the quota ledger is untouched by the ledger path. |
| + if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode) |
| + } |
| +} |
| + |
| +// The gate is deliberate: LCHAT_HIBERNATE_BUCKET alone must NOT turn the ledger |
| +// on — only the explicit LCHAT_CLOUD_ID_LEDGER opt-in does, and it fails fast if |
| +// the shared bucket is missing. This guard is the whole point of the flag. |
| +func TestCloudLedgerGate(t *testing.T) { |
| + ledgerEnv(t) // sets AWS creds + flag + bucket + endpoint |
| + ctx := context.Background() |
| + |
| + // Bucket present, flag OFF → no ledger (the load-bearing guard). |
| + t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") |
| + if a, err := newCloudLedger(ctx); err != nil || a != nil { |
| + t.Fatalf("bucket set but flag off: got (%v, %v), want (nil, nil) — bucket alone must NOT activate the ledger", a, err) |
| + } |
| + |
| + // Flag ON, bucket MISSING → fail fast, never silent-off. |
| + t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") |
| + t.Setenv("LCHAT_HIBERNATE_BUCKET", "") |
| + if a, err := newCloudLedger(ctx); err == nil || a != nil { |
| + t.Fatalf("flag on, bucket empty: got (%v, %v), want a hard error (fail fast)", a, err) |
| + } |
| + |
| + // Flag ON + bucket → an allocator. |
| + t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") |
| + if a, err := newCloudLedger(ctx); err != nil || a == nil { |
| + t.Fatalf("flag on + bucket: got (%v, %v), want a non-nil allocator", a, err) |
| + } |
| +} |
| + |
| +// Behaviour-level guard: with the bucket present but the flag OFF, the cloud |
| +// still mints local-PK hostnames — newCloudLedger returns nil, so s.ids stays |
| +// nil and nothing draws from the global counter. |
| +func TestCloudBucketWithoutFlagUsesLocalPK(t *testing.T) { |
| + ledgerEnv(t) |
| + t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") // bucket stays set; flag off |
| + ids, err := newCloudLedger(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ids != nil { |
| + t.Fatal("bucket alone built an allocator — the flag guard failed") |
| + } |
| + |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.ids = ids // nil |
| + tok := mintCaller(t, srv, "home") |
| + host := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) |
| + pk, _, _, ok, err := s.db.spaceByHost(host) |
| + if err != nil || !ok { |
| + t.Fatalf("spaceByHost(%q): ok=%v err=%v", host, ok, err) |
| + } |
| + if got := hostLabelID(t, host); got != pk { |
| + t.Errorf("bucket-without-flag hostname id = %d, want cloud.db PK %d (hostnames stay local)", got, pk) |
| + } |
| +} |
| + |
| +// mustCreate posts a create (kind "" for free, "demo" for a demo) and fails on |
| +// any non-201. |
| +func mustCreate(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { |
| + t.Helper() |
| + var resp *http.Response |
| + if kind == "" { |
| + resp = createSpace(t, srv, token, requester) |
| + } else { |
| + resp = createKind(t, srv, token, requester, kind) |
| + } |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create (kind %q): HTTP %d", kind, resp.StatusCode) |
| + } |
| + return resp |
| +} |
diff --git a/demo.go b/demo.go| index 81da983..897f5b2 100644 |
| --- a/demo.go |
| +++ b/demo.go |
| @@ -23,6 +23,7 @@ package main |
| // from the first second. |
| import ( |
| + "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| @@ -34,7 +35,7 @@ import ( |
| // createDemoSpace is createSpaceHandler's kind=demo branch: no quota, no |
| // self-reclaim — a demo's bounds are its own cap and its TTL. |
| -func (s *cloudServer) createDemoSpace(w http.ResponseWriter, callerID, requester string) { |
| +func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter, callerID, requester string) { |
| if s.demoTTL <= 0 { |
| writeControlErr(w, http.StatusNotImplemented, "demo_unavailable", "demo spaces aren't offered here") |
| return |
| @@ -55,7 +56,18 @@ func (s *cloudServer) createDemoSpace(w http.ResponseWriter, callerID, requester |
| writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| return |
| } |
| - slug, err := spaceSlug(id) |
| + // Same fork as the regular create path (cloud.go spaceHostID): the shared |
| + // global ledger when configured, else this demo row's cloud.db PK — today's |
| + // behaviour unchanged on any box without the shared bucket. The PK still |
| + // keys the row for the reaper/claim/finalize; only the address's id moves. |
| + hostID, err := s.spaceHostID(ctx, id) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + log.Printf("cloud: id ledger allocate for demo id %d: %v", id, err) |
| + writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable") |
| + return |
| + } |
| + slug, err := spaceSlug(hostID) |
| if err != nil { |
| s.db.deleteSpaceByID(id) |
| writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") |