eleven / ocho Public

cloud: reaper's "never claimed" signal is setup_complete, not member count (#442)

End-to-end verification on the box exposed a wrong premise in #434: a
freshly-created, NEVER-claimed space has ONE member, not zero. add-instance
pre-seeds the admin user so the setup invite has an identity to ship, and
claiming BECOMES that same user — so member count is 1 whether or not the space
was ever set up. The old `members == 0` check therefore never fired: the reaper
was armed but would never reclaim anything (safe, but non-functional).

Fix: the signal is `setup_complete` (absent until the admin finishes the setup
conversation, then "1"). instanceStatus now reads both setup_complete and the
member count; GET /instances/{host}/stats returns both; the reaper reclaims a
candidate only if it NEVER completed setup AND members <= 1 (nobody beyond the
seeded admin joined) — a belt-and-suspenders guard. Set-up / in-use / unreadable
spaces are still always skipped.

Verified against real box data: never-claimed space has setup_complete unset +
an unused setup invite; claimed spaces have setup_complete=1 + the invite
consumed.

Tests updated to the setup_complete signal (both cases now have 1 member; the
flag is the discriminator); instanceStatus + stats endpoint covered.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell 80020f17f61f1ceb1c8bbd3e29dee49e56d10037 parent 261d8c2
4 files changed, +108 −75
  • controld.go +6 −5
  • docs/cloud-provisioner.md +8 −3
  • reaper.go +47 −32
  • reaper_test.go +47 −35
diff --git a/controld.go b/controld.go
index 17b8388..f4d812d 100644
--- a/controld.go
+++ b/controld.go
@@ -89,16 +89,17 @@ func (c *controld) handler() http.Handler {
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.
+// statsHandler reports whether a space was ever set up (setup_complete) plus its
+// member count — the reaper's "never claimed" signal. 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)
+ members, setupComplete, err := instanceStatus(host)
if err != nil {
if os.IsNotExist(err) {
writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
@@ -108,7 +109,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) {
writeControlErr(w, http.StatusInternalServerError, "stats_failed", err.Error())
return
}
- writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": n})
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": members, "setup_complete": setupComplete})
}
// deleteHandler deprovisions one instance (self-serve space deletion, reached
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index a028d1f..ef43a07 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -206,10 +206,15 @@ would otherwise sit there forever: empty, but holding a slot and a disk. The
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
+ which reads the instance DB **read-only** and returns `setup_complete` + member
+ count. The "never claimed" signal is **`setup_complete`, not member count**:
+ `add-instance` pre-seeds the admin user so the setup invite has an identity, and
+ claiming *becomes* that user — so a space has exactly one member whether or not
+ it was ever set up. A candidate is reaped only if it **never completed setup**
+ (`setup_complete` absent) AND no one beyond the seeded admin joined
+ (`members <= 1`). **Anything set up, in use, or whose status 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
+- A reaped 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
diff --git a/reaper.go b/reaper.go
index 27b9d31..3a246eb 100644
--- a/reaper.go
+++ b/reaper.go
@@ -18,41 +18,52 @@ import (
//
// 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.
+// expires after 7 days — would otherwise sit there forever, holding a slot and a
+// disk. The reaper sweeps the cloud ledger for spaces older than a threshold,
+// asks the control daemon whether each was ever set up (see instanceStatus — the
+// signal is `setup_complete`, NOT member count), and deprovisions the ones that
+// never were. 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) ---------------------------------
+// ---- "never claimed" signal (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) {
+// instanceStatus reads a space's claimed-ness from its DB, READ-ONLY (safe
+// against the live instance — no migrations, no writes).
+//
+// "Never claimed" is NOT "zero members": add-instance pre-seeds the admin user
+// so the setup invite has an identity to ship, and claiming BECOMES that same
+// user — so a space has exactly one member whether or not anyone ever set it up.
+// The real signal is `setup_complete`: absent until the admin finishes the setup
+// conversation (markSetupComplete), then "1". members is returned too, as a
+// belt-and-suspenders guard (don't reap if anyone beyond the seeded admin joined).
+func instanceStatus(host string) (members int, setupComplete bool, err 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
+ return 0, false, 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
+ return 0, false, 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
+ if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&members); err != nil {
+ return 0, false, err
+ }
+ var v string
+ switch err := conn.QueryRowContext(ctx, "SELECT value FROM config WHERE key='setup_complete'").Scan(&v); err {
+ case nil, sql.ErrNoRows: // absent = never set up
+ default:
+ return 0, false, err
}
- return n, nil
+ return members, v == "1", nil
}
// ---- trash purge (controld-side, root) ----------------------------------------
@@ -118,28 +129,30 @@ func runTrashPurge(keep time.Duration) {
// ---- the reaper (cloud-side) ---------------------------------------------------
-// controlStats asks the control daemon how many members a space has.
-func (s *cloudServer) controlStats(host string) (int, error) {
+// controlStatus asks the control daemon whether a space was ever set up (and its
+// member count, as a guard).
+func (s *cloudServer) controlStatus(host string) (members int, setupComplete bool, err error) {
req, err := http.NewRequest("GET", "http://control/instances/"+host+"/stats", nil)
if err != nil {
- return 0, err
+ return 0, false, err
}
req.Header.Set("Authorization", "Bearer "+s.controlToken)
resp, err := s.control.Do(req)
if err != nil {
- return 0, err
+ return 0, false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return 0, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
+ return 0, false, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
}
var body struct {
- Members int `json:"members"`
+ Members int `json:"members"`
+ SetupComplete bool `json:"setup_complete"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
- return 0, err
+ return 0, false, err
}
- return body.Members, nil
+ return body.Members, body.SetupComplete, nil
}
// reapLoop sweeps every interval; enabled only when reapAfter>0.
@@ -159,8 +172,10 @@ func (s *cloudServer) reapLoop(interval time.Duration) {
}
// 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.
+// space the control daemon confirms was NEVER set up (setup_complete absent) AND
+// that nobody beyond the pre-seeded admin joined (members <= 1); anything set up,
+// in use, or whose status can't be read 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)
@@ -169,18 +184,18 @@ func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) {
return 0, 0
}
for _, c := range cands {
- members, err := s.controlStats(c.Host)
+ members, setupComplete, err := s.controlStatus(c.Host)
if err != nil {
log.Printf("cloud: reaper: skip %s (stats unavailable): %v", c.Host, err)
continue
}
- if members > 0 {
+ if setupComplete || members > 1 {
kept++
- continue // claimed / in use — never reap
+ continue // set up, or someone else joined — 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)
+ log.Printf("cloud: reaper: WOULD reclaim %s (id %d, never set up, created %s)", c.Host, c.ID, created)
continue
}
status, body, err := s.controlDelete(c.Host)
diff --git a/reaper_test.go b/reaper_test.go
index bc4abf7..6a41852 100644
--- a/reaper_test.go
+++ b/reaper_test.go
@@ -93,26 +93,30 @@ func TestReapOnceReclaimsOnlyOldEmpties(t *testing.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
+ // Both have the 1 pre-seeded admin (realistic); setup_complete is the signal.
+ unclaimedID := insertSpaceRow(t, db, "unclaimed.11ch.at", old) // old + never set up → reaped
+ claimedID := insertSpaceRow(t, db, "used.11ch.at", old) // old + set up → 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)
+ sock := reapControl(t,
+ map[string]int{"unclaimed.11ch.at": 1, "used.11ch.at": 1},
+ map[string]bool{"used.11ch.at": true}, // only used.* completed setup
+ 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["unclaimed.11ch.at"] {
+ t.Error("the old never-set-up space should have been deleted")
}
if deleted["used.11ch.at"] {
- t.Error("a space with members must NEVER be reaped")
+ t.Error("a space that completed setup must NEVER be reaped")
}
- // ledger: empty gone, used + young remain
- if _, ok, _ := db.spaceOwnedByID(t, emptyID); ok {
+ // ledger: unclaimed gone, used + young remain
+ if _, ok, _ := db.spaceOwnedByID(t, unclaimedID); ok {
t.Error("reaped space should be cleared from the ledger")
}
if _, ok, _ := db.spaceOwnedByID(t, claimedID); !ok {
@@ -124,10 +128,10 @@ 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)
+ insertSpaceRow(t, db, "unclaimed.11ch.at", old)
deleted := map[string]bool{}
- sock := reapControl(t, map[string]int{"empty.11ch.at": 0}, deleted, nil)
+ sock := reapControl(t, map[string]int{"unclaimed.11ch.at": 1}, nil, deleted, nil)
s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapDryRun: true}
reaped, _ := s.reapOnce(now)
@@ -147,7 +151,7 @@ func TestReapOnceSkipsWhenStatsUnavailable(t *testing.T) {
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})
+ sock := reapControl(t, map[string]int{}, nil, 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)
@@ -159,45 +163,51 @@ func TestReapOnceSkipsWhenStatsUnavailable(t *testing.T) {
}
}
-// ---- instanceMembers + the controld stats endpoint ----------------------------
+// ---- instanceStatus + the controld stats endpoint -----------------------------
-func TestInstanceMembersAndStatsHandler(t *testing.T) {
+func TestInstanceStatusAndStatsHandler(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"))
+ // A set-up space: the pre-seeded admin + setup_complete='1'.
+ used, 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 {
+ defer used.sql.Close()
+ if _, err := used.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
t.Fatal(err)
}
- // And an empty (never-claimed) one.
- empty, err := openDB(filepath.Join(dir, "empty.11ch.at.db"))
+ if err := used.setSetupComplete(true); err != nil {
+ t.Fatal(err)
+ }
+ // A never-set-up space: the pre-seeded admin, no setup_complete row.
+ fresh, err := openDB(filepath.Join(dir, "fresh.11ch.at.db"))
if err != nil {
t.Fatal(err)
}
- defer empty.sql.Close()
+ defer fresh.sql.Close()
+ if _, err := fresh.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
+ t.Fatal(err)
+ }
- if n, err := instanceMembers("used.11ch.at"); err != nil || n != 2 {
- t.Errorf("instanceMembers(used) = %d, %v; want 2", n, err)
+ if m, sc, err := instanceStatus("used.11ch.at"); err != nil || m != 1 || !sc {
+ t.Errorf("instanceStatus(used) = (%d, %v, %v); want (1, true, nil)", m, sc, err)
}
- if n, err := instanceMembers("empty.11ch.at"); err != nil || n != 0 {
- t.Errorf("instanceMembers(empty) = %d, %v; want 0", n, err)
+ if m, sc, err := instanceStatus("fresh.11ch.at"); err != nil || m != 1 || sc {
+ t.Errorf("instanceStatus(fresh) = (%d, %v, %v); want (1, false, nil)", m, sc, err)
}
- if _, err := instanceMembers("missing.11ch.at"); !os.IsNotExist(err) {
+ if _, _, err := instanceStatus("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.
+ // The stats endpoint, routed + auth-gated, reports setup_complete.
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, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil)
req.Header.Set("Authorization", "Bearer secret")
resp, err := srv.Client().Do(req)
if err != nil {
@@ -208,15 +218,16 @@ func TestInstanceMembersAndStatsHandler(t *testing.T) {
t.Fatalf("stats HTTP %d", resp.StatusCode)
}
var body struct {
- Members int `json:"members"`
+ Members int `json:"members"`
+ SetupComplete bool `json:"setup_complete"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
- if body.Members != 2 {
- t.Errorf("stats members = %d, want 2", body.Members)
+ if body.Members != 1 || body.SetupComplete {
+ t.Errorf("stats = %+v, want members 1, setup_complete false", body)
}
// No token → 401.
- req2, _ := http.NewRequest("GET", srv.URL+"/instances/used.11ch.at/stats", nil)
+ req2, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil)
resp2, _ := srv.Client().Do(req2)
if resp2.StatusCode != 401 {
t.Errorf("unauth stats = %d, want 401", resp2.StatusCode)
@@ -262,8 +273,9 @@ func (db *cloudDB) spaceOwnedByID(t *testing.T, id int64) (string, bool, error)
// 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 {
+// returns member count + setup_complete (or 500 when statsErr[host]); DELETE
+// records the host.
+func reapControl(t *testing.T, members map[string]int, setup, deleted, statsErr map[string]bool) string {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
@@ -272,7 +284,7 @@ func reapControl(t *testing.T, members map[string]int, deleted, statsErr map[str
w.WriteHeader(http.StatusInternalServerError)
return
}
- writeJSON(w, http.StatusOK, map[string]any{"members": members[h]})
+ writeJSON(w, http.StatusOK, map[string]any{"members": members[h], "setup_complete": setup[h]})
})
mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) {
deleted[r.PathValue("host")] = true