eleven / ocho Public

cloud: reaper for never-claimed spaces (+ trash purge) (#434)

Self-serve creation hands out a capacity slot the instant a space is made. A
space whose admin never finishes setup (the setup invite expires after 7 days)
would otherwise sit there forever — empty, but holding a slot and a disk. The
reaper reclaims them; a companion trash purge reclaims the archive later.

- cloud (reaper.go): every LCHAT_CLOUD_REAP_INTERVAL_HOURS (default 6) it sweeps
  the ledger for finalized spaces older than LCHAT_CLOUD_REAP_AFTER_DAYS. For
  each, it asks controld for the member count; zero = never claimed → deprovision
  (which archives to /data/trash, never destroys) + clear the ledger row.
  Anything non-empty, or whose count can't be read, is left untouched — a stats
  error is always a skip, never a delete. LCHAT_CLOUD_REAP_DRY_RUN logs what it
  would reclaim without deleting.
- controld: new GET /instances/{host}/stats reads the instance DB READ-ONLY and
  returns its member count (validHost-guarded path). Plus an opt-in trash purge
  (LCHAT_TRASH_KEEP_DAYS) that removes /data/trash/<host>-<unix> entries older
  than the window, swept daily.

Both are OPT-IN (0/unset disables), so this is behaviour-neutral until enabled on
a box. The reaper only ever considers hosts in the cloud ledger, so operator-run
instances (family.…, home, push, …) can never be candidates.

Tests: reapOnce reclaims only old empties / keeps claimed / skips on stats error
/ dry-run deletes nothing; spacesOlderThan query; instanceMembers + the stats
endpoint end-to-end (incl. read-only count against a live DB); trash purge +
dir-time parsing.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell da99651c110be6f3d529b6dee2568e487c0a37d4 parent 31453c6
7 files changed, +595 −11
  • CLAUDE.md +7 −5
  • cloud.go +11 −3
  • cloud_db.go +29 −0
  • controld.go +27 −0
  • docs/cloud-provisioner.md +31 −3
  • reaper.go +206 −0
  • reaper_test.go +284 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 0ea7cf8..689032b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,11 +100,13 @@ in, so deploy is one file (no cgo).
reference implementation, kept on purpose** (a runnable example + the wire-format
illustration the Go tests pin against) — don't delete it or let it rot.
- `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache).
-- `controld.go` / `cloud.go` — self-serve space creation on a router box: a
- root-only local control daemon that executes `add-instance` (with a capacity
- cap), and the public, unprivileged provisioner in front of it (caller tokens,
- per-user quotas, friendly slugs). The home server's `POST /api/spaces`
- forwards a signed-in user's request to it. See `docs/cloud-provisioner.md`.
+- `controld.go` / `cloud.go` / `reaper.go` — self-serve space creation on a
+ router box: a root-only local control daemon that executes `add-instance` (with
+ a capacity cap), and the public, unprivileged provisioner in front of it (caller
+ tokens, per-user quotas, friendly slugs). The home server's `POST /api/spaces`
+ forwards a signed-in user's request to it. `reaper.go` reclaims never-claimed
+ (zero-member) spaces so abandoned creates don't eat the cap, archiving to
+ `/data/trash` (recoverable) + a trash purge. See `docs/cloud-provisioner.md`.
- `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped).
- `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed`
with `Cache-Control: no-cache`.
diff --git a/cloud.go b/cloud.go
index b158358..4a8d0b8 100644
--- a/cloud.go
+++ b/cloud.go
@@ -75,9 +75,11 @@ type cloudServer struct {
db *cloudDB
adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke
controlToken string
- control *http.Client // dials the control daemon's unix socket
- baseDomain string // spaces land at <slug>.<baseDomain>
- quota int // max spaces per (caller, requester)
+ control *http.Client // dials the control daemon's unix socket
+ baseDomain string // spaces land at <slug>.<baseDomain>
+ quota int // max spaces per (caller, requester)
+ reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
+ reapDryRun bool // log what the reaper would reclaim, delete nothing
}
func (s *cloudServer) handler() http.Handler {
@@ -362,6 +364,9 @@ func runCloud(args []string) {
baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)")
quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 3), "max spaces per caller+requester")
controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket")
+ reapAfterDays := fs.Int("reap-after-days", envInt("LCHAT_CLOUD_REAP_AFTER_DAYS", 0), "reclaim never-claimed (zero-member) spaces older than this many days; 0 disables the reaper")
+ reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps")
+ reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything")
_ = fs.Parse(args)
if *baseDomain == "" || !validHost("x."+*baseDomain) {
@@ -387,7 +392,10 @@ func runCloud(args []string) {
control: controlClient(*controlSocket),
baseDomain: *baseDomain,
quota: *quota,
+ reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
+ reapDryRun: *reapDryRun,
}
+ go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour)
ln, where, err := listen(*addr, *socketPath)
if err != nil {
die("cloud: listen: %v", err)
diff --git a/cloud_db.go b/cloud_db.go
index ffaa9fa..f44a493 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -220,6 +220,35 @@ func (db *cloudDB) deleteSpaceByID(id int64) error {
return err
}
+// reapCandidate is a finalized space old enough for the reaper to check.
+type reapCandidate struct {
+ ID int64
+ Host string
+ CreatedAt int64
+}
+
+// spacesOlderThan returns finalized spaces created at/before cutoff (unix secs),
+// oldest first — the reaper's candidate set. A NULL host (create in flight or
+// rolled back) is never a candidate.
+func (db *cloudDB) spacesOlderThan(cutoff int64) ([]reapCandidate, error) {
+ rows, err := db.sql.Query(
+ `SELECT id, host, created_at FROM spaces
+ WHERE host IS NOT NULL AND created_at <= ? ORDER BY created_at`, 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()
+}
+
// spaceOwned reports whether this exact (caller, requester) provisioned host —
// the ownership check in front of deletion. Nobody can delete a space through
// the provisioner that the provisioner didn't create for them.
diff --git a/controld.go b/controld.go
index 82e5ee4..17b8388 100644
--- a/controld.go
+++ b/controld.go
@@ -27,6 +27,7 @@ import (
"os"
"os/user"
"strconv"
+ "strings"
"sync"
"syscall"
"time"
@@ -84,9 +85,32 @@ func (c *controld) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /instances", c.auth(c.createHandler))
mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
+ mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
return mux
}
+// statsHandler reports a space's member count — the reaper's emptiness signal
+// (zero = never claimed). Read-only against the live instance DB. validHost
+// guards the DB path against traversal before it's joined with the data dir.
+func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ n, err := instanceMembers(host)
+ if err != nil {
+ if os.IsNotExist(err) {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ log.Printf("controld: stats %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "stats_failed", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": n})
+}
+
// 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.
@@ -228,6 +252,7 @@ func runControld(args []string) {
tokenFile := fs.String("token-file", envOr("LCHAT_CONTROL_TOKEN_FILE", controlTokenPath), "file holding the control bearer token")
maxInstances := fs.Int("max-instances", envInt("LCHAT_MAX_INSTANCES", 40), "refuse creation beyond this many instances on the box")
minFreeGB := fs.Int("min-free-gb", envInt("LCHAT_MIN_FREE_GB", 5), "refuse creation when the data disk has less than this free (GB)")
+ trashKeepDays := fs.Int("trash-keep-days", envInt("LCHAT_TRASH_KEEP_DAYS", 0), "purge archived (deleted) spaces from /data/trash after this many days; 0 keeps them forever")
_ = fs.Parse(args)
tok, err := os.ReadFile(*tokenFile)
@@ -263,6 +288,8 @@ func runControld(args []string) {
}
}
+ go runTrashPurge(time.Duration(*trashKeepDays) * 24 * time.Hour)
+
log.Printf("controld: serving on %s (registry %s, max %d instances, %d GB disk floor)",
*socketPath, *registryPath, *maxInstances, *minFreeGB)
srv := &http.Server{Handler: c.handler(), ReadHeaderTimeout: 10 * time.Second}
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 3d4911e..89204f8 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -186,6 +186,35 @@ server's random opaque uid (no email, no name), the blob stays content-blind,
and a user who never provisions never appears in the ledger. Members who join
by invite are invisible here, unchanged.
+## Reaping never-claimed spaces
+
+Self-serve creation hands out a capacity slot the instant a space is made. A
+space whose admin never finishes setup — the setup invite expires after 7 days —
+would otherwise sit there forever: empty, but holding a slot and a disk. The
+**reaper** (in `cloud`, `reaper.go`) reclaims them:
+
+- Every `LCHAT_CLOUD_REAP_INTERVAL_HOURS` (default 6) it sweeps the ledger for
+ finalized spaces older than `LCHAT_CLOUD_REAP_AFTER_DAYS` (**opt-in — 0/unset
+ disables it**; a value comfortably past the 7-day invite expiry, e.g. 14, is
+ the intent).
+- For each candidate it asks the control daemon `GET /instances/{host}/stats`,
+ which reads the instance DB **read-only** and returns its member count. Zero
+ members = never claimed. **Anything non-empty, or whose count can't be read, is
+ left untouched** — a stats error is always a skip, never a delete.
+- An empty candidate is deprovisioned through the normal controld delete, which
+ **archives** the DB/env to `/data/trash` (never destroys), so a false positive
+ is recoverable. Its ledger row is cleared, freeing the quota slot.
+- `LCHAT_CLOUD_REAP_DRY_RUN=1` logs exactly what it *would* reclaim without
+ deleting — run it once before arming the real sweep.
+
+Because the reaper only ever considers hosts **in the cloud ledger**, operator-run
+instances (family.…, home, push, …) are never candidates.
+
+A companion **trash purge** in `controld` reclaims the archive after a longer
+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.
+
## Enabling it on a box
```sh
@@ -194,8 +223,10 @@ systemctl enable --now lchat-control
cat > /data/cloud.env <<EOF # then: chown lchat:lchat, chmod 600
LCHAT_ADMIN_TOKEN=<mint one>
LCHAT_CLOUD_BASE_DOMAIN=<domain with wildcard DNS at this box>
+LCHAT_CLOUD_REAP_AFTER_DAYS=14 # optional: reclaim never-claimed spaces (0/unset = off)
EOF
systemctl enable --now lchat-cloud
+# optional trash purge (controld): set LCHAT_TRASH_KEEP_DAYS in /data/control.env, restart lchat-control
lchat add-service cloud.<domain> --socket /run/lchat/cloud.sock
# mint the home server's caller token:
curl -sX POST https://cloud.<domain>/api/admin/tokens \
@@ -209,7 +240,4 @@ curl -sX POST https://cloud.<domain>/api/admin/tokens \
server list syncing/sorting through the existing encrypted blob.
- **Bot sidecars on provisioned spaces** — once the Go bot runtime
(`lchat bot`) lands; controld grows an attach-bot step.
-- **Reaper for never-claimed spaces** — setup invites already expire after 7
- days; a zero-member space older than N days could be reclaimed so abandoned
- creates don't eat the cap. (A periodic purge of `/data/trash` belongs with it.)
- **Multi-box placement** — a cloud front choosing among several router boxes.
diff --git a/reaper.go b/reaper.go
new file mode 100644
index 0000000..27b9d31
--- /dev/null
+++ b/reaper.go
@@ -0,0 +1,206 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Reclaiming never-claimed spaces.
+//
+// Self-serve creation hands out a slot against the capacity cap the moment a
+// space is made. A space whose admin never completes setup — the setup invite
+// expires after 7 days — would otherwise sit there forever: empty, but holding a
+// slot and a disk. The reaper sweeps the cloud ledger for spaces older than a
+// threshold, asks the control daemon whether each is still empty (zero members =
+// never claimed), and deprovisions the empties. Deprovisioning ARCHIVES to
+// /data/trash (deleteInstance never destroys), so a false positive is
+// recoverable; a companion trash purge reclaims that archive after a longer
+// window.
+//
+// Both are opt-in (off unless their env is set) and only ever touch spaces IN
+// the cloud ledger — operator-run instances (family.…, home, push, …) are never
+// in it, so they can never be candidates.
+
+// ---- instance emptiness (controld-side, root) ---------------------------------
+
+// instanceMembers counts the members in a space's DB. Opened READ-ONLY so it's
+// safe against the live instance — no migrations, no writes. A never-claimed
+// space has zero (its admin never finished setup, so no user was ever created).
+func instanceMembers(host string) (int, error) {
+ path := filepath.Join(instanceDataDir, host+".db")
+ if _, err := os.Stat(path); err != nil {
+ return 0, err // not-exist propagates → the caller answers unknown_host
+ }
+ conn, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)")
+ if err != nil {
+ return 0, err
+ }
+ defer conn.Close()
+ conn.SetMaxOpenConns(1)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var n int
+ if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&n); err != nil {
+ return 0, err
+ }
+ return n, nil
+}
+
+// ---- trash purge (controld-side, root) ----------------------------------------
+
+// trashDirTime returns when a /data/trash entry was archived. deleteInstance
+// names them "<host>-<unix>"; trust that suffix, falling back to the dir mtime.
+func trashDirTime(name string, info os.FileInfo) time.Time {
+ if i := strings.LastIndexByte(name, '-'); i >= 0 {
+ if sec, err := strconv.ParseInt(name[i+1:], 10, 64); err == nil && sec > 0 {
+ return time.Unix(sec, 0)
+ }
+ }
+ if info != nil {
+ return info.ModTime()
+ }
+ return time.Time{}
+}
+
+// purgeTrash removes archived spaces older than keep, returning how many it
+// removed. Pure but for the filesystem, so it's directly testable.
+func purgeTrash(trashDir string, keep time.Duration, now time.Time) (int, error) {
+ entries, err := os.ReadDir(trashDir)
+ if os.IsNotExist(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ cutoff := now.Add(-keep)
+ removed := 0
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ info, _ := e.Info()
+ if trashDirTime(e.Name(), info).Before(cutoff) {
+ if err := os.RemoveAll(filepath.Join(trashDir, e.Name())); err != nil {
+ log.Printf("controld: trash purge %s: %v", e.Name(), err)
+ continue
+ }
+ removed++
+ }
+ }
+ return removed, nil
+}
+
+// runTrashPurge sweeps /data/trash once at startup then daily. keep<=0 disables it.
+func runTrashPurge(keep time.Duration) {
+ if keep <= 0 {
+ return
+ }
+ trashDir := filepath.Join(instanceDataDir, "trash")
+ log.Printf("controld: trash purge on — reclaims archived spaces older than %s", keep)
+ for {
+ if n, err := purgeTrash(trashDir, keep, time.Now()); err != nil {
+ log.Printf("controld: trash purge: %v", err)
+ } else if n > 0 {
+ log.Printf("controld: trash purge removed %d archived space(s)", n)
+ }
+ time.Sleep(24 * time.Hour)
+ }
+}
+
+// ---- the reaper (cloud-side) ---------------------------------------------------
+
+// controlStats asks the control daemon how many members a space has.
+func (s *cloudServer) controlStats(host string) (int, error) {
+ req, err := http.NewRequest("GET", "http://control/instances/"+host+"/stats", nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
+ }
+ var body struct {
+ Members int `json:"members"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return 0, err
+ }
+ return body.Members, nil
+}
+
+// reapLoop sweeps every interval; enabled only when reapAfter>0.
+func (s *cloudServer) reapLoop(interval time.Duration) {
+ if s.reapAfter <= 0 {
+ return
+ }
+ mode := ""
+ if s.reapDryRun {
+ mode = " (dry-run — logs only, deletes nothing)"
+ }
+ log.Printf("cloud: reaper on%s — reclaims never-claimed spaces older than %s, sweeping every %s", mode, s.reapAfter, interval)
+ for {
+ s.reapOnce(time.Now())
+ time.Sleep(interval)
+ }
+}
+
+// reapOnce reclaims never-claimed spaces older than reapAfter. It deletes only a
+// space the control daemon confirms is EMPTY; anything unreachable or non-empty
+// is left untouched (a stats error is a skip, never a reap). Returns counts.
+func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) {
+ cutoff := now.Add(-s.reapAfter).Unix()
+ cands, err := s.db.spacesOlderThan(cutoff)
+ if err != nil {
+ log.Printf("cloud: reaper: ledger query: %v", err)
+ return 0, 0
+ }
+ for _, c := range cands {
+ members, err := s.controlStats(c.Host)
+ if err != nil {
+ log.Printf("cloud: reaper: skip %s (stats unavailable): %v", c.Host, err)
+ continue
+ }
+ if members > 0 {
+ kept++
+ continue // claimed / in use — never reap
+ }
+ created := time.Unix(c.CreatedAt, 0).UTC().Format(time.RFC3339)
+ if s.reapDryRun {
+ log.Printf("cloud: reaper: WOULD reclaim %s (id %d, 0 members, created %s)", c.Host, c.ID, created)
+ continue
+ }
+ status, body, err := s.controlDelete(c.Host)
+ if err != nil {
+ log.Printf("cloud: 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: reaper: delete %s: HTTP %d", c.Host, status)
+ continue
+ }
+ if err := s.db.deleteSpaceByID(c.ID); err != nil {
+ log.Printf("cloud: reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err)
+ }
+ log.Printf("cloud: reaper: reclaimed never-claimed space %s (id %d, created %s)", c.Host, c.ID, created)
+ reaped++
+ }
+ if reaped > 0 || kept > 0 {
+ log.Printf("cloud: reaper: swept %d candidate(s) — %d reclaimed, %d still in use", len(cands), reaped, kept)
+ }
+ return reaped, kept
+}
diff --git a/reaper_test.go b/reaper_test.go
new file mode 100644
index 0000000..bc4abf7
--- /dev/null
+++ b/reaper_test.go
@@ -0,0 +1,284 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// ---- trash purge --------------------------------------------------------------
+
+func TestTrashDirTime(t *testing.T) {
+ // The "<host>-<unix>" suffix wins when present.
+ got := trashDirTime("cnz.11ch.at-1700000000", nil)
+ if want := time.Unix(1700000000, 0); !got.Equal(want) {
+ t.Errorf("suffix parse = %v, want %v", got, want)
+ }
+ // No parseable suffix → mtime fallback (a zero time when info is nil).
+ if !trashDirTime("weird-name-no-unix-xyz", nil).IsZero() {
+ t.Error("unparseable name with nil info should be zero time")
+ }
+}
+
+func TestPurgeTrash(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Unix(1_800_000_000, 0)
+ mk := func(name string) { //nolint
+ if err := os.Mkdir(filepath.Join(dir, name), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ old := now.Add(-40 * 24 * time.Hour).Unix()
+ fresh := now.Add(-2 * 24 * time.Hour).Unix()
+ mk("old.11ch.at-" + itoa(old)) // 40 days → purged
+ mk("fresh.11ch.at-" + itoa(fresh)) // 2 days → kept
+ // a stray file (not a dir) must be ignored
+ if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ removed, err := purgeTrash(dir, 30*24*time.Hour, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if removed != 1 {
+ t.Errorf("removed = %d, want 1", removed)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "old.11ch.at-"+itoa(old))); !os.IsNotExist(err) {
+ t.Error("old trash should be gone")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "fresh.11ch.at-"+itoa(fresh))); err != nil {
+ t.Error("fresh trash should remain")
+ }
+
+ // A missing trash dir is fine (nothing to purge).
+ if n, err := purgeTrash(filepath.Join(dir, "does-not-exist"), time.Hour, now); err != nil || n != 0 {
+ t.Errorf("missing dir: n=%d err=%v", n, err)
+ }
+}
+
+// ---- ledger query -------------------------------------------------------------
+
+func TestSpacesOlderThan(t *testing.T) {
+ db := openTestCloudDB(t)
+ insertSpaceRow(t, db, "old.11ch.at", 1000)
+ insertSpaceRow(t, db, "mid.11ch.at", 2000)
+ insertSpaceRow(t, db, "new.11ch.at", 5000)
+ // a pending create (NULL host) is never a candidate
+ if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := db.spacesOlderThan(2000)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("candidates = %d, want 2 (old, mid)", len(got))
+ }
+ if got[0].Host != "old.11ch.at" || got[1].Host != "mid.11ch.at" {
+ t.Errorf("order = %s, %s (want old, mid, oldest first)", got[0].Host, got[1].Host)
+ }
+}
+
+// ---- the reaper ---------------------------------------------------------------
+
+func TestReapOnceReclaimsOnlyOldEmpties(t *testing.T) {
+ db := openTestCloudDB(t)
+ now := time.Unix(2_000_000_000, 0)
+ old := now.Add(-20 * 24 * time.Hour).Unix()
+ recent := now.Add(-1 * 24 * time.Hour).Unix()
+ emptyID := insertSpaceRow(t, db, "empty.11ch.at", old) // old + 0 members → reaped
+ claimedID := insertSpaceRow(t, db, "used.11ch.at", old) // old + members → kept
+ insertSpaceRow(t, db, "young.11ch.at", recent) // too new → not a candidate
+
+ deleted := map[string]bool{}
+ sock := reapControl(t, map[string]int{"empty.11ch.at": 0, "used.11ch.at": 4}, deleted, nil)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
+
+ reaped, kept := s.reapOnce(now)
+ if reaped != 1 || kept != 1 {
+ t.Fatalf("reaped=%d kept=%d, want 1/1", reaped, kept)
+ }
+ if !deleted["empty.11ch.at"] {
+ t.Error("the old empty space should have been deleted")
+ }
+ if deleted["used.11ch.at"] {
+ t.Error("a space with members must NEVER be reaped")
+ }
+ // ledger: empty gone, used + young remain
+ if _, ok, _ := db.spaceOwnedByID(t, emptyID); ok {
+ t.Error("reaped space should be cleared from the ledger")
+ }
+ if _, ok, _ := db.spaceOwnedByID(t, claimedID); !ok {
+ t.Error("kept space should remain in the ledger")
+ }
+}
+
+func TestReapOnceDryRunDeletesNothing(t *testing.T) {
+ db := openTestCloudDB(t)
+ now := time.Unix(2_000_000_000, 0)
+ old := now.Add(-20 * 24 * time.Hour).Unix()
+ insertSpaceRow(t, db, "empty.11ch.at", old)
+
+ deleted := map[string]bool{}
+ sock := reapControl(t, map[string]int{"empty.11ch.at": 0}, deleted, nil)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapDryRun: true}
+
+ reaped, _ := s.reapOnce(now)
+ if reaped != 0 {
+ t.Errorf("dry-run reaped = %d, want 0", reaped)
+ }
+ if len(deleted) != 0 {
+ t.Errorf("dry-run must not delete anything, got %v", deleted)
+ }
+}
+
+func TestReapOnceSkipsWhenStatsUnavailable(t *testing.T) {
+ db := openTestCloudDB(t)
+ now := time.Unix(2_000_000_000, 0)
+ old := now.Add(-20 * 24 * time.Hour).Unix()
+ insertSpaceRow(t, db, "flaky.11ch.at", old)
+
+ deleted := map[string]bool{}
+ // stats endpoint errors for this host → the reaper must skip, never delete.
+ sock := reapControl(t, map[string]int{}, deleted, map[string]bool{"flaky.11ch.at": true})
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
+
+ reaped, kept := s.reapOnce(now)
+ if reaped != 0 || kept != 0 {
+ t.Errorf("reaped=%d kept=%d, want 0/0 (skipped)", reaped, kept)
+ }
+ if deleted["flaky.11ch.at"] {
+ t.Error("a space whose emptiness is unknown must NOT be reaped")
+ }
+}
+
+// ---- instanceMembers + the controld stats endpoint ----------------------------
+
+func TestInstanceMembersAndStatsHandler(t *testing.T) {
+ dir := t.TempDir()
+ prev := instanceDataDir
+ instanceDataDir = dir
+ defer func() { instanceDataDir = prev }()
+
+ // A live instance DB with two members.
+ db, err := openDB(filepath.Join(dir, "used.11ch.at.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.sql.Close()
+ if _, err := db.sql.Exec(`INSERT INTO users(id, created_at) VALUES('u1',1),('u2',2)`); err != nil {
+ t.Fatal(err)
+ }
+ // And an empty (never-claimed) one.
+ empty, err := openDB(filepath.Join(dir, "empty.11ch.at.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer empty.sql.Close()
+
+ if n, err := instanceMembers("used.11ch.at"); err != nil || n != 2 {
+ t.Errorf("instanceMembers(used) = %d, %v; want 2", n, err)
+ }
+ if n, err := instanceMembers("empty.11ch.at"); err != nil || n != 0 {
+ t.Errorf("instanceMembers(empty) = %d, %v; want 0", n, err)
+ }
+ if _, err := instanceMembers("missing.11ch.at"); !os.IsNotExist(err) {
+ t.Errorf("missing instance should be not-exist, got %v", err)
+ }
+
+ // The stats endpoint, routed + auth-gated, reports the same.
+ c := &controld{tokenHash: hashToken("secret")}
+ srv := httptest.NewServer(c.handler())
+ defer srv.Close()
+ req, _ := http.NewRequest("GET", srv.URL+"/instances/used.11ch.at/stats", nil)
+ req.Header.Set("Authorization", "Bearer secret")
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("stats HTTP %d", resp.StatusCode)
+ }
+ var body struct {
+ Members int `json:"members"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&body)
+ if body.Members != 2 {
+ t.Errorf("stats members = %d, want 2", body.Members)
+ }
+
+ // No token → 401.
+ req2, _ := http.NewRequest("GET", srv.URL+"/instances/used.11ch.at/stats", nil)
+ resp2, _ := srv.Client().Do(req2)
+ if resp2.StatusCode != 401 {
+ t.Errorf("unauth stats = %d, want 401", resp2.StatusCode)
+ }
+ resp2.Body.Close()
+}
+
+// ---- helpers ------------------------------------------------------------------
+
+func openTestCloudDB(t *testing.T) *cloudDB {
+ t.Helper()
+ db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func insertSpaceRow(t *testing.T, db *cloudDB, host string, createdAt int64) int64 {
+ t.Helper()
+ res, err := db.sql.Exec(
+ `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(?, 'c', 'r', ?)`, host, createdAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+// spaceOwnedByID reports whether a ledger row still exists (test-only lookup).
+func (db *cloudDB) spaceOwnedByID(t *testing.T, id int64) (string, bool, error) {
+ t.Helper()
+ var host string
+ err := db.sql.QueryRow(`SELECT COALESCE(host,'') FROM spaces WHERE id = ?`, id).Scan(&host)
+ if err != nil {
+ return "", false, nil
+ }
+ return host, true, nil
+}
+
+// reapControl serves a stand-in control daemon (on a socket, via the existing
+// fakeControl helper) answering the reaper's stats + delete calls: GET stats
+// returns a member count (or 500 when statsErr[host]); DELETE records the host.
+func reapControl(t *testing.T, members map[string]int, deleted, statsErr map[string]bool) string {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ h := r.PathValue("host")
+ if statsErr[h] {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"members": members[h]})
+ })
+ mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) {
+ deleted[r.PathValue("host")] = true
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ })
+ return fakeControl(t, mux.ServeHTTP)
+}
+
+func itoa(n int64) string { return strconv.FormatInt(n, 10) }