| 1 | package main |
| 2 | |
| 3 | // Demo spaces, slice 1 (docs/demo-spaces.md): the ledger tier, the |
| 4 | // unconditional TTL reap, and no-backups-by-construction. A demo is a real |
| 5 | // instance anyone will be able to try without an account, reaped 24 hours |
| 6 | // after creation no matter what's in it — the death is the contract, disclosed |
| 7 | // at birth. |
| 8 | // |
| 9 | // This slice ships DARK: nothing sends kind=demo yet (home doesn't offer it, |
| 10 | // and the whole path answers demo_unavailable unless LCHAT_DEMO_TTL_HOURS is |
| 11 | // set). The abuse gates that make the path safe to expose — per-IP semaphore + |
| 12 | // cooldown in home, adaptive proof-of-work — are the next slice; the only |
| 13 | // gates here are the live-demo cap and the shared creates_log budget, enough |
| 14 | // to bound the dark path. |
| 15 | // |
| 16 | // No backups is structural, not configured away: controld forces |
| 17 | // Hibernate=false for a demo create, so a demo is never activator-managed — |
| 18 | // no S3 lease, no litestream replica, no parked prefix, ever (the request-cost |
| 19 | // class #836 killed stays killed). The price is that a demo holds a |
| 20 | // materialized slot for its 24h life; the live-demo cap bounds how many. And |
| 21 | // the reap DISCARDS instead of archiving to /data/trash — the one deliberate |
| 22 | // exception to archive-everything, safe because the 24h death was disclosed |
| 23 | // from the first second. |
| 24 | |
| 25 | import ( |
| 26 | "context" |
| 27 | "encoding/json" |
| 28 | "fmt" |
| 29 | "io" |
| 30 | "log" |
| 31 | "net/http" |
| 32 | "strings" |
| 33 | "time" |
| 34 | ) |
| 35 | |
| 36 | // createDemoSpace is createSpaceHandler's kind=demo branch: no quota, no |
| 37 | // self-reclaim — a demo's bounds are its own cap and its TTL. |
| 38 | func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter, callerID, requester string) { |
| 39 | if s.demoTTL <= 0 { |
| 40 | writeControlErr(w, http.StatusNotImplemented, "demo_unavailable", "demo spaces aren't offered here") |
| 41 | return |
| 42 | } |
| 43 | n, err := s.db.countDemos() |
| 44 | if err != nil { |
| 45 | writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| 46 | return |
| 47 | } |
| 48 | if n >= s.demoMaxLive { |
| 49 | writeControlErr(w, http.StatusServiceUnavailable, "demo_busy", |
| 50 | "every demo slot is taken right now — they free up quickly, try again in a few minutes") |
| 51 | return |
| 52 | } |
| 53 | |
| 54 | id, err := s.db.allocateDemoSpace(callerID, requester) |
| 55 | if err != nil { |
| 56 | writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") |
| 57 | return |
| 58 | } |
| 59 | // Same fork as the regular create path (cloud.go spaceHostID): the shared |
| 60 | // global ledger when configured, else this demo row's cloud.db PK — today's |
| 61 | // behaviour unchanged on any box without the shared bucket. The PK still |
| 62 | // keys the row for the reaper/claim/finalize; only the address's id moves. |
| 63 | hostID, err := s.spaceHostID(ctx, id) |
| 64 | if err != nil { |
| 65 | s.db.deleteSpaceByID(id) |
| 66 | log.Printf("cloud: id ledger allocate for demo id %d: %v", id, err) |
| 67 | writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable") |
| 68 | return |
| 69 | } |
| 70 | slug, err := spaceSlug(hostID) |
| 71 | if err != nil { |
| 72 | s.db.deleteSpaceByID(id) |
| 73 | writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") |
| 74 | return |
| 75 | } |
| 76 | host := slug + "." + s.baseDomain |
| 77 | |
| 78 | status, body, err := s.controlCreate(host, entDemo, "") |
| 79 | if err != nil { |
| 80 | s.db.deleteSpaceByID(id) |
| 81 | log.Printf("cloud: control call for demo %s: %v", host, err) |
| 82 | writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable") |
| 83 | return |
| 84 | } |
| 85 | switch { |
| 86 | case status == http.StatusCreated: |
| 87 | if err := s.db.finalizeSpace(id, host); err != nil { |
| 88 | log.Printf("cloud: ledger finalize for demo %s (id %d) failed: %v", host, id, err) |
| 89 | } |
| 90 | log.Printf("cloud: created demo %s (id %d, caller %.8s…, dies in %s)", host, id, callerID, s.demoTTL) |
| 91 | s.pushQuota(id, host, entDemo, "", 0) // the tiny demo cap (no-op unless configured) |
| 92 | writeJSON(w, http.StatusCreated, map[string]any{ |
| 93 | "host": host, |
| 94 | "url": body["url"], |
| 95 | "setup_link": body["setup_link"], |
| 96 | "kind": entDemo, |
| 97 | "expires_at": time.Now().Add(s.demoTTL).Unix(), |
| 98 | }) |
| 99 | case status == http.StatusServiceUnavailable: |
| 100 | s.db.deleteSpaceByID(id) |
| 101 | writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| 102 | default: |
| 103 | s.db.deleteSpaceByID(id) |
| 104 | msg, _ := body["message"].(string) |
| 105 | log.Printf("cloud: control create demo %s: HTTP %d %s", host, status, msg) |
| 106 | writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed") |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // demoReapLoop sweeps expired demos. Hourly and separate from the main |
| 111 | // reaper's (default 6h) cadence: the countdown banner promises "24 hours", so |
| 112 | // the overshoot should be minutes-to-an-hour, not a workday. |
| 113 | func (s *cloudServer) demoReapLoop() { |
| 114 | if s.demoTTL <= 0 { |
| 115 | return |
| 116 | } |
| 117 | mode := "" |
| 118 | if s.reapDryRun { |
| 119 | mode = " (dry-run — logs only, deletes nothing)" |
| 120 | } |
| 121 | log.Printf("cloud: demo reaper on%s — demos die %s after creation, swept hourly, discarded (no trash archive)", mode, s.demoTTL) |
| 122 | for { |
| 123 | s.demoReapOnce(time.Now()) |
| 124 | time.Sleep(time.Hour) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // demoReapOnce deletes every demo past its TTL. UNCONDITIONALLY — no stats |
| 129 | // call, no guards: content does not protect a demo (deliberate inversion of |
| 130 | // the main reaper's three guards, per docs/demo-spaces.md — the death was |
| 131 | // disclosed at birth and on a countdown banner ever since). The scope is |
| 132 | // bounded by construction: the query selects entitlement='demo' only, so the |
| 133 | // worst a bug here can cost is demos dying early. Data is discarded, not |
| 134 | // archived (the demo contract's one exception to archive-everything). |
| 135 | func (s *cloudServer) demoReapOnce(now time.Time) (reaped int) { |
| 136 | cutoff := now.Add(-s.demoTTL).Unix() |
| 137 | cands, err := s.db.demosPastTTL(cutoff) |
| 138 | if err != nil { |
| 139 | log.Printf("cloud: demo reaper: ledger query: %v", err) |
| 140 | return 0 |
| 141 | } |
| 142 | for _, c := range cands { |
| 143 | if s.reapDryRun { |
| 144 | log.Printf("cloud: demo reaper: WOULD discard %s (id %d)", c.Host, c.ID) |
| 145 | continue |
| 146 | } |
| 147 | // A pending row (create died between allocate and finalize) holds a |
| 148 | // demo slot with no instance behind it — ledger-only cleanup. |
| 149 | if c.Host == "" { |
| 150 | if err := s.db.deleteSpaceByID(c.ID); err != nil { |
| 151 | log.Printf("cloud: demo reaper: ledger delete pending id %d: %v", c.ID, err) |
| 152 | } |
| 153 | continue |
| 154 | } |
| 155 | status, body, err := s.controlDelete(c.Host, true) |
| 156 | if err != nil { |
| 157 | log.Printf("cloud: demo reaper: delete %s failed: %v", c.Host, err) |
| 158 | continue |
| 159 | } |
| 160 | // unknown_host = already gone (an operator removed it); still clear the row. |
| 161 | if status != http.StatusOK && body["error"] != "unknown_host" { |
| 162 | log.Printf("cloud: demo reaper: delete %s: HTTP %d", c.Host, status) |
| 163 | continue |
| 164 | } |
| 165 | if err := s.db.deleteSpaceByID(c.ID); err != nil { |
| 166 | log.Printf("cloud: demo reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err) |
| 167 | } |
| 168 | log.Printf("cloud: demo reaper: discarded expired demo %s (id %d, lived its %s)", c.Host, c.ID, s.demoTTL) |
| 169 | reaped++ |
| 170 | } |
| 171 | return reaped |
| 172 | } |
| 173 | |
| 174 | // claimDemoHandler: POST /api/instances/{host}/claim — convert a demo into |
| 175 | // the claiming account's free space (docs/demo-spaces.md, the happy exit). |
| 176 | // Ownership TRANSFERS: a demo belongs to a one-shot anonymous requester until |
| 177 | // someone signs in and claims it; from then on it's an ordinary free space — |
| 178 | // it counts against the account's quota, the demo reaper no longer sees it |
| 179 | // (the entitlement flip is the TTL clear), and the free tier's rules apply. |
| 180 | // No pool gate here: the space's spend was already counted while it was a |
| 181 | // demo, so a claim adds no net-new cost — refusing one at red would only |
| 182 | // destroy data the pool has already paid for. |
| 183 | func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) { |
| 184 | callerID := r.Context().Value(callerKey{}).(string) |
| 185 | host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| 186 | var req struct { |
| 187 | Requester string `json:"requester"` |
| 188 | } |
| 189 | if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { |
| 190 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| 191 | return |
| 192 | } |
| 193 | req.Requester = strings.TrimSpace(req.Requester) |
| 194 | if req.Requester == "" || len(req.Requester) > 128 { |
| 195 | writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)") |
| 196 | return |
| 197 | } |
| 198 | |
| 199 | id, entitlement, createdAt, ok, err := s.db.spaceByHost(host) |
| 200 | if err != nil { |
| 201 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| 202 | return |
| 203 | } |
| 204 | if !ok { |
| 205 | writeControlErr(w, http.StatusNotFound, "not_found", "no such space") |
| 206 | return |
| 207 | } |
| 208 | if entitlement != entDemo { |
| 209 | writeControlErr(w, http.StatusConflict, "not_demo", "this space isn't a demo") |
| 210 | return |
| 211 | } |
| 212 | // Racing the reaper: a demo past its lifetime is already dead by |
| 213 | // contract, even if the sweep hasn't collected it yet. |
| 214 | if s.demoTTL > 0 && time.Now().Unix() > createdAt+int64(s.demoTTL/time.Second) { |
| 215 | writeControlErr(w, http.StatusGone, "demo_expired", "this demo's 24 hours are up — it can no longer be claimed") |
| 216 | return |
| 217 | } |
| 218 | // The claim consumes the account's free slot, same rules as a create — |
| 219 | // including the create path's courtesy reclaim of their own dead weight. |
| 220 | if n, err := s.db.countFreeSpaces(callerID, req.Requester); err != nil { |
| 221 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| 222 | return |
| 223 | } else if n >= s.quota { |
| 224 | if !s.reclaimOwnSpace(callerID, req.Requester) { |
| 225 | noun := "spaces" |
| 226 | if s.quota == 1 { |
| 227 | noun = "space" |
| 228 | } |
| 229 | writeControlErr(w, http.StatusConflict, "quota_exceeded", |
| 230 | fmt.Sprintf("this account already has %d free %s (the limit) — upgrading a space frees its slot, and so does deleting one you no longer use", s.quota, noun)) |
| 231 | return |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | claimed, err := s.db.claimDemoSpace(id, callerID, req.Requester) |
| 236 | if err != nil { |
| 237 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| 238 | return |
| 239 | } |
| 240 | if !claimed { |
| 241 | // Lost a race — another claim or the reaper got there first. |
| 242 | writeControlErr(w, http.StatusConflict, "not_demo", "this demo was just claimed or expired") |
| 243 | return |
| 244 | } |
| 245 | log.Printf("cloud: demo %s (id %d) claimed by caller %.8s… — now a free space", host, id, callerID) |
| 246 | // Flip the instance's own view: entitlement env + the free tier's storage |
| 247 | // cap, and record the free replication class for when the space is later |
| 248 | // flipped onto the activator (hibernation/backups are an operator or |
| 249 | // follow-up concern — the claim itself never restarts anything). |
| 250 | s.pushQuota(id, host, entFree, "", 0) |
| 251 | s.pushReplication(host, entFree, "") |
| 252 | writeJSON(w, http.StatusOK, map[string]any{ |
| 253 | "host": host, "url": "https://" + host, "entitlement": entFree, |
| 254 | }) |
| 255 | } |
| 256 | |
| 257 | // demoStatusHandler: GET /api/instances/{host}/demo-status — the countdown |
| 258 | // banner's live truth. A claim flips the ledger WITHOUT restarting the |
| 259 | // instance (deliberate — see claimDemoHandler), so the instance's boot-time |
| 260 | // LCHAT_ENTITLEMENT can read stale-demo for a space that's already safe; the |
| 261 | // banner asks here (via home's public relay) before promising anyone a death. |
| 262 | // One shape for claimed, ordinary, and never-existed hosts — the answer is |
| 263 | // only ever "still a demo, and until when", so the endpoint can't be used to |
| 264 | // probe which hosts exist or what anyone pays. |
| 265 | func (s *cloudServer) demoStatusHandler(w http.ResponseWriter, r *http.Request) { |
| 266 | host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| 267 | _, entitlement, createdAt, ok, err := s.db.spaceByHost(host) |
| 268 | if err != nil { |
| 269 | writeControlErr(w, http.StatusInternalServerError, "status_failed", "db error") |
| 270 | return |
| 271 | } |
| 272 | if !ok || entitlement != entDemo { |
| 273 | writeJSON(w, http.StatusOK, map[string]any{"demo": false}) |
| 274 | return |
| 275 | } |
| 276 | out := map[string]any{"demo": true} |
| 277 | // TTL unset (demos since turned off, row predates that) leaves the expiry |
| 278 | // unknown rather than inventing one; the banner falls back to generic copy. |
| 279 | if s.demoTTL > 0 { |
| 280 | out["expires_at"] = createdAt + int64(s.demoTTL/time.Second) |
| 281 | } |
| 282 | writeJSON(w, http.StatusOK, out) |
| 283 | } |
| 284 | |