ops: FleetDBs discovers unit-tenant databases via DBPath, cli requires -db for hibernating unit routes — replication was asserted but not implemented
FleetDBs only globbed dataDir/*.db, but a unit tenant's data lives at its systemd StateDirectory (/var/lib/carlos-app/<host>), never under dataDir — so the box-wide litestream unit silently never replicated any unit-backed tenant despite comments claiming otherwise. No single filename convention can be assumed inside a unit tenant's state dir (the app picks it), so FleetDBs now includes a BackingUnit route's DBPath only when the operator set one via `carlos add -db`, and `-backing unit -hibernate` now requires -db (the case where going unreplicated is a real data-loss risk, not a missed nicety). Non-hibernating unit routes stay unenforced by design. Comments in litestream.go, cli.go, and backend_unit.go now state exactly what is covered and what the operator must hand-configure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+238
−22
cli.go+18 −2e2e_test.go+74 −0internal/activator/backend_unit.go+15 −4internal/ops/litestream.go+57 −11internal/ops/ops_test.go+74 −5
diff --git a/cli.go b/cli.go| index 2a43023ef..02511f882 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -80,11 +80,27 @@ func runAdd(args []string) { |
| } |
| } |
| // No DB default for unit tenants: their data lives in the unit's |
| - // StateDirectory (/var/lib/carlos-app/<host>), replicated by the box |
| - // litestream unit — -db stays an explicit operator choice there. |
| + // StateDirectory (/var/lib/carlos-app/<host>), under a filename the APP |
| + // chooses (no platform-wide convention the way dataDir/<host>.db is for |
| + // exec children) — so there is no honest path to default to. -db stays |
| + // an explicit operator choice there, and ops.FleetDBs (the box-wide |
| + // litestream unit's config generator) only learns a unit tenant's |
| + // database path from this flag; leave it unset and that tenant's data |
| + // is replicated by nobody. |
| if *dbPath == "" && *kind == "instance" && *backing != registry.BackingUnit { |
| *dbPath = filepath.Join(dataDir(), host+".db") |
| } |
| + // A hibernating unit tenant stops and starts with systemd, not the |
| + // agent — box-wide litestream (root) is the ONLY thing that can |
| + // replicate it, so skipping -db here is a genuine data-loss risk |
| + // (not just a missed nicety) the moment the unit is ever stopped for |
| + // idleness. Require it. A non-hibernating unit tenant is always running |
| + // and so has no equivalent forced moment of data being unreachable if |
| + // unreplicated — -db is still strongly recommended there but stays the |
| + // operator's call, unenforced. |
| + if *backing == registry.BackingUnit && *hibernate && *dbPath == "" { |
| + log.Fatalf("add %s: -backing unit -hibernate requires -db <path> — the box-wide litestream unit can't find a unit tenant's database on its own (see ops.FleetDBs)", host) |
| + } |
| r, err := registry.Open(*reg) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/e2e_test.go b/e2e_test.go| index 98e014010..ae3fe3ace 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -451,3 +451,77 @@ func TestStatusCLI(t *testing.T) { |
| t.Error("carlos status with no -dry-run flag: want a non-zero exit, got 0") |
| } |
| } |
| + |
| +// TestAddUnitBackingRequiresDBForHibernate pins the I1 fix's operator-facing |
| +// half: `carlos add -backing unit -hibernate` with no -db must refuse, |
| +// because ops.FleetDBs (the box-wide litestream unit) has no way to |
| +// discover a unit tenant's database path other than that flag, and a |
| +// hibernating unit tenant left unreplicated is a genuine data-loss risk the |
| +// moment systemd stops it for idleness. A non-hibernating unit route stays |
| +// unenforced (still the operator's call), and -db always persists onto the |
| +// route when given. |
| +func TestAddUnitBackingRequiresDBForHibernate(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", out, err) |
| + } |
| + |
| + regPath := filepath.Join(tmp, "registry.db") |
| + |
| + // No -db: refused, non-zero exit, no row written. |
| + out, err := exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| + "-backing", "unit", "-hibernate", "no-db.example.com").CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("add -backing unit -hibernate with no -db: want non-zero exit, got 0\n%s", out) |
| + } |
| + if !strings.Contains(string(out), "-db") { |
| + t.Errorf("add refusal output = %q, want it to mention -db", out) |
| + } |
| + |
| + // With -db: succeeds, and the route's DBPath is exactly what was given |
| + // (ops.FleetDBs reads this verbatim — no default synthesized here). |
| + dbPath := filepath.Join(tmp, "unit-state", "app.db") |
| + out, err = exec.Command(carlosBin, "add", "-registry", regPath, "-app", "kass", |
| + "-backing", "unit", "-hibernate", "-db", dbPath, "with-db.example.com").CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("add -backing unit -hibernate -db %s: %v\n%s", dbPath, err, out) |
| + } |
| + |
| + reg, err := registry.Open(regPath) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatalf("list routes: %v", err) |
| + } |
| + var found bool |
| + for _, rt := range routes { |
| + if rt.Host != "with-db.example.com" { |
| + continue |
| + } |
| + found = true |
| + if rt.DBPath != dbPath { |
| + t.Errorf("with-db.example.com DBPath = %q, want %q", rt.DBPath, dbPath) |
| + } |
| + if rt.Backing != registry.BackingUnit { |
| + t.Errorf("with-db.example.com Backing = %q, want %q", rt.Backing, registry.BackingUnit) |
| + } |
| + } |
| + if !found { |
| + t.Fatal("with-db.example.com route missing after a successful add") |
| + } |
| + // The refused add must not have left a partial row behind. |
| + for _, rt := range routes { |
| + if rt.Host == "no-db.example.com" { |
| + t.Fatal("refused add left a route behind") |
| + } |
| + } |
| +} |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index 79fc48490..daede904d 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -16,10 +16,21 @@ |
| // unit (root), which streams changes continuously whether the tenant is |
| // awake or asleep. That IS the snapshot-on-sleep story here: a stop only |
| // happens after a full idle window, so streaming replication shipped the |
| -// final writes long before the unit goes down. ops.FleetDBs keeps |
| -// unit-backed hibernating routes IN the generated config for the same |
| -// reason — only exec-backed hibernating hosts, whose replication the |
| -// activator's embedded litestream owns under the S3 lease, are excluded. |
| +// final writes long before the unit goes down. |
| +// |
| +// WHAT THIS ACTUALLY REQUIRES from the operator: box-wide litestream |
| +// (ops.FleetDBs) has no platform-wide convention for a unit tenant's |
| +// database filename inside its state dir — that filename is the APP's |
| +// own choice, unlike the dataDir/<host>.db convention exec children |
| +// get for free — so it can only replicate a unit tenant whose route |
| +// was added with an explicit `carlos add -backing unit -db <path>`. |
| +// `-hibernate` unit routes REQUIRE -db (cli.go's runAdd enforces it: |
| +// going unreplicated there is a genuine data-loss risk, the moment the |
| +// unit is ever stopped for idleness, not just a missed nicety). |
| +// Non-hibernating unit routes do NOT enforce it — -db is strongly |
| +// recommended but stays the operator's unchecked call, and a route |
| +// added without it is silently replicated by nobody. See |
| +// ops.FleetDBs's own doc comment for the mechanics. |
| // - There is no lease to trade: the unit exists on exactly one box (its |
| // state dir is that box's disk) and the box replicator never hands |
| // ownership off, so wake/sleep is not a writer handoff. When unit |
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go| index b87830b08..fc9275121 100644 |
| --- a/internal/ops/litestream.go |
| +++ b/internal/ops/litestream.go |
| @@ -81,23 +81,57 @@ func ParseLitestreamDBs(cfg string) []string { |
| return dbs |
| } |
| -// FleetDBs lists the non-empty *.db files under dataDir that this replicator |
| -// owns. Hibernating EXEC-backed routes' databases (matched by DBPath) are |
| -// excluded: the activator's embedded Litestream owns their replication (to |
| -// the instances bucket, under the lease) — a second replicator on the same |
| -// WAL would fight it for checkpointing and corrupt the replica. (Eleven's |
| -// fleet-backup.sh lesson, carried verbatim.) UNIT-backed hibernating routes |
| -// (carlos-app@ tenants) stay in: their wake/sleep is systemctl, the agent |
| -// never replicates them (it can't even read a DynamicUser's state dir), so |
| -// dropping them here would leave their data replicated by nobody. |
| +// FleetDBs lists the non-empty *.db files this box-wide replicator owns: |
| +// everything found by globbing dataDir (the agent's own exec-child |
| +// databases, plus the registry itself), PLUS — separately, not via the glob |
| +// — every BackingUnit route's DBPath when the operator set one. |
| +// |
| +// WHAT IS COVERED: a unit tenant (carlos-app@<host>: DynamicUser, |
| +// StateDirectory=/var/lib/carlos-app/<host>) whose route was added with an |
| +// explicit `-db <path>`, hibernating or not — its wake/sleep is systemctl |
| +// and the agent can't even read a DynamicUser's 0700 state dir, so this |
| +// box-wide replicator (running as root) is the only thing that can stream |
| +// its writes off-box. |
| +// |
| +// WHAT IS NOT COVERED, and why this function does not try to guess it: a |
| +// unit tenant's database filename inside its state dir is the APP's own |
| +// choice — the platform enforces no naming convention there the way it does |
| +// the dataDir/<host>.db convention for exec children — so there is no |
| +// honest default path to assume. A unit route added without -db is |
| +// silently invisible here. cli.go's runAdd requires -db for |
| +// `-backing unit -hibernate` routes for exactly this reason (going |
| +// unreplicated there is a genuine data-loss risk, not just a missed |
| +// nicety); a non-hibernating unit tenant without -db is still the |
| +// operator's to hand-configure. See internal/activator/backend_unit.go's |
| +// package comment for the fuller operator-facing story. |
| +// |
| +// Hibernating EXEC-backed routes' databases (matched by DBPath, found via |
| +// the dataDir glob) are excluded from the results below: the activator's |
| +// embedded Litestream owns their replication (to the instances bucket, |
| +// under the lease) — a second replicator on the same WAL would fight it for |
| +// checkpointing and corrupt the replica. (Eleven's fleet-backup.sh lesson, |
| +// carried verbatim.) That exclusion never applies to BackingUnit routes — |
| +// their replication has no other owner at all, so they are added here |
| +// rather than filtered. |
| func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) { |
| routes, err := reg.List() |
| if err != nil { |
| return nil, err |
| } |
| hibernating := map[string]bool{} |
| + unitPaths := map[string]bool{} |
| for _, r := range routes { |
| - if r.Hibernate && r.Backing != registry.BackingUnit && r.DBPath != "" { |
| + if r.Backing == registry.BackingUnit { |
| + // Unit tenants' data lives outside dataDir (their systemd |
| + // StateDirectory), so the glob below can never find them on its |
| + // own — DBPath, when the operator set it, is the only way this |
| + // replicator learns where they are. |
| + if r.DBPath != "" { |
| + unitPaths[filepath.Clean(r.DBPath)] = true |
| + } |
| + continue |
| + } |
| + if r.Hibernate && r.DBPath != "" { |
| hibernating[filepath.Clean(r.DBPath)] = true |
| } |
| } |
| @@ -106,15 +140,27 @@ func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) { |
| if err != nil { |
| return nil, err |
| } |
| + // Unit-tenant DBPaths are added on top of the glob: they typically live |
| + // entirely outside dataDir, at their StateDirectory path. |
| + for p := range unitPaths { |
| + matches = append(matches, p) |
| + } |
| + |
| + seen := map[string]bool{} |
| var dbs []string |
| for _, p := range matches { |
| + p = filepath.Clean(p) |
| + if seen[p] { |
| + continue // a unit DBPath that happens to also sit under dataDir |
| + } |
| fi, err := os.Stat(p) |
| if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 { |
| continue // gone, odd, or an empty placeholder — nothing to replicate |
| } |
| - if hibernating[filepath.Clean(p)] { |
| + if hibernating[p] { |
| continue |
| } |
| + seen[p] = true |
| dbs = append(dbs, p) |
| } |
| return dbs, nil |
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go| index 18400fde4..7f5fc7739 100644 |
| --- a/internal/ops/ops_test.go |
| +++ b/internal/ops/ops_test.go |
| @@ -22,9 +22,17 @@ func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| t.Fatal(err) |
| } |
| + // A unit tenant's data lives OUTSIDE dataDir entirely (its systemd |
| + // StateDirectory, e.g. /var/lib/carlos-app/<host>) — a separate temp dir |
| + // here stands in for that, deliberately not under dir, so this test |
| + // actually exercises FleetDBs' DBPath-based discovery instead of |
| + // accidentally passing because the file happened to sit where the glob |
| + // already looks. |
| + unitDir := t.TempDir() |
| + |
| awake := filepath.Join(dir, "a.example.com.db") |
| asleep := filepath.Join(dir, "b.example.com.db") |
| - unitTenant := filepath.Join(dir, "c.example.com.db") |
| + unitTenant := filepath.Join(unitDir, "state.db") |
| for _, p := range []string{awake, asleep, unitTenant} { |
| if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { |
| t.Fatal(err) |
| @@ -36,9 +44,10 @@ func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| if err := reg.Upsert(registry.Route{Host: "b.example.com", DBPath: asleep, Hibernate: true}); err != nil { |
| t.Fatal(err) |
| } |
| - // A unit-backed hibernating tenant (carlos-app@): its wake/sleep is |
| - // systemctl and the activator never replicates it, so this replicator |
| - // must keep owning its database. |
| + // A unit-backed hibernating tenant (carlos-app@) with an operator-set |
| + // -db: its wake/sleep is systemctl and the activator never replicates |
| + // it, so this box-wide replicator must own its database — found via |
| + // DBPath, not the dataDir glob (its file isn't under dataDir at all). |
| if err := reg.Upsert(registry.Route{Host: "c.example.com", DBPath: unitTenant, |
| Hibernate: true, Backing: registry.BackingUnit}); err != nil { |
| t.Fatal(err) |
| @@ -56,7 +65,7 @@ func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| t.Fatalf("hibernating route's DB must be excluded (activator owns its replication):\n%s", cfg) |
| } |
| if !strings.Contains(cfg, unitTenant) { |
| - t.Fatalf("unit-backed hibernating route's DB missing (nobody else replicates it):\n%s", cfg) |
| + t.Fatalf("unit-backed hibernating route's DB (outside dataDir) missing (nobody else replicates it):\n%s", cfg) |
| } |
| // The registry itself lives in the data dir and is not anyone's hibernating |
| // instance — it must be replicated. |
| @@ -71,6 +80,66 @@ func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| } |
| } |
| +// TestFleetDBsUnitTenantWithoutDBPathIsInvisible pins the honest half of the |
| +// I1 fix: a BackingUnit route added without -db has no discoverable database |
| +// path (the platform enforces no filename convention inside a unit tenant's |
| +// state dir), so FleetDBs must not guess one — it must simply not appear in |
| +// the generated config. This is the documented gap operators must close by |
| +// hand (or cli.go's -hibernate requirement closes it for them); it must |
| +// never masquerade as covered. |
| +func TestFleetDBsUnitTenantWithoutDBPathIsInvisible(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "no-db.example.com", Backing: registry.BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + dbs, err := FleetDBs(reg, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, p := range dbs { |
| + if strings.Contains(p, "no-db") { |
| + t.Fatalf("FleetDBs invented a path for a unit tenant with no -db: %v", dbs) |
| + } |
| + } |
| +} |
| + |
| +// TestFleetDBsDedupesUnitPathUnderDataDir: a unit tenant's DBPath can, in |
| +// principle, sit inside dataDir (nothing forbids it) — FleetDBs must not |
| +// list it twice just because both the glob and the DBPath pass found it. |
| +func TestFleetDBsDedupesUnitPathUnderDataDir(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + p := filepath.Join(dir, "d.example.com.db") |
| + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "d.example.com", DBPath: p, Backing: registry.BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + dbs, err := FleetDBs(reg, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + count := 0 |
| + for _, got := range dbs { |
| + if got == p { |
| + count++ |
| + } |
| + } |
| + if count != 1 { |
| + t.Fatalf("FleetDBs listed %s %d times, want exactly once: %v", p, count, dbs) |
| + } |
| +} |
| + |
| // The round-trip is the contract that lets restore-verify read the generated |
| // config without a YAML parser: whatever LitestreamConfig writes, |
| // ParseLitestreamDBs must recover exactly. |