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>
4 files changed,
+168
−0
control.go+103 −0control_test.go+57 −0docs/planet-scale-guide/operations.html+5 −0main.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’s live experiment — leave them be |
| # (a throwaway test host is also flagged: lchat add-instance --canary, or lchat canary <host> --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 <host> --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 |