eleven / messenger Public

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>
Paul Campbell 01cadd25ec62afb0a9241e980a3c16526130748d parent 80b5b03
10 files changed, +662 −32
  • cloud.go +43 −14
  • cloud_db.go +60 −0
  • cloudreplication.go +3 −0
  • control.go +24 −12
  • controld.go +19 −4
  • demo.go +156 −0
  • demo_test.go +320 −0
  • docs/cloud-provisioner.md +28 −0
  • meter.go +7 −0
  • reaper.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