eleven / ocho Public

control: lchat reset <host> — the canary-gated in-place wipe (the devbox capstone) (#1031)

Turns #989's canary flag into the thing the whole reset saga pointed at:
a first-class, safe way to wipe a THROWAWAY's DB for a fresh first-run,
which operators can no longer do by hand (sudo-rs hardening forbids
rm/cp/mv in /data and sudo -u lchat).

Safety model = the canary gate: reset refuses any instance not flagged
Route.Canary (canReset), so a real member instance can never be wiped
whatever its hostname; plus --yes to confirm a destructive act. Mechanics
reuse the established patterns: stop the unit, archive the DB (+ blobs) to
/data/trash (recoverable, env/config kept), restart — the instance process
(User=lchat) recreates an empty DB with correct ownership on boot, which is
exactly why this must be a privileged-binary command and not an operator rm
— then mint the fresh onboarding link. Guide updated; gate + archive tested.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 02796fe04edc1573aa3d456c125b598c64574d8f parent 2359667
4 files changed, +168 −0
  • control.go +103 −0
  • control_test.go +57 −0
  • docs/planet-scale-guide/operations.html +5 −0
  • main.go +3 −0
diff --git a/control.go b/control.go
index 654598c..ea6d658 100644
--- a/control.go
+++ b/control.go
@@ -593,6 +593,109 @@ func runRemove(args []string) {
}
}
+
+// canReset reports why host may not be reset, or nil if it may. The canary gate
+// IS the safety model: reset wipes a live DB, so it refuses anything not
+// explicitly flagged throwaway (registry.go: Route.Canary) — a real member
+// instance can never be wiped by this command, whatever its hostname.
+func canReset(rt *Route) error {
+ if rt == nil {
+ return fmt.Errorf("unknown host")
+ }
+ if rt.Kind != "instance" {
+ return fmt.Errorf("%q is a %s route, not an instance", rt.Host, rt.Kind)
+ }
+ if !rt.Canary {
+ return fmt.Errorf("%q is not flagged as a canary — refusing to wipe a real instance's data. "+
+ "If it is genuinely a throwaway: lchat canary %s --on", rt.Host, rt.Host)
+ }
+ return nil
+}
+
+// archiveInstanceDB moves host's database (and blob dir) into /data/trash,
+// keeping its env/vapid so the admin token + config survive — the wipe half of
+// `lchat reset`. Recoverable, like remove/reaper (never a destroy).
+func archiveInstanceDB(host string) error {
+ trash := filepath.Join(instanceDataDir, "trash", fmt.Sprintf("%s-reset-%d", host, time.Now().Unix()))
+ if err := os.MkdirAll(trash, 0o700); err != nil {
+ return fmt.Errorf("trash dir: %v", err)
+ }
+ names := []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".db" + litestream.MetaDirSuffix, host + "-blobs"}
+ for _, name := range names {
+ src := filepath.Join(instanceDataDir, name)
+ if _, err := os.Stat(src); err == nil {
+ if err := os.Rename(src, filepath.Join(trash, name)); err != nil {
+ return fmt.Errorf("archive %s: %v", name, err)
+ }
+ }
+ }
+ return nil
+}
+
+// runReset: `lchat reset <host>` — wipe a CANARY's database in place for a fresh
+// first-run, the operator-clean answer to "reset this throwaway" (the in-place
+// `rm` an operator can't do under the hardening). Stop the unit, archive its DB
+// to trash, restart — the instance process (User=lchat) recreates an empty DB
+// with correct ownership on boot — then mint the fresh onboarding link.
+func runReset(args []string) {
+ fs := flag.NewFlagSet("reset", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ yes := fs.Bool("yes", false, "confirm the destructive wipe (required — reset deletes the DB)")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("reset: invalid host %q", host)
+ }
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("reset: open registry: %v", err)
+ }
+ rt, err := reg.get(host)
+ if err != nil {
+ die("reset: %v", err)
+ }
+ if err := canReset(rt); err != nil {
+ die("reset: %v", err)
+ }
+ if !*yes {
+ die("reset: this DELETES %s's database (archived to /data/trash; env/config kept). Re-run with --yes to confirm.", host)
+ }
+
+ if err := systemctl("stop", *unitTmpl+host); err != nil {
+ die("reset: stop %s: %v", host, err)
+ }
+ if err := archiveInstanceDB(host); err != nil {
+ _ = systemctl("start", *unitTmpl+host) // never leave it stopped after a failed wipe
+ die("reset: %v", err)
+ }
+ if err := systemctl("start", *unitTmpl+host); err != nil {
+ die("reset: start %s: %v", host, err)
+ }
+
+ // The instance is booting a fresh DB; mint the onboarding link once it answers.
+ env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
+ if err != nil {
+ die("reset: %v (instance restarted fresh; mint later: lchat setup-invite %s)", err, host)
+ }
+ socket := filepath.Join(instanceSocketDir, host+".sock")
+ var link string
+ for i := 0; i < 20; i++ {
+ if link, err = mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, env["LCHAT_ADMIN_TOKEN"]); err == nil {
+ break
+ }
+ time.Sleep(300 * time.Millisecond)
+ }
+ if err != nil {
+ fmt.Printf("reset %s — DB wiped (archived to /data/trash), instance restarted fresh.\n"+
+ " (setup link not ready yet: %v — mint it: lchat setup-invite %s)\n", host, err, host)
+ return
+ }
+ fmt.Printf("reset %s — DB wiped (archived to /data/trash), instance restarted fresh.\n"+
+ " onboarding link (one-time, makes you the admin):\n %s\n", host, link)
+}
+
// runInstances: `lchat instances` — the route/status table.
func runInstances(args []string) {
fs := flag.NewFlagSet("instances", flag.ExitOnError)
diff --git a/control_test.go b/control_test.go
index 9c94193..dcc1433 100644
--- a/control_test.go
+++ b/control_test.go
@@ -1018,3 +1018,60 @@ INSERT INTO routes (host, kind, socket, hibernate, created_at) VALUES ('old.exam
t.Fatalf("setCanary unknown host should no-op: %v", err)
}
}
+
+func TestCanResetGatesOnTheCanaryFlag(t *testing.T) {
+ // The whole safety model: reset refuses anything not flagged a throwaway.
+ if err := canReset(nil); err == nil {
+ t.Error("canReset(nil) should refuse an unknown host")
+ }
+ if err := canReset(&Route{Host: "push.example.com", Kind: "service", Canary: true}); err == nil {
+ t.Error("canReset should refuse a non-instance route even if flagged")
+ }
+ real := &Route{Host: "family.example.com", Kind: "instance", Canary: false}
+ if err := canReset(real); err == nil {
+ t.Fatal("canReset MUST refuse a real (unflagged) instance — this is the data-safety gate")
+ }
+ canary := &Route{Host: "test-x.11ch.at", Kind: "instance", Canary: true}
+ if err := canReset(canary); err != nil {
+ t.Fatalf("canReset should allow a flagged canary instance, got: %v", err)
+ }
+}
+
+func TestArchiveInstanceDBMovesDBKeepsEnv(t *testing.T) {
+ dir := t.TempDir()
+ oldData := instanceDataDir
+ instanceDataDir = dir
+ defer func() { instanceDataDir = oldData }()
+
+ host := "test-x.11ch.at"
+ // Seed the DB set + a blob dir + the env/vapid that must SURVIVE.
+ for _, f := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".env", host + ".vapid"} {
+ if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.MkdirAll(filepath.Join(dir, host+"-blobs"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := archiveInstanceDB(host); err != nil {
+ t.Fatalf("archiveInstanceDB: %v", err)
+ }
+
+ // DB + blobs gone from /data; env + vapid kept in place.
+ for _, gone := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + "-blobs"} {
+ if _, err := os.Stat(filepath.Join(dir, gone)); !os.IsNotExist(err) {
+ t.Errorf("%s should have been archived out of the data dir", gone)
+ }
+ }
+ for _, kept := range []string{host + ".env", host + ".vapid"} {
+ if _, err := os.Stat(filepath.Join(dir, kept)); err != nil {
+ t.Errorf("%s must survive a reset (admin token + config): %v", kept, err)
+ }
+ }
+ // And the DB is recoverable under /data/trash, not destroyed.
+ trashes, _ := filepath.Glob(filepath.Join(dir, "trash", host+"-reset-*", host+".db"))
+ if len(trashes) != 1 {
+ t.Errorf("archived DB should be recoverable in /data/trash, found %d", len(trashes))
+ }
+}
diff --git a/docs/planet-scale-guide/operations.html b/docs/planet-scale-guide/operations.html
index 2b93fe8..cc6c061 100644
--- a/docs/planet-scale-guide/operations.html
+++ b/docs/planet-scale-guide/operations.html
@@ -36,6 +36,11 @@ lchat instances # STATUS=failed rows need their last error
lchat update status # AUTO=pinned rows are someone&rsquo;s live experiment — leave them be
# (a throwaway test host is also flagged: lchat add-instance --canary, or lchat canary &lt;host&gt; --on; shows as CANARY in lchat instances)
+# Reset a canary to a fresh first-run (the operator-clean wipe — no `rm` in /data):
+lchat reset &lt;host&gt; --yes # CANARY-ONLY: refuses any instance not flagged (the data-safety gate).
+ # Stops the unit, archives its DB to /data/trash (recoverable; env/config kept),
+ # restarts fresh, and prints the onboarding link. A real instance can never be reset.
+
# 4. Is disk comfortable? (disk, not memory, is the binding resource on these boxes)
df -h /data
diff --git a/main.go b/main.go
index 84a1841..46def25 100644
--- a/main.go
+++ b/main.go
@@ -191,6 +191,9 @@ func main() {
case "canary":
runCanary(os.Args[2:])
return
+ case "reset":
+ runReset(os.Args[2:])
+ return
case "replication":
runReplication(os.Args[2:])
return