server: demo spaces slice 1 — ledger tier, unconditional TTL reap, no backups by construction (#874)
docs/demo-spaces.md slice 1 (design PR #854). kind=demo creates make an entitlement='demo' ledger row: no free-quota interaction, bounded by LCHAT_DEMO_MAX_LIVE + the shared creates budget, response carries expires_at. An hourly sweep discards demos past LCHAT_DEMO_TTL_HOURS unconditionally — no stats, no guards, no trash archive (controld DELETE ?discard=1 removes files outright, blobs included). controld forces Hibernate=false for demo creates even on hibernate-by-default boxes, so a demo never touches S3. Ships dark: demos off unless LCHAT_DEMO_TTL_HOURS is set, and nothing sends kind=demo yet — the per-IP gates land before any surface does.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+662
−32
cloud.go+43 −14cloud_db.go+60 −0cloudreplication.go+3 −0control.go+24 −12controld.go+19 −4demo.go+156 −0demo_test.go+320 −0docs/cloud-provisioner.md+28 −0meter.go+7 −0reaper.go+2 −2
diff --git a/cloud.go b/cloud.go| index 1c5bb0e..5726718 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -86,7 +86,9 @@ type cloudServer struct { |
| // replicationClasses maps tier keys (+ "free") to durability-ladder classes |
| // (cloudreplication.go); empty = classes never pushed, every space streams. |
| replicationClasses map[string]string |
| - createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped) |
| + demoTTL time.Duration // demo lifetime (demo.go); 0 = demos not offered |
| + demoMaxLive int // live-demo cap, pending rows included |
| + createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped) |
| // usageURL is this service's own public /api/usage URL, pushed into each |
| // instance's env (LCHAT_METER_URL) alongside a minted meter token so |
| // sidecar usage reports can flow (#857). Empty = the usage pipe is off. |
| @@ -152,6 +154,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| callerID := r.Context().Value(callerKey{}).(string) |
| var req struct { |
| Requester string `json:"requester"` |
| + Kind string `json:"kind"` // "" = a free space (the default); "demo" = a 24h demo (demo.go) |
| } |
| if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { |
| writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| @@ -162,6 +165,10 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)") |
| return |
| } |
| + if req.Kind != "" && req.Kind != entDemo { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown kind (want empty or \"demo\")") |
| + return |
| + } |
| // The fleet-wide circuit breaker: a trailing-24h creation budget, counted |
| // against an append-only log so create-and-delete can't reset it. Insurance |
| @@ -174,6 +181,13 @@ 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) |
| + return |
| + } |
| + |
| // Reserve the id (fails closed if over quota). The row is pending (NULL host) |
| // until the instance is up; on any failure below we delete it so it doesn't |
| // hold a quota slot. |
| @@ -214,7 +228,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| } |
| host := slug + "." + s.baseDomain |
| - status, body, err := s.controlCreate(host) |
| + status, body, err := s.controlCreate(host, entFree) |
| if err != nil { |
| s.db.deleteSpaceByID(id) |
| log.Printf("cloud: control call for %s: %v", host, err) |
| @@ -247,15 +261,16 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| -// controlCreate posts one creation request to the control daemon. Every |
| -// provisioned space is born free — the entitlement lands in the instance env |
| -// so its UI can offer Upgrade from day one; billing flips re-push via the |
| -// quota path. The replication class rides along so the space is born on the |
| -// free rung of the durability ladder (billing flips re-push via |
| -// pushReplicationByID). |
| -func (s *cloudServer) controlCreate(host string) (int, map[string]any, error) { |
| - payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entFree, |
| - Replication: s.replicationFor(entFree, "")}) |
| +// controlCreate posts one creation request to the control daemon. The |
| +// entitlement ("free", or "demo" for the 24h tier) lands in the instance env |
| +// so its UI can gate affordances; billing flips re-push via the quota path. |
| +// The replication class rides along so the space is born on its plan's rung |
| +// of the durability ladder (billing flips re-push via pushReplicationByID); |
| +// a demo's class is always empty — controld keeps demos off the activator |
| +// 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, "")}) |
| req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload)) |
| if err != nil { |
| return 0, nil, err |
| @@ -297,7 +312,7 @@ func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) |
| writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| return |
| } |
| - status, body, err := s.controlDelete(host) |
| + status, body, err := s.controlDelete(host, false) |
| if err != nil { |
| log.Printf("cloud: control delete for %s: %v", host, err) |
| writeControlErr(w, http.StatusBadGateway, "delete_failed", "the box's control daemon is unreachable") |
| @@ -319,8 +334,14 @@ func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) |
| } |
| // controlDelete sends one deletion to the control daemon. |
| -func (s *cloudServer) controlDelete(host string) (int, map[string]any, error) { |
| - req, err := http.NewRequest("DELETE", "http://control/instances/"+host, nil) |
| +// controlDelete deprovisions one instance. discard=true skips the trash |
| +// archive — demo-only (docs/demo-spaces.md), never for a real space. |
| +func (s *cloudServer) controlDelete(host string, discard bool) (int, map[string]any, error) { |
| + url := "http://control/instances/" + host |
| + if discard { |
| + url += "?discard=1" |
| + } |
| + req, err := http.NewRequest("DELETE", url, nil) |
| if err != nil { |
| return 0, nil, err |
| } |
| @@ -433,6 +454,8 @@ func runCloud(args []string) { |
| reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)") |
| meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)") |
| createBudget := fs.Int("max-creates-per-day", envInt("LCHAT_CLOUD_MAX_CREATES_PER_DAY", 0), "fleet-wide creation circuit breaker: max spaces per trailing 24h (0 = uncapped)") |
| + demoTTLHours := fs.Int("demo-ttl-hours", envInt("LCHAT_DEMO_TTL_HOURS", 0), "demo-space lifetime; 0 = demos not offered") |
| + demoMaxLive := fs.Int("demo-max-live", envInt("LCHAT_DEMO_MAX_LIVE", 10), "max live demo spaces at once") |
| poolMaxShare := fs.Int("pool-max-share", envInt("LCHAT_POOL_MAX_SHARE", 0), "free-pool ceiling as % of total cost (0 = pool gauge off)") |
| poolRevenueShare := fs.Int("pool-revenue-share", envInt("LCHAT_POOL_REVENUE_SHARE", 30), "% of net revenue that funds the free pool") |
| poolBaseEURDay := fs.Float64("pool-base-eur-day", envFloat("LCHAT_POOL_BASE_EUR_DAY", 0.20), "operator floor: pool budget in EUR/day at zero revenue") |
| @@ -493,6 +516,8 @@ func runCloud(args []string) { |
| storageCaps: storageCaps, |
| replicationClasses: replicationClasses, |
| createBudget: *createBudget, |
| + demoTTL: time.Duration(*demoTTLHours) * time.Hour, |
| + demoMaxLive: *demoMaxLive, |
| usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")), |
| pool: poolConfig{ |
| maxShare: *poolMaxShare, |
| @@ -508,6 +533,9 @@ func runCloud(args []string) { |
| if *createBudget > 0 { |
| log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget) |
| } |
| + if s.demoTTL > 0 { |
| + log.Printf("cloud: demo spaces on (TTL %s, max %d live, discarded on expiry)", s.demoTTL, s.demoMaxLive) |
| + } |
| if s.pool.enabled() { |
| log.Printf("cloud: free-pool gauge on (ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)", |
| s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare) |
| @@ -529,6 +557,7 @@ func runCloud(args []string) { |
| log.Printf("cloud: billing on (%d tiers)", len(billing.tiers)) |
| } |
| go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour) |
| + go s.demoReapLoop() |
| go s.meterLoop(time.Duration(*meterIntervalHours) * time.Hour) |
| ln, where, err := listen(*addr, *socketPath) |
| if err != nil { |
diff --git a/cloud_db.go b/cloud_db.go| index f668d70..1f93c36 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -438,6 +438,65 @@ func (db *cloudDB) allocateSpace(callerID, requester string, quota int) (id int6 |
| return id, false, tx.Commit() |
| } |
| +// allocateDemoSpace reserves a demo row (docs/demo-spaces.md). Demos never |
| +// count against — and are never counted by — the free-space quota; their own |
| +// bounds are the live-demo cap (counted in cloud.go before this runs) and the |
| +// shared creates_log budget, which they do feed: a demo create is still a |
| +// create. |
| +func (db *cloudDB) allocateDemoSpace(callerID, requester string) (int64, error) { |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + now := time.Now().Unix() |
| + res, err := tx.Exec( |
| + `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement) VALUES(NULL, ?, ?, ?, ?)`, |
| + callerID, requester, now, entDemo, |
| + ) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if _, err := tx.Exec(`INSERT INTO creates_log(created_at) VALUES(?)`, now); err != nil { |
| + return 0, err |
| + } |
| + id, err := res.LastInsertId() |
| + if err != nil { |
| + return 0, err |
| + } |
| + return id, tx.Commit() |
| +} |
| + |
| +// countDemos counts every live demo row, pending creates included — the |
| +// live-demo cap must see a create the moment it's reserved, not once it's up. |
| +func (db *cloudDB) countDemos() (int, error) { |
| + var n int |
| + err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces WHERE entitlement = ?`, entDemo).Scan(&n) |
| + return n, err |
| +} |
| + |
| +// demosPastTTL lists every demo past its lifetime — finalized or pending (a |
| +// pending row is an allocation whose create died mid-flight; it holds a demo |
| +// slot and gets cleaned ledger-only). |
| +func (db *cloudDB) demosPastTTL(cutoff int64) ([]reapCandidate, error) { |
| + rows, err := db.sql.Query( |
| + `SELECT id, COALESCE(host, ''), created_at FROM spaces |
| + WHERE entitlement = ? AND created_at <= ? ORDER BY created_at`, entDemo, cutoff) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []reapCandidate |
| + for rows.Next() { |
| + var c reapCandidate |
| + if err := rows.Scan(&c.ID, &c.Host, &c.CreatedAt); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, c) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // createsSince counts allocations at/after cutoff — the global budget's meter. |
| func (db *cloudDB) createsSince(cutoff int64) (int, error) { |
| var n int |
| @@ -587,6 +646,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| const ( |
| entFree = "free" |
| entPaid = "paid" |
| + entDemo = "demo" // 24h try-it spaces (docs/demo-spaces.md, demo.go) |
| ) |
| // spaceEntitlement reads one space's entitlement flag. |
diff --git a/cloudreplication.go b/cloudreplication.go| index 43a6a7c..82e4632 100644 |
| --- a/cloudreplication.go |
| +++ b/cloudreplication.go |
| @@ -53,6 +53,9 @@ func (s *cloudServer) replicationFor(entitlement, tier string) string { |
| if len(s.replicationClasses) == 0 { |
| return "" |
| } |
| + if entitlement == entDemo { |
| + return "" // demos never replicate — they're never activator-managed (demo.go) |
| + } |
| if entitlement == entPaid { |
| if class, ok := s.replicationClasses[tier]; ok { |
| return class |
diff --git a/control.go b/control.go| index 63f16e3..fe8c353 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -123,7 +123,7 @@ type addInstanceOpts struct { |
| // no billing surface and show nothing. |
| Entitlement string |
| HomeRegion string // geo seam (#550 Part 7): "" = the single implicit region |
| - Provider string // co-op seam (#550 Part 10): "" = the single implicit provider |
| + Provider string // co-op seam (#550 Part 10): "" = the single implicit provider |
| // Hibernate registers the instance as activator-managed (#550 Part 4 v2): |
| // no systemd unit is created — the router wakes it on first request. Needs |
| // the router running with LCHAT_HIBERNATE_BUCKET, or the host never answers. |
| @@ -459,7 +459,7 @@ var errUnknownHost = fmt.Errorf("unknown host") |
| // noncurrent-version expiry, so an orphaned prefix ages rather than grows. |
| // Reclaiming orphaned prefixes is deferred to the S3 lifecycle work — an |
| // offline sweep comparing bucket prefixes against the registry. |
| -func deleteInstance(reg *Registry, host string) error { |
| +func deleteInstance(reg *Registry, host string, discard bool) error { |
| host = strings.ToLower(strings.TrimSpace(host)) |
| if !validHost(host) { |
| return fmt.Errorf("%w: %q", errInvalidHost, host) |
| @@ -500,19 +500,31 @@ func deleteInstance(reg *Registry, host string) error { |
| // straggling checkpoint lands harmlessly inside the archive. |
| _ = waitSocketQuiet(rt.Socket, deleteReleaseTimeout) |
| } |
| - // Archive the data (never destroy it here — the trash dir is the safety net). |
| + // Archive the data (never destroy it here — the trash dir is the safety |
| + // net) — except under discard, the demo tier's no-backups contract |
| + // (docs/demo-spaces.md): a demo's files are removed outright, blob dir |
| + // included, so nothing of a disclosed-24h space outlives it. |
| // The litestream meta dir rides along: it describes the archived DB, and a |
| // leftover would only confuse a future re-create of the same host (the wake |
| // path clears a stale one, but don't rely on it). |
| - trash := filepath.Join(instanceDataDir, "trash", fmt.Sprintf("%s-%d", host, time.Now().Unix())) |
| - if err := os.MkdirAll(trash, 0o700); err != nil { |
| - return fmt.Errorf("trash dir: %v", err) |
| - } |
| - for _, name := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".db" + litestream.MetaDirSuffix, host + ".env", host + ".vapid"} { |
| - src := filepath.Join(instanceDataDir, name) |
| - if _, err := os.Stat(src); err == nil { |
| - if err := os.Rename(src, filepath.Join(trash, name)); err != nil { |
| - return fmt.Errorf("archive %s: %v", name, err) |
| + names := []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".db" + litestream.MetaDirSuffix, host + ".env", host + ".vapid"} |
| + if discard { |
| + for _, name := range append(names, host+"-blobs") { |
| + if err := os.RemoveAll(filepath.Join(instanceDataDir, name)); err != nil { |
| + return fmt.Errorf("discard %s: %v", name, err) |
| + } |
| + } |
| + } else { |
| + trash := filepath.Join(instanceDataDir, "trash", fmt.Sprintf("%s-%d", host, time.Now().Unix())) |
| + if err := os.MkdirAll(trash, 0o700); err != nil { |
| + return fmt.Errorf("trash dir: %v", err) |
| + } |
| + for _, name := range names { |
| + src := filepath.Join(instanceDataDir, name) |
| + if _, err := os.Stat(src); err == nil { |
| + if err := os.Rename(src, filepath.Join(trash, name)); err != nil { |
| + return fmt.Errorf("archive %s: %v", name, err) |
| + } |
| } |
| } |
| } |
diff --git a/controld.go b/controld.go| index d5c0f67..99630e9 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -370,12 +370,15 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| // deleteHandler deprovisions one instance (self-serve space deletion, reached |
| // via the cloud provisioner which enforces ownership). The instance's data is |
| -// archived to /data/trash, not destroyed — see deleteInstance. |
| +// archived to /data/trash, not destroyed — see deleteInstance — unless the |
| +// caller passes ?discard=1, the demo tier's no-backups contract |
| +// (docs/demo-spaces.md): a demo's data dies with it, by disclosure. |
| func (c *controld) deleteHandler(w http.ResponseWriter, r *http.Request) { |
| host := r.PathValue("host") |
| + discard := r.URL.Query().Get("discard") == "1" |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| - err := deleteInstance(c.reg, host) |
| + err := deleteInstance(c.reg, host, discard) |
| switch { |
| case errors.Is(err, errInvalidHost): |
| writeControlErr(w, http.StatusBadRequest, "invalid_host", err.Error()) |
| @@ -388,7 +391,11 @@ func (c *controld) deleteHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusInternalServerError, "delete_failed", err.Error()) |
| return |
| } |
| - log.Printf("controld: deleted %s (data archived to trash)", host) |
| + if discard { |
| + log.Printf("controld: deleted %s (data DISCARDED — demo contract)", host) |
| + } else { |
| + log.Printf("controld: deleted %s (data archived to trash)", host) |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| @@ -474,6 +481,14 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Demos are NEVER activator-managed, even on hibernate-by-default boxes: |
| + // no lease, no litestream replica, no parked S3 prefix — "no backups" |
| + // (docs/demo-spaces.md) is structural. They hold a materialized slot for |
| + // their 24h life instead; the cloud's live-demo cap bounds how many. |
| + hibernate := c.hibernateCreates |
| + if strings.EqualFold(strings.TrimSpace(req.Entitlement), "demo") { |
| + hibernate = false |
| + } |
| res, err := addInstance(c.reg, addInstanceOpts{ |
| Host: req.Host, |
| AdminToken: req.AdminToken, |
| @@ -489,7 +504,7 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) { |
| // setup link and warming the cert, and the idle window then puts it to |
| // sleep. A never-claimed space costs zero RAM from that point on. Off |
| // by default so the flip stays a per-box config decision. |
| - Hibernate: c.hibernateCreates, |
| + Hibernate: hibernate, |
| }) |
| switch { |
| case errors.Is(err, errInvalidHost): |
diff --git a/demo.go b/demo.go| new file mode 100644 |
| index 0000000..3f655ce |
| --- /dev/null |
| +++ b/demo.go |
| @@ -0,0 +1,156 @@ |
| +package main |
| + |
| +// Demo spaces, slice 1 (docs/demo-spaces.md): the ledger tier, the |
| +// unconditional TTL reap, and no-backups-by-construction. A demo is a real |
| +// instance anyone will be able to try without an account, reaped 24 hours |
| +// after creation no matter what's in it — the death is the contract, disclosed |
| +// at birth. |
| +// |
| +// This slice ships DARK: nothing sends kind=demo yet (home doesn't offer it, |
| +// and the whole path answers demo_unavailable unless LCHAT_DEMO_TTL_HOURS is |
| +// set). The abuse gates that make the path safe to expose — per-IP semaphore + |
| +// cooldown in home, adaptive proof-of-work — are the next slice; the only |
| +// gates here are the live-demo cap and the shared creates_log budget, enough |
| +// to bound the dark path. |
| +// |
| +// No backups is structural, not configured away: controld forces |
| +// Hibernate=false for a demo create, so a demo is never activator-managed — |
| +// no S3 lease, no litestream replica, no parked prefix, ever (the request-cost |
| +// class #836 killed stays killed). The price is that a demo holds a |
| +// materialized slot for its 24h life; the live-demo cap bounds how many. And |
| +// the reap DISCARDS instead of archiving to /data/trash — the one deliberate |
| +// exception to archive-everything, safe because the 24h death was disclosed |
| +// from the first second. |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| + "time" |
| +) |
| + |
| +// 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) { |
| + if s.demoTTL <= 0 { |
| + writeControlErr(w, http.StatusNotImplemented, "demo_unavailable", "demo spaces aren't offered here") |
| + return |
| + } |
| + n, err := s.db.countDemos() |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| + return |
| + } |
| + if n >= s.demoMaxLive { |
| + writeControlErr(w, http.StatusServiceUnavailable, "demo_busy", |
| + "every demo slot is taken right now — they free up quickly, try again in a few minutes") |
| + return |
| + } |
| + |
| + id, err := s.db.allocateDemoSpace(callerID, requester) |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| + return |
| + } |
| + slug, err := spaceSlug(id) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") |
| + return |
| + } |
| + host := slug + "." + s.baseDomain |
| + |
| + status, body, err := s.controlCreate(host, entDemo) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) |
| + log.Printf("cloud: control call for demo %s: %v", host, err) |
| + writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable") |
| + return |
| + } |
| + switch { |
| + case status == http.StatusCreated: |
| + if err := s.db.finalizeSpace(id, host); err != nil { |
| + log.Printf("cloud: ledger finalize for demo %s (id %d) failed: %v", host, id, err) |
| + } |
| + log.Printf("cloud: created demo %s (id %d, caller %.8s…, dies in %s)", host, id, callerID, s.demoTTL) |
| + s.pushQuota(id, host, entDemo, "") // the tiny demo cap (no-op unless configured) |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "host": host, |
| + "url": body["url"], |
| + "setup_link": body["setup_link"], |
| + "kind": entDemo, |
| + "expires_at": time.Now().Add(s.demoTTL).Unix(), |
| + }) |
| + case status == http.StatusServiceUnavailable: |
| + s.db.deleteSpaceByID(id) |
| + writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| + default: |
| + s.db.deleteSpaceByID(id) |
| + msg, _ := body["message"].(string) |
| + log.Printf("cloud: control create demo %s: HTTP %d %s", host, status, msg) |
| + writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed") |
| + } |
| +} |
| + |
| +// demoReapLoop sweeps expired demos. Hourly and separate from the main |
| +// reaper's (default 6h) cadence: the countdown banner promises "24 hours", so |
| +// the overshoot should be minutes-to-an-hour, not a workday. |
| +func (s *cloudServer) demoReapLoop() { |
| + if s.demoTTL <= 0 { |
| + return |
| + } |
| + mode := "" |
| + if s.reapDryRun { |
| + mode = " (dry-run — logs only, deletes nothing)" |
| + } |
| + log.Printf("cloud: demo reaper on%s — demos die %s after creation, swept hourly, discarded (no trash archive)", mode, s.demoTTL) |
| + for { |
| + s.demoReapOnce(time.Now()) |
| + time.Sleep(time.Hour) |
| + } |
| +} |
| + |
| +// demoReapOnce deletes every demo past its TTL. UNCONDITIONALLY — no stats |
| +// call, no guards: content does not protect a demo (deliberate inversion of |
| +// the main reaper's three guards, per docs/demo-spaces.md — the death was |
| +// disclosed at birth and on a countdown banner ever since). The scope is |
| +// bounded by construction: the query selects entitlement='demo' only, so the |
| +// worst a bug here can cost is demos dying early. Data is discarded, not |
| +// archived (the demo contract's one exception to archive-everything). |
| +func (s *cloudServer) demoReapOnce(now time.Time) (reaped int) { |
| + cutoff := now.Add(-s.demoTTL).Unix() |
| + cands, err := s.db.demosPastTTL(cutoff) |
| + if err != nil { |
| + log.Printf("cloud: demo reaper: ledger query: %v", err) |
| + return 0 |
| + } |
| + for _, c := range cands { |
| + if s.reapDryRun { |
| + log.Printf("cloud: demo reaper: WOULD discard %s (id %d)", c.Host, c.ID) |
| + continue |
| + } |
| + // A pending row (create died between allocate and finalize) holds a |
| + // demo slot with no instance behind it — ledger-only cleanup. |
| + if c.Host == "" { |
| + if err := s.db.deleteSpaceByID(c.ID); err != nil { |
| + log.Printf("cloud: demo reaper: ledger delete pending id %d: %v", c.ID, err) |
| + } |
| + continue |
| + } |
| + status, body, err := s.controlDelete(c.Host, true) |
| + if err != nil { |
| + log.Printf("cloud: demo reaper: delete %s failed: %v", c.Host, err) |
| + continue |
| + } |
| + // unknown_host = already gone (an operator removed it); still clear the row. |
| + if status != http.StatusOK && body["error"] != "unknown_host" { |
| + log.Printf("cloud: demo reaper: delete %s: HTTP %d", c.Host, status) |
| + continue |
| + } |
| + if err := s.db.deleteSpaceByID(c.ID); err != nil { |
| + log.Printf("cloud: demo reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err) |
| + } |
| + log.Printf("cloud: demo reaper: discarded expired demo %s (id %d, lived its %s)", c.Host, c.ID, s.demoTTL) |
| + reaped++ |
| + } |
| + return reaped |
| +} |
diff --git a/demo_test.go b/demo_test.go| new file mode 100644 |
| index 0000000..368735b |
| --- /dev/null |
| +++ b/demo_test.go |
| @@ -0,0 +1,320 @@ |
| +package main |
| + |
| +// Demo spaces, slice 1 (demo.go): the kind=demo create branch, the |
| +// unconditional TTL reap with discard, and the structural no-backups |
| +// guarantees (never hibernated → never replicated; discarded → never |
| +// archived). The main reaper's guards are also pinned to NOT apply to demos — |
| +// content protects a free space, never a demo. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// createKind posts a create with an explicit kind. |
| +func createKind(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)) |
| + 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 |
| +} |
| + |
| +func TestCreateDemoSpace(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + token := mintCaller(t, srv, "home") |
| + |
| + resp := createKind(t, srv, token, "alice", "demo") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo create: HTTP %d", resp.StatusCode) |
| + } |
| + body := decodeBody(t, resp) |
| + if body["kind"] != "demo" { |
| + t.Fatalf("kind = %v", body["kind"]) |
| + } |
| + exp, ok := body["expires_at"].(float64) |
| + if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() { |
| + t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"]) |
| + } |
| + var ent string |
| + if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, body["host"]).Scan(&ent); err != nil || ent != entDemo { |
| + t.Fatalf("ledger entitlement = %q err=%v, want demo", ent, err) |
| + } |
| +} |
| + |
| +// Demos neither consume nor are blocked by the free-space quota. |
| +func TestDemoSkipsFreeQuota(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| + token := mintCaller(t, srv, "home") |
| + |
| + // Fill the free quota (harness default 3)... |
| + for i := 0; i < 3; i++ { |
| + if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) |
| + } |
| + } |
| + if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("4th free create: HTTP %d, want 409", resp.StatusCode) |
| + } |
| + // ...a demo still goes through past a full quota... |
| + if resp := createKind(t, srv, token, "bob", "demo"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode) |
| + } |
| + // ...and doesn't tighten it further. |
| + if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestDemoDisabledAndBadKind(t *testing.T) { |
| + srv, _ := newTestCloud(t, okControl(t)) // demoTTL zero: the shipped default |
| + token := mintCaller(t, srv, "home") |
| + |
| + resp := createKind(t, srv, token, "carol", "demo") |
| + if resp.StatusCode != http.StatusNotImplemented { |
| + t.Fatalf("demo while disabled: HTTP %d, want 501", resp.StatusCode) |
| + } |
| + if body := decodeBody(t, resp); body["error"] != "demo_unavailable" { |
| + t.Fatalf("error = %v", body["error"]) |
| + } |
| + if resp := createKind(t, srv, token, "carol", "banana"); resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("bad kind: HTTP %d, want 400", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestDemoLiveCap(t *testing.T) { |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.demoTTL, s.demoMaxLive = 24*time.Hour, 1 |
| + token := mintCaller(t, srv, "home") |
| + |
| + if resp := createKind(t, srv, token, "dave", "demo"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("first demo: HTTP %d", resp.StatusCode) |
| + } |
| + resp := createKind(t, srv, token, "erin", "demo") |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("capped demo: HTTP %d, want 503", resp.StatusCode) |
| + } |
| + if body := decodeBody(t, resp); body["error"] != "demo_busy" { |
| + t.Fatalf("error = %v", body["error"]) |
| + } |
| +} |
| + |
| +// demoControl fakes controld's DELETE endpoint, recording host → discard flag. |
| +// Deliberately NO stats route: a demo reap that consulted stats would fail |
| +// loudly here — pinning that the reap is unconditional. |
| +func demoControl(t *testing.T, deleted map[string]string, mu *sync.Mutex) string { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) { |
| + mu.Lock() |
| + deleted[r.PathValue("host")] = r.URL.Query().Get("discard") |
| + mu.Unlock() |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + }) |
| + return fakeControl(t, mux.ServeHTTP) |
| +} |
| + |
| +// seedDemo inserts a demo row directly (finalized unless host == ""). |
| +func seedDemo(t *testing.T, db *cloudDB, host string, createdAt int64) int64 { |
| + t.Helper() |
| + var h any |
| + if host != "" { |
| + h = host |
| + } |
| + res, err := db.sql.Exec( |
| + `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement) VALUES(?, 'c', 'r', ?, 'demo')`, |
| + h, createdAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, _ := res.LastInsertId() |
| + return id |
| +} |
| + |
| +func TestDemoReapUnconditionalWithDiscard(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + now := time.Unix(2_000_000_000, 0) |
| + expired := seedDemo(t, db, "old.example.com", now.Add(-25*time.Hour).Unix()) |
| + fresh := seedDemo(t, db, "new.example.com", now.Add(-1*time.Hour).Unix()) |
| + pending := seedDemo(t, db, "", now.Add(-30*time.Hour).Unix()) |
| + |
| + deleted := map[string]string{} |
| + var mu sync.Mutex |
| + s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t", demoTTL: 24 * time.Hour} |
| + |
| + if reaped := s.demoReapOnce(now); reaped != 1 { |
| + t.Fatalf("reaped = %d, want 1", reaped) |
| + } |
| + if deleted["old.example.com"] != "1" { |
| + t.Fatalf("expired demo not deleted with discard=1: %v", deleted) |
| + } |
| + if _, ok := deleted["new.example.com"]; ok { |
| + t.Fatal("fresh demo was deleted") |
| + } |
| + for id, want := range map[int64]bool{expired: false, fresh: true, pending: false} { |
| + var n int |
| + if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces WHERE id = ?`, id).Scan(&n); err != nil { |
| + t.Fatal(err) |
| + } |
| + if (n == 1) != want { |
| + t.Fatalf("row %d present=%v, want %v", id, n == 1, want) |
| + } |
| + } |
| +} |
| + |
| +func TestDemoReapDryRun(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + now := time.Unix(2_000_000_000, 0) |
| + seedDemo(t, db, "old.example.com", now.Add(-48*time.Hour).Unix()) |
| + |
| + deleted := map[string]string{} |
| + var mu sync.Mutex |
| + s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t", |
| + demoTTL: 24 * time.Hour, reapDryRun: true} |
| + if reaped := s.demoReapOnce(now); reaped != 0 { |
| + t.Fatalf("dry-run reaped %d", reaped) |
| + } |
| + if len(deleted) != 0 { |
| + t.Fatalf("dry-run called control delete: %v", deleted) |
| + } |
| + var n int |
| + if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces`).Scan(&n); err != nil || n != 1 { |
| + t.Fatalf("rows = %d err=%v, want 1 untouched", n, err) |
| + } |
| +} |
| + |
| +// The MAIN reaper must never see demos: its candidate query is free-only, so |
| +// even an ancient demo full of content is not its business (the demo reaper |
| +// owns that lifecycle, on its own clock). |
| +func TestMainReaperIgnoresDemos(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + now := time.Unix(2_000_000_000, 0) |
| + seedDemo(t, db, "olddemo.example.com", now.Add(-40*24*time.Hour).Unix()) |
| + |
| + cands, err := db.spacesOlderThan(now.Add(-14 * 24 * time.Hour).Unix()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(cands) != 0 { |
| + t.Fatalf("main reaper candidates include a demo: %+v", cands) |
| + } |
| +} |
| + |
| +func TestCapForDemo(t *testing.T) { |
| + s := &cloudServer{storageCaps: map[string]int64{"free": 2e9, "demo": 25e6}} |
| + if got := s.capFor(entDemo, ""); got != 25e6 { |
| + t.Fatalf("demo cap = %d, want its own 25MB entry", got) |
| + } |
| + s.storageCaps = map[string]int64{"free": 2e9} |
| + if got := s.capFor(entDemo, ""); got != 2e9 { |
| + t.Fatalf("demo cap without a demo entry = %d, want the free fallback", got) |
| + } |
| +} |
| + |
| +// A demo create is never activator-managed, even on a hibernate-by-default |
| +// box — the structural half of "no backups": no lease, no replica, no prefix. |
| +// (That a FREE create on the same box is born asleep is pinned by the |
| +// existing hibernate-creates tests; no wakeOnProbe here because the demo |
| +// path is materialized — the fixture's fake systemctl serves its probe.) |
| +func TestControldDemoCreateNeverHibernates(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5, |
| + hibernateCreates: true} |
| + srv := httptest.NewServer(c.handler()) |
| + defer srv.Close() |
| + |
| + body, _ := json.Marshal(map[string]string{"host": "demo1.example.com", "entitlement": "demo"}) |
| + req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer test-control-token") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("demo create: HTTP %d", resp.StatusCode) |
| + } |
| + rt, err := reg.get("demo1.example.com") |
| + if err != nil || rt == nil { |
| + t.Fatalf("registry row missing: %v", err) |
| + } |
| + if rt.Hibernate { |
| + t.Fatal("demo create was registered activator-managed — it must never touch S3") |
| + } |
| +} |
| + |
| +// discard=1 removes an instance's files outright; the default still archives. |
| +func TestControldDeleteDiscard(t *testing.T) { |
| + reg := controlTestEnv(t) |
| + c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5} |
| + srv := httptest.NewServer(c.handler()) |
| + defer srv.Close() |
| + |
| + del := func(host, query string) { |
| + t.Helper() |
| + req, _ := http.NewRequest("DELETE", srv.URL+"/instances/"+host+query, nil) |
| + req.Header.Set("Authorization", "Bearer test-control-token") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("delete %s%s: HTTP %d", host, query, resp.StatusCode) |
| + } |
| + } |
| + trashEntries := func() []string { |
| + entries, _ := os.ReadDir(filepath.Join(instanceDataDir, "trash")) |
| + var names []string |
| + for _, e := range entries { |
| + names = append(names, e.Name()) |
| + } |
| + return names |
| + } |
| + |
| + if resp := controlCreate(t, srv, "test-control-token", "gone.example.com"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create: HTTP %d", resp.StatusCode) |
| + } |
| + envPath := filepath.Join(instanceDataDir, "gone.example.com.env") |
| + if _, err := os.Stat(envPath); err != nil { |
| + t.Fatalf("env file missing after create: %v", err) |
| + } |
| + del("gone.example.com", "?discard=1") |
| + if _, err := os.Stat(envPath); !os.IsNotExist(err) { |
| + t.Fatalf("discarded env file still exists (err=%v)", err) |
| + } |
| + for _, name := range trashEntries() { |
| + if strings.HasPrefix(name, "gone.example.com-") { |
| + t.Fatalf("discard archived to trash anyway: %s", name) |
| + } |
| + } |
| + |
| + if resp := controlCreate(t, srv, "test-control-token", "kept.example.com"); resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create: HTTP %d", resp.StatusCode) |
| + } |
| + del("kept.example.com", "") |
| + found := false |
| + for _, name := range trashEntries() { |
| + if strings.HasPrefix(name, "kept.example.com-") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatal("default delete did not archive to trash") |
| + } |
| +} |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 5caee7c..35c6e1a 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -334,6 +334,34 @@ window: `LCHAT_TRASH_KEEP_DAYS` (opt-in — 0/unset keeps trash forever) removes |
| `/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well |
| above the reap threshold so a mistaken reap stays recoverable for a while. |
| +## Demo spaces (docs/demo-spaces.md slice 1) — 24h, discarded, no backups |
| + |
| +With `LCHAT_DEMO_TTL_HOURS` set (unset = the shipped default, demos off), a |
| +create may carry `kind: "demo"`: an `entitlement='demo'` ledger row that |
| +skips the free-space quota and is bounded instead by `LCHAT_DEMO_MAX_LIVE` |
| +(default 10, pending rows included — refusals answer `demo_busy`) plus the |
| +shared creates-per-day breaker. The create response carries `expires_at`. |
| +Three deliberate inversions of the free-space rules: |
| + |
| +- **Reaped unconditionally** at `created_at + TTL` by an hourly demo sweep — |
| + no stats call, no guards: content protects a free space, never a demo (the |
| + 24h death is the disclosed contract). `LCHAT_CLOUD_REAP_DRY_RUN` is |
| + honoured. The main reaper never sees demos (its candidate query is |
| + free-only). |
| +- **Discarded, not archived**: the reap deletes via controld |
| + `DELETE /instances/{host}?discard=1`, which removes the files outright |
| + (blobs included) instead of renaming into `/data/trash` — the one exception |
| + to archive-everything. |
| +- **Never activator-managed**: controld forces `Hibernate=false` for a demo |
| + create even on a hibernate-by-default box, so a demo never touches S3 — no |
| + lease, no litestream replica, no parked prefix. It holds a materialized |
| + slot for its 24h life; the live-demo cap bounds how many. |
| + |
| +Demos take a `demo` key in `LCHAT_CLOUD_STORAGE_CAPS` (falling back to the |
| +`free` cap when absent). This slice ships dark — home never sends |
| +`kind=demo` yet; the per-IP semaphore + cooldown + adaptive proof-of-work |
| +gates land before any surface does. |
| + |
| ## The storage meter (#668 slice 1) |
| Every sweep interval (`LCHAT_CLOUD_METER_INTERVAL_HOURS`, default 6, 0 |
diff --git a/meter.go b/meter.go| index fcb5fc8..7b17ee5 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -194,6 +194,13 @@ func (s *cloudServer) capFor(entitlement, tier string) int64 { |
| if entitlement == entPaid { |
| return s.storageCaps[tier] |
| } |
| + // Demos get their own (tiny) cap when configured, else the free one — |
| + // falling through beats leaving a demo uncapped. |
| + if entitlement == entDemo { |
| + if cap, ok := s.storageCaps[entDemo]; ok { |
| + return cap |
| + } |
| + } |
| return s.storageCaps["free"] |
| } |
diff --git a/reaper.go b/reaper.go| index cd5c35b..ee34c15 100644 |
| --- a/reaper.go |
| +++ b/reaper.go |
| @@ -233,7 +233,7 @@ func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) { |
| log.Printf("cloud: reaper: per-sweep cap (%d) reached — deferring the rest to the next sweep", s.reapMax) |
| break |
| } |
| - status, body, err := s.controlDelete(c.Host) |
| + status, body, err := s.controlDelete(c.Host, false) |
| if err != nil { |
| log.Printf("cloud: reaper: delete %s failed: %v", c.Host, err) |
| continue |
| @@ -296,7 +296,7 @@ func (s *cloudServer) reclaimOwnSpace(callerID, requester string) bool { |
| if st.SetupComplete || st.Members > 1 || st.HasContent { |
| continue // in real use — the quota refusal stands |
| } |
| - status, body, err := s.controlDelete(c.Host) |
| + status, body, err := s.controlDelete(c.Host, false) |
| if err != nil { |
| log.Printf("cloud: self-reclaim: delete %s failed: %v", c.Host, err) |
| continue |