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>
7 files changed,
+595
−11
CLAUDE.md+7 −5cloud.go+11 −3cloud_db.go+29 −0controld.go+27 −0docs/cloud-provisioner.md+31 −3reaper.go+206 −0reaper_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) } |