carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package ops
2
3import (
4 "database/sql"
5 "os"
6 "path/filepath"
7 "slices"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/provision"
13 "github.com/carlosframework/platform/internal/registry"
14
15 _ "modernc.org/sqlite"
16)
17
18// TestConfigGenSkipsHibernatingRoutes pins Eleven's two-replicators lesson in
19// executable form: a route with Hibernate=true must never appear in the fleet
20// litestream config, because the activator's embedded Litestream owns that
21// database's replication — a second replicator on the same WAL would fight it
22// for checkpointing.
23func TestConfigGenSkipsHibernatingRoutes(t *testing.T) {
24 dir := t.TempDir()
25 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
26 if err != nil {
27 t.Fatal(err)
28 }
29
30 // A unit tenant's data lives OUTSIDE dataDir entirely (its systemd
31 // StateDirectory, e.g. /var/lib/carlos-app/<host>) — a separate temp dir
32 // here stands in for that, deliberately not under dir, so this test
33 // actually exercises FleetDBs' DBPath-based discovery instead of
34 // accidentally passing because the file happened to sit where the glob
35 // already looks.
36 unitDir := t.TempDir()
37
38 awake := filepath.Join(dir, "a.example.com.db")
39 asleep := filepath.Join(dir, "b.example.com.db")
40 unitTenant := filepath.Join(unitDir, "state.db")
41 for _, p := range []string{awake, asleep, unitTenant} {
42 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
43 t.Fatal(err)
44 }
45 }
46 if err := reg.Upsert(registry.Route{Host: "a.example.com", DBPath: awake}); err != nil {
47 t.Fatal(err)
48 }
49 if err := reg.Upsert(registry.Route{Host: "b.example.com", DBPath: asleep, Hibernate: true}); err != nil {
50 t.Fatal(err)
51 }
52 // A unit-backed hibernating tenant (carlos-app@) with an operator-set
53 // -db: its wake/sleep is systemctl and the activator never replicates
54 // it, so this box-wide replicator must own its database — found via
55 // DBPath, not the dataDir glob (its file isn't under dataDir at all).
56 if err := reg.Upsert(registry.Route{Host: "c.example.com", DBPath: unitTenant,
57 Hibernate: true, Backing: registry.BackingUnit}); err != nil {
58 t.Fatal(err)
59 }
60
61 dbs, err := FleetDBs(reg, dir)
62 if err != nil {
63 t.Fatal(err)
64 }
65 cfg := LitestreamConfig(dbs, "bucket-x", "fleet", "eu-west-1")
66 if !strings.Contains(cfg, awake) {
67 t.Fatalf("awake route's DB missing from config:\n%s", cfg)
68 }
69 if strings.Contains(cfg, asleep) {
70 t.Fatalf("hibernating route's DB must be excluded (activator owns its replication):\n%s", cfg)
71 }
72 if !strings.Contains(cfg, unitTenant) {
73 t.Fatalf("unit-backed hibernating route's DB (outside dataDir) missing (nobody else replicates it):\n%s", cfg)
74 }
75 // The registry itself lives in the data dir and is not anyone's hibernating
76 // instance — it must be replicated.
77 if !strings.Contains(cfg, filepath.Join(dir, "registry.db")) {
78 t.Fatalf("registry.db missing from config:\n%s", cfg)
79 }
80 if !strings.Contains(cfg, "GENERATED") {
81 t.Fatalf("generated-file warning missing:\n%s", cfg)
82 }
83 if !strings.Contains(cfg, "region: eu-west-1") {
84 t.Fatalf("replica region missing:\n%s", cfg)
85 }
86}
87
88// TestFleetDBsDiscoversNestedDatabases pins the isolated-exec companion to
89// the flat glob above (design §7): a route's DBPath being "claimed" only
90// FILTERS what the glob already found. For a non-unit route it never adds
91// a path by itself, so a nested row's database, under
92// <dataDir>/instances/<id>/<host>.db, would stay invisible to replication
93// forever without the nested glob to find it in the first place.
94func TestFleetDBsDiscoversNestedDatabases(t *testing.T) {
95 dir := t.TempDir()
96 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
97 if err != nil {
98 t.Fatal(err)
99 }
100 host := "nested.example.com"
101 nested := provision.NestedDBPath(dir, host)
102 if err := os.MkdirAll(provision.InstanceDir(dir, host), 0o755); err != nil {
103 t.Fatal(err)
104 }
105 if err := os.WriteFile(nested, []byte("x"), 0o644); err != nil {
106 t.Fatal(err)
107 }
108 // Not hibernating: a resident, activator-managed nested row is exactly
109 // as replicable by this box-wide pass as a flat one.
110 if err := reg.Upsert(registry.Route{Host: host, DBPath: nested, Socket: provision.NestedSocket(host)}); err != nil {
111 t.Fatal(err)
112 }
113
114 dbs, err := FleetDBs(reg, dir)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if !slices.Contains(dbs, nested) {
119 t.Fatalf("nested database missing from FleetDBs: %v, want %s", dbs, nested)
120 }
121}
122
123// TestFleetDBsUnitTenantWithoutDBPathIsInvisible pins the honest half of the
124// I1 fix: a BackingUnit route added without -db has no discoverable database
125// path (the platform enforces no filename convention inside a unit tenant's
126// state dir), so FleetDBs must not guess one — it must simply not appear in
127// the generated config. This is the documented gap operators must close by
128// hand (or cli.go's -hibernate requirement closes it for them); it must
129// never masquerade as covered.
130func TestFleetDBsUnitTenantWithoutDBPathIsInvisible(t *testing.T) {
131 dir := t.TempDir()
132 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
133 if err != nil {
134 t.Fatal(err)
135 }
136 if err := reg.Upsert(registry.Route{Host: "no-db.example.com", Backing: registry.BackingUnit}); err != nil {
137 t.Fatal(err)
138 }
139
140 dbs, err := FleetDBs(reg, dir)
141 if err != nil {
142 t.Fatal(err)
143 }
144 for _, p := range dbs {
145 if strings.Contains(p, "no-db") {
146 t.Fatalf("FleetDBs invented a path for a unit tenant with no --db: %v", dbs)
147 }
148 }
149}
150
151// TestFleetDBsDedupesUnitPathUnderDataDir: a unit tenant's DBPath can, in
152// principle, sit inside dataDir (nothing forbids it) — FleetDBs must not
153// list it twice just because both the glob and the DBPath pass found it.
154func TestFleetDBsDedupesUnitPathUnderDataDir(t *testing.T) {
155 dir := t.TempDir()
156 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
157 if err != nil {
158 t.Fatal(err)
159 }
160 p := filepath.Join(dir, "d.example.com.db")
161 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
162 t.Fatal(err)
163 }
164 if err := reg.Upsert(registry.Route{Host: "d.example.com", DBPath: p, Backing: registry.BackingUnit}); err != nil {
165 t.Fatal(err)
166 }
167
168 dbs, err := FleetDBs(reg, dir)
169 if err != nil {
170 t.Fatal(err)
171 }
172 count := 0
173 for _, got := range dbs {
174 if got == p {
175 count++
176 }
177 }
178 if count != 1 {
179 t.Fatalf("FleetDBs listed %s %d times, want exactly once: %v", p, count, dbs)
180 }
181}
182
183// The round-trip is the contract that lets restore-verify read the generated
184// config without a YAML parser: whatever LitestreamConfig writes,
185// ParseLitestreamDBs must recover exactly.
186func TestConfigRoundTrip(t *testing.T) {
187 in := []string{"/data/registry.db", "/data/a.example.com.db", "/data/b.example.com.db"}
188 got := ParseLitestreamDBs(LitestreamConfig(in, "b", "p", "eu-west-1"))
189 if len(got) != len(in) {
190 t.Fatalf("round-trip lost databases: in %d, out %d (%v)", len(in), len(got), got)
191 }
192 want := map[string]bool{}
193 for _, p := range in {
194 want[p] = true
195 }
196 for _, p := range got {
197 if !want[p] {
198 t.Fatalf("round-trip invented path %q", p)
199 }
200 }
201}
202
203func TestParseLitestreamDBsIgnoresNoise(t *testing.T) {
204 got := ParseLitestreamDBs("# comment\ndbs:\n - path: /data/x.db\n replicas:\n - url: s3://b/p/x.db\n region: eu-west-1\n\n")
205 if len(got) != 1 || got[0] != "/data/x.db" {
206 t.Fatalf("ParseLitestreamDBs = %#v, want [/data/x.db]", got)
207 }
208 if got := ParseLitestreamDBs(""); len(got) != 0 {
209 t.Fatalf("ParseLitestreamDBs(\"\") = %#v, want none", got)
210 }
211}
212
213// Re-running the generator over an unchanged fleet must produce a
214// byte-identical file — that is what lets the `litestream` verb report
215// "unchanged" and skip a restart-worthy config write.
216func TestConfigChangeDetection(t *testing.T) {
217 dbs := []string{"/data/b.db", "/data/a.db", "/data/registry.db"}
218 first := LitestreamConfig(dbs, "bucket", "prefix", "eu-west-1")
219 second := LitestreamConfig([]string{"/data/registry.db", "/data/a.db", "/data/b.db"}, "bucket", "prefix", "eu-west-1")
220 if first != second {
221 t.Fatalf("same fleet produced different configs:\n--- first\n%s\n--- second\n%s", first, second)
222 }
223}
224
225func TestCheckRestoredDBRejectsEmptySchema(t *testing.T) {
226 dir := t.TempDir()
227
228 good := filepath.Join(dir, "good.db")
229 db, err := sql.Open("sqlite", good)
230 if err != nil {
231 t.Fatal(err)
232 }
233 if _, err := db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1)"); err != nil {
234 t.Fatal(err)
235 }
236 db.Close()
237 if err := CheckRestoredDB(good); err != nil {
238 t.Fatalf("CheckRestoredDB(good) = %v, want nil", err)
239 }
240
241 // A schema-less database restores "successfully" but would be useless in a
242 // disaster — the check must refuse it.
243 empty := filepath.Join(dir, "empty.db")
244 db, err = sql.Open("sqlite", empty)
245 if err != nil {
246 t.Fatal(err)
247 }
248 if _, err := db.Exec("PRAGMA user_version=1"); err != nil { // force file creation
249 t.Fatal(err)
250 }
251 db.Close()
252 if err := CheckRestoredDB(empty); err == nil {
253 t.Fatal("CheckRestoredDB accepted a database with no tables")
254 }
255
256 // Garbage bytes must fail loudly, not pass as an open-able file.
257 junk := filepath.Join(dir, "junk.db")
258 if err := os.WriteFile(junk, []byte("this is not a sqlite database at all"), 0o644); err != nil {
259 t.Fatal(err)
260 }
261 if err := CheckRestoredDB(junk); err == nil {
262 t.Fatal("CheckRestoredDB accepted a non-SQLite file")
263 }
264}
265
266// The console's own SQLite — accounts, ownership, sessions, the deployment's
267// Keymail credentials — lives one directory BELOW dataDir, where FleetDBs'
268// flat <data>/*.db glob cannot see it. Losing it loses who everyone is, so it
269// is included explicitly.
270func TestFleetDBsIncludesConsoleDB(t *testing.T) {
271 dir := t.TempDir()
272 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
273 if err != nil {
274 t.Fatal(err)
275 }
276
277 // Absent console: nothing invented, and no error.
278 dbs, err := FleetDBs(reg, dir)
279 if err != nil {
280 t.Fatal(err)
281 }
282 for _, p := range dbs {
283 if strings.Contains(p, "console") {
284 t.Fatalf("FleetDBs listed a console DB on a box that has none: %v", dbs)
285 }
286 }
287
288 consoleDB := filepath.Join(dir, "console", "console.db")
289 if err := os.MkdirAll(filepath.Dir(consoleDB), 0o750); err != nil {
290 t.Fatal(err)
291 }
292 if err := os.WriteFile(consoleDB, []byte("x"), 0o600); err != nil {
293 t.Fatal(err)
294 }
295
296 dbs, err = FleetDBs(reg, dir)
297 if err != nil {
298 t.Fatal(err)
299 }
300 if !slices.Contains(dbs, consoleDB) {
301 t.Fatalf("console DB missing from the replication set: %v", dbs)
302 }
303}
304
305// CONSOLE_DB is the console's own env var for its database; a box that moved
306// it must not silently drop out of replication.
307func TestFleetDBsHonoursConsoleDBEnv(t *testing.T) {
308 dir := t.TempDir()
309 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
310 if err != nil {
311 t.Fatal(err)
312 }
313 elsewhere := filepath.Join(t.TempDir(), "moved-console.db")
314 if err := os.WriteFile(elsewhere, []byte("x"), 0o600); err != nil {
315 t.Fatal(err)
316 }
317 t.Setenv("CONSOLE_DB", elsewhere)
318
319 dbs, err := FleetDBs(reg, dir)
320 if err != nil {
321 t.Fatal(err)
322 }
323 if !slices.Contains(dbs, elsewhere) {
324 t.Fatalf("relocated console DB missing from the replication set: %v", dbs)
325 }
326}
327
328// The generated config must state the recovery window rather than inherit
329// litestream's 24h snapshot retention by silence. A day is the shortest
330// useful window there is: a bad migration noticed on Tuesday is unrecoverable
331// if it landed on Sunday.
332func TestConfigPinsSnapshotRetention(t *testing.T) {
333 cfg := LitestreamConfig([]string{"/data/registry.db"}, "b", "p", "eu-west-1")
334 if !strings.Contains(cfg, "snapshot:") {
335 t.Fatalf("generated config states no snapshot policy — it inherits a 24h window silently:\n%s", cfg)
336 }
337 if !strings.Contains(cfg, "retention: "+SnapshotRetention.String()) {
338 t.Fatalf("generated config does not pin retention to %s:\n%s", SnapshotRetention, cfg)
339 }
340 if SnapshotRetention <= 24*time.Hour {
341 t.Fatalf("SnapshotRetention = %s, which is no better than litestream's default", SnapshotRetention)
342 }
343 // And the round-trip the restore-verify verb depends on must survive it.
344 if got := ParseLitestreamDBs(cfg); len(got) != 1 || got[0] != "/data/registry.db" {
345 t.Fatalf("ParseLitestreamDBs broke on the retention stanza: %v", got)
346 }
347}
348
349// A config that STATES a snapshot policy the installed binary silently
350// ignores is worse than one that states nothing: it reads as a decision
351// someone made. litestream 0.3.13 (still on the flagship, verified 2026-08-15)
352// parses the stanza and drops it on the floor, and 0.5 accepts unknown keys
353// just as quietly — so parsing proves nothing and the VERSION is the only
354// signal there is.
355func TestSnapshotPolicySupported(t *testing.T) {
356 cases := []struct {
357 out string
358 want bool
359 }{
360 {"v0.3.13", false}, // the flagship's binary, dated 2023
361 {"0.3.13", false}, // some builds print no leading v
362 {"v0.4.9", false}, // still pre-snapshot-policy
363 {"0.5.15", true}, // tito's binary, and the repo's pin
364 {"v0.5.14", true}, // the vendored library version
365 {"v1.0.0", true}, // a future major must not regress to "unsupported"
366 {"litestream v0.5.15\n", true},
367 {"", false}, // unknown is treated as unsupported: warn, never assume
368 {"not-a-version", false},
369 }
370 for _, c := range cases {
371 if got := SnapshotPolicySupported(c.out); got != c.want {
372 t.Errorf("SnapshotPolicySupported(%q) = %v, want %v", c.out, got, c.want)
373 }
374 }
375}
376
377// The drill must hold ONE restored database at a time, not all of them.
378//
379// This is not tidiness. carlos-restore-verify.service runs PrivateTmp=yes, so
380// its temp dir is a tmpfs whose pages are charged to the unit's own cgroup —
381// accumulating every restored copy there spends MemoryMax on files. Measured
382// on carlos-tito-ie 2026-08-15: 54 databases totalling 247M against
383// MemoryMax=256M, killed by the OOM killer at "256.2M memory peak" after ~40
384// clean restores. Deleting each copy once it has been checked bounds the peak
385// at the LARGEST SINGLE database instead of their sum.
386func TestRestoreVerifyHoldsOneRestoreAtATime(t *testing.T) {
387 tmp := t.TempDir()
388 srcDir := t.TempDir()
389 cfgPath := filepath.Join(t.TempDir(), "litestream.yml")
390 // Real source databases: the freshness check reads their headers, and a
391 // config naming files that do not exist is itself a finding now.
392 var dbs []string
393 for _, name := range []string{"a.db", "b.db", "c.db"} {
394 p := filepath.Join(srcDir, name)
395 writeCounterDB(t, p, 3)
396 dbs = append(dbs, p)
397 }
398 if err := os.WriteFile(cfgPath, []byte(LitestreamConfig(dbs, "b", "p", "eu-west-1")), 0o644); err != nil {
399 t.Fatal(err)
400 }
401
402 maxSeen := 0
403 prev := restoreAndCheckFn
404 restoreAndCheckFn = func(_, _, dbPath, tmpDir string) error {
405 // Stand in for a restore of a CURRENT replica: a byte copy of the
406 // source, so its change counter matches and freshness passes.
407 body, err := os.ReadFile(dbPath)
408 if err != nil {
409 return err
410 }
411 if err := os.WriteFile(filepath.Join(tmpDir, filepath.Base(dbPath)), body, 0o600); err != nil {
412 return err
413 }
414 ents, err := os.ReadDir(tmpDir)
415 if err != nil {
416 return err
417 }
418 if len(ents) > maxSeen {
419 maxSeen = len(ents)
420 }
421 return nil
422 }
423 t.Cleanup(func() { restoreAndCheckFn = prev })
424
425 if err := RestoreVerify("litestream", cfgPath, tmp); err != nil {
426 t.Fatalf("RestoreVerify: %v", err)
427 }
428 if maxSeen > 1 {
429 t.Fatalf("as many as %d restored copies were held at once — the drill spends MemoryMax on tmpfs files", maxSeen)
430 }
431 left, err := os.ReadDir(tmp)
432 if err != nil {
433 t.Fatal(err)
434 }
435 if len(left) != 0 {
436 t.Fatalf("RestoreVerify left %d file(s) behind", len(left))
437 }
438}
439
440// --- replica freshness ---------------------------------------------------
441//
442// The drill proves a replica RESTORES. It has never proved the replica is
443// CURRENT — and those come apart exactly when it matters. On 2026-08-11 the
444// flagship's litestream died with "permission denied" writing its shadow WAL
445// into a DynamicUser tenant's state dir; had nobody noticed by hand, the daily
446// drill would have gone on passing indefinitely, restoring a replica frozen at
447// the moment of death. A valid backup of last week is not a backup.
448
449func writeCounterDB(t *testing.T, path string, writes int) {
450 t.Helper()
451 db, err := sql.Open("sqlite", path)
452 if err != nil {
453 t.Fatal(err)
454 }
455 defer db.Close()
456 // DELETE journalling: the header change counter advances per commit
457 // without needing a checkpoint, which keeps the fixture honest about what
458 // it is testing.
459 if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil {
460 t.Fatal(err)
461 }
462 if _, err := db.Exec("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)"); err != nil {
463 t.Fatal(err)
464 }
465 for i := 0; i < writes; i++ {
466 // A fresh rowid each call, so re-seeding an existing file adds
467 // transactions rather than colliding on the primary key.
468 if _, err := db.Exec("INSERT INTO t (id) VALUES (NULL)"); err != nil {
469 t.Fatal(err)
470 }
471 }
472}
473
474func TestChangeCounterAdvancesWithWrites(t *testing.T) {
475 dir := t.TempDir()
476 a := filepath.Join(dir, "a.db")
477 writeCounterDB(t, a, 1)
478 first, err := ChangeCounter(a)
479 if err != nil {
480 t.Fatal(err)
481 }
482 writeCounterDB(t, a, 5)
483 second, err := ChangeCounter(a)
484 if err != nil {
485 t.Fatal(err)
486 }
487 if second <= first {
488 t.Fatalf("change counter did not advance across writes: %d then %d", first, second)
489 }
490
491 junk := filepath.Join(dir, "junk.db")
492 if err := os.WriteFile(junk, []byte("nope"), 0o600); err != nil {
493 t.Fatal(err)
494 }
495 if _, err := ChangeCounter(junk); err == nil {
496 t.Fatal("ChangeCounter accepted a file that is not a SQLite database")
497 }
498}
499
500func TestReplicaFreshness(t *testing.T) {
501 dir := t.TempDir()
502 local := filepath.Join(dir, "local.db")
503 behind := filepath.Join(dir, "behind.db")
504 writeCounterDB(t, behind, 1) // the replica, frozen early
505 writeCounterDB(t, local, 9) // the source, written since
506
507 long := time.Now().Add(-2 * time.Hour)
508 recent := time.Now()
509
510 // Quiet since well before the drill, replica still behind: the replicator
511 // has had every chance to catch up and has not. That is the alarm.
512 if v, detail := replicaFreshness(local, behind, long); v != freshnessStale {
513 t.Fatalf("a replica behind a long-quiet source = %v (%s), want stale", v, detail)
514 }
515 // Written moments ago: the replica being a transaction or two behind is
516 // ordinary lag, not a fault. Never cry wolf here — a drill that goes red
517 // on healthy churn is a drill people stop reading.
518 if v, _ := replicaFreshness(local, behind, recent); v != freshnessOK {
519 t.Fatalf("a replica behind a just-written source = %v, want ok (still catching up)", v)
520 }
521 // Caught up.
522 if v, _ := replicaFreshness(local, local, long); v != freshnessOK {
523 t.Fatalf("an up-to-date replica = %v, want ok", v)
524 }
525 // The source is gone: a config naming a database that no longer exists is
526 // its own finding — the replica can never advance again.
527 if v, _ := replicaFreshness(filepath.Join(dir, "absent.db"), behind, long); v != freshnessNoSource {
528 t.Fatalf("a missing source = %v, want no-source", v)
529 }
530 // Unreadable header: say nothing rather than invent a verdict.
531 junk := filepath.Join(dir, "junk.db")
532 if err := os.WriteFile(junk, []byte("nope"), 0o600); err != nil {
533 t.Fatal(err)
534 }
535 if v, _ := replicaFreshness(junk, behind, long); v != freshnessUnknown {
536 t.Fatalf("an unreadable source = %v, want unknown", v)
537 }
538}
539
540// The whole point, end to end: a replica that restores perfectly but has
541// stopped tracking its source must FAIL the run. Before this, such a replica
542// was reported "ok" every night for as long as the replicator stayed dead.
543func TestRestoreVerifyFailsOnAStaleReplica(t *testing.T) {
544 srcDir, replicaDir := t.TempDir(), t.TempDir()
545 cfgPath := filepath.Join(t.TempDir(), "litestream.yml")
546
547 live := filepath.Join(srcDir, "live.db")
548 writeCounterDB(t, live, 2)
549 // Snapshot the replica HERE — this stands for the moment the replicator
550 // died — then let the source move on without it.
551 frozen, err := os.ReadFile(live)
552 if err != nil {
553 t.Fatal(err)
554 }
555 writeCounterDB(t, live, 20)
556 // And make the source quiet for longer than the grace window, so this is
557 // unambiguously "replication stopped" rather than "still catching up".
558 old := time.Now().Add(-2 * time.Hour)
559 if err := os.Chtimes(live, old, old); err != nil {
560 t.Fatal(err)
561 }
562
563 if err := os.WriteFile(cfgPath, []byte(LitestreamConfig([]string{live}, "b", "p", "eu-west-1")), 0o644); err != nil {
564 t.Fatal(err)
565 }
566 prev := restoreAndCheckFn
567 restoreAndCheckFn = func(_, _, dbPath, tmpDir string) error {
568 return os.WriteFile(filepath.Join(tmpDir, filepath.Base(dbPath)), frozen, 0o600)
569 }
570 t.Cleanup(func() { restoreAndCheckFn = prev })
571
572 err = RestoreVerify("litestream", cfgPath, replicaDir)
573 if err == nil {
574 t.Fatal("a replica frozen at the moment its replicator died passed the drill")
575 }
576 if !strings.Contains(err.Error(), "STOPPED TRACKING") {
577 t.Fatalf("error does not name the failure: %v", err)
578 }
579}
580
581// A unit tenant whose app manages its OWN sub-instances keeps a TREE of
582// databases, not one file: kass on the flagship has registry.db plus an
583// instances/ directory that grows as the tenant creates instances. Naming a
584// single -db path cannot express that, which is why the flagship's config is
585// still hand-maintained ("carlos ops litestream takes over once nested layouts
586// are supported") — and why it had already drifted, missing one instance
587// database that the tenant created after the list was written.
588//
589// So: a BackingUnit route whose -db names a DIRECTORY replicates every *.db
590// beneath it. Exec routes are untouched by this — the activator consumes their
591// DBPath verbatim (Restore, Seed, the --db flag), so a directory there would
592// be meaningless.
593func TestFleetDBsWalksAUnitTenantDirectory(t *testing.T) {
594 dir := t.TempDir()
595 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
596 if err != nil {
597 t.Fatal(err)
598 }
599 state := t.TempDir()
600 nested := filepath.Join(state, "instances")
601 if err := os.MkdirAll(nested, 0o750); err != nil {
602 t.Fatal(err)
603 }
604 top := filepath.Join(state, "registry.db")
605 inst1 := filepath.Join(nested, "5vepbveytb8y.db")
606 inst2 := filepath.Join(nested, "eafu5brhjs26.db")
607 for _, p := range []string{top, inst1, inst2} {
608 if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
609 t.Fatal(err)
610 }
611 }
612 // Litestream's own shadow-WAL directory sits beside each database and is
613 // full of files that are not databases. Replicating a replicator's
614 // bookkeeping would be absurd, and dot-directories are its convention.
615 meta := filepath.Join(state, ".registry.db-litestream", "generations", "abc", "wal")
616 if err := os.MkdirAll(meta, 0o750); err != nil {
617 t.Fatal(err)
618 }
619 if err := os.WriteFile(filepath.Join(meta, "00000000.db"), []byte("x"), 0o600); err != nil {
620 t.Fatal(err)
621 }
622 // Sidecars and empty placeholders are not databases either.
623 for _, p := range []string{top + "-wal", top + "-shm", filepath.Join(state, "empty.db")} {
624 if err := os.WriteFile(p, nil, 0o600); err != nil {
625 t.Fatal(err)
626 }
627 }
628
629 if err := reg.Upsert(registry.Route{Host: "home.kass.training", Backing: registry.BackingUnit, DBPath: state}); err != nil {
630 t.Fatal(err)
631 }
632
633 dbs, err := FleetDBs(reg, dir)
634 if err != nil {
635 t.Fatal(err)
636 }
637 for _, want := range []string{top, inst1, inst2} {
638 if !slices.Contains(dbs, want) {
639 t.Fatalf("tree database %s missing: %v", want, dbs)
640 }
641 }
642 for _, unwanted := range []string{meta, top + "-wal", top + "-shm", filepath.Join(state, "empty.db")} {
643 if slices.Contains(dbs, unwanted) {
644 t.Fatalf("FleetDBs swept up %s: %v", unwanted, dbs)
645 }
646 }
647 for _, got := range dbs {
648 if strings.Contains(got, "-litestream") {
649 t.Fatalf("FleetDBs descended into a litestream meta directory: %s", got)
650 }
651 }
652}
653
654// The tree rule must not leak to exec routes: their DBPath is consumed
655// verbatim by the activator, so a directory there is a mistake, not a tree.
656func TestFleetDBsDoesNotWalkExecRouteDBPath(t *testing.T) {
657 dir := t.TempDir()
658 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
659 if err != nil {
660 t.Fatal(err)
661 }
662 state := t.TempDir()
663 if err := os.WriteFile(filepath.Join(state, "inner.db"), []byte("x"), 0o600); err != nil {
664 t.Fatal(err)
665 }
666 if err := reg.Upsert(registry.Route{Host: "exec.example.com", DBPath: state}); err != nil {
667 t.Fatal(err)
668 }
669
670 dbs, err := FleetDBs(reg, dir)
671 if err != nil {
672 t.Fatal(err)
673 }
674 if slices.Contains(dbs, filepath.Join(state, "inner.db")) {
675 t.Fatalf("an exec route's directory DBPath was walked: %v", dbs)
676 }
677}
678
679// Cutting a box over from a hand-maintained config to a generated one is the
680// moment a database can silently stop being replicated: the new list is
681// derived from the registry, and anything the registry does not know about
682// simply is not in it. Nobody would notice — replication of a dropped database
683// just stops, and the drill only checks what the config names.
684//
685// So the generator refuses to shrink the set unless told to.
686func TestCoverageShortfall(t *testing.T) {
687 existing := LitestreamConfig([]string{"/data/a.db", "/data/b.db", "/data/registry.db"}, "b", "p", "eu-west-1")
688
689 // A superset is fine — that is the ordinary case (console.db arriving).
690 if missing := CoverageShortfall(existing, []string{"/data/a.db", "/data/b.db", "/data/registry.db", "/data/console/console.db"}); len(missing) != 0 {
691 t.Fatalf("a superset was reported as a shortfall: %v", missing)
692 }
693 // Identical is fine.
694 if missing := CoverageShortfall(existing, []string{"/data/registry.db", "/data/b.db", "/data/a.db"}); len(missing) != 0 {
695 t.Fatalf("an identical set was reported as a shortfall: %v", missing)
696 }
697 // Dropping one is the thing to catch, and the caller must be told WHICH.
698 missing := CoverageShortfall(existing, []string{"/data/a.db", "/data/registry.db"})
699 if len(missing) != 1 || missing[0] != "/data/b.db" {
700 t.Fatalf("CoverageShortfall = %v, want [/data/b.db]", missing)
701 }
702 // No existing config (a fresh box) can never be a shortfall.
703 if missing := CoverageShortfall("", []string{"/data/a.db"}); len(missing) != 0 {
704 t.Fatalf("a fresh box reported a shortfall: %v", missing)
705 }
706}
707
708// Two databases with the same basename must not replicate to the same place.
709//
710// Every SQLite-backed tenant on the fleet names its database registry.db or
711// data.db; the box's own registry is /data/registry.db. Deriving the replica
712// URL from filepath.Base collapses all of them onto one S3 key, and two
713// litestream replicators writing one prefix interleave their WAL into a
714// replica that restores as neither database. Tree discovery makes the
715// collision the normal case rather than a curiosity: every tenant tree has a
716// registry.db in it.
717func TestReplicaURLsAreUniquePerPath(t *testing.T) {
718 cfg := LitestreamConfig([]string{
719 "/data/registry.db",
720 "/var/lib/carlos-app/home.kass.training/registry.db",
721 "/var/lib/carlos-app/other.example.com/registry.db",
722 }, "bucket-x", "fleet", "eu-west-1")
723
724 var urls []string
725 for _, line := range strings.Split(cfg, "\n") {
726 if u, ok := strings.CutPrefix(strings.TrimSpace(line), "- url: "); ok {
727 urls = append(urls, u)
728 }
729 }
730 if len(urls) != 3 {
731 t.Fatalf("expected 3 replica urls, got %d:\n%s", len(urls), cfg)
732 }
733 seen := map[string]bool{}
734 for _, u := range urls {
735 if seen[u] {
736 t.Fatalf("two databases share the replica url %q — their WALs would interleave:\n%s", u, cfg)
737 }
738 seen[u] = true
739 }
740 // And the key should still be legible to whoever is doing the restore.
741 if !strings.Contains(cfg, "home.kass.training") {
742 t.Fatalf("replica keys lost the path that identifies them:\n%s", cfg)
743 }
744}
745
746// The dataDir glob takes every *.db it finds, which means the box-wide
747// replicator will happily stream anything that lands there — including things
748// that are not tenant databases at all. The flagship has a 410M
749// harbourmail-purge-backup.db sitting in /data: a leftover from a purge, owned
750// by root, matching the glob exactly. Today it is not replicated only because
751// that box's config is hand-written; the moment the generator takes over it
752// would join the replication set and start costing storage forever.
753//
754// So the glob proposes and the REGISTRY disposes: a file under dataDir is
755// replicated when some route claims it (or it is the box's own registry or
756// console). Anything else is skipped and NAMED — never silently, because a
757// skipped database that someone did want is exactly the failure this whole
758// area keeps producing.
759func TestFleetDBsSkipsUnclaimedFilesAndSaysSo(t *testing.T) {
760 dir := t.TempDir()
761 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
762 if err != nil {
763 t.Fatal(err)
764 }
765 claimed := filepath.Join(dir, "app.example.com.db")
766 stray := filepath.Join(dir, "harbourmail-purge-backup.db")
767 for _, p := range []string{claimed, stray} {
768 if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
769 t.Fatal(err)
770 }
771 }
772 if err := reg.Upsert(registry.Route{Host: "app.example.com", DBPath: claimed}); err != nil {
773 t.Fatal(err)
774 }
775
776 dbs, skipped, err := FleetDBsWithSkipped(reg, dir, nil)
777 if err != nil {
778 t.Fatal(err)
779 }
780 if !slices.Contains(dbs, claimed) {
781 t.Fatalf("a route's own database was skipped: %v", dbs)
782 }
783 if !slices.Contains(dbs, filepath.Join(dir, "registry.db")) {
784 t.Fatalf("the box registry was skipped: %v", dbs)
785 }
786 if slices.Contains(dbs, stray) {
787 t.Fatalf("an unclaimed file joined the replication set: %v", dbs)
788 }
789 if !slices.Contains(skipped, stray) {
790 t.Fatalf("the unclaimed file was dropped SILENTLY — skipped = %v", skipped)
791 }
792}
793
794// A host with a layout migration journal is between two directories, so
795// the generator claims neither of its database paths: its file is named
796// among the skipped rather than replicated from a place it is leaving.
797func TestFleetDBsSkipsAHostTheLayoutPassHolds(t *testing.T) {
798 dir := t.TempDir()
799 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
800 if err != nil {
801 t.Fatal(err)
802 }
803 held := filepath.Join(dir, "held.example.com.db")
804 kept := filepath.Join(dir, "kept.example.com.db")
805 for _, p := range []string{held, kept} {
806 if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
807 t.Fatal(err)
808 }
809 }
810 for _, rt := range []registry.Route{
811 {Host: "held.example.com", DBPath: held},
812 {Host: "kept.example.com", DBPath: kept},
813 } {
814 if err := reg.Upsert(rt); err != nil {
815 t.Fatal(err)
816 }
817 }
818 journal := provision.LayoutJournalPath(dir, "held.example.com")
819 if err := os.MkdirAll(filepath.Dir(journal), 0o700); err != nil {
820 t.Fatal(err)
821 }
822 if err := os.WriteFile(journal, []byte("{}"), 0o600); err != nil {
823 t.Fatal(err)
824 }
825
826 dbs, skipped, err := FleetDBsWithSkipped(reg, dir, LayoutJournalPresent(dir))
827 if err != nil {
828 t.Fatal(err)
829 }
830 if slices.Contains(dbs, held) {
831 t.Fatalf("a held host's database joined the replication set: %v", dbs)
832 }
833 if !slices.Contains(skipped, held) {
834 t.Fatalf("a held host's database was dropped silently: skipped = %v", skipped)
835 }
836 if !slices.Contains(dbs, kept) {
837 t.Fatalf("a host with no journal was skipped: %v", dbs)
838 }
839
840 // No hook is the old behaviour: every route's database is claimed.
841 dbs, _, err = FleetDBsWithSkipped(reg, dir, nil)
842 if err != nil {
843 t.Fatal(err)
844 }
845 if !slices.Contains(dbs, held) {
846 t.Fatalf("with no hook the held host was skipped: %v", dbs)
847 }
848}
849
850// LayoutJournalPresent answers true for anything at the journal path that
851// is not definitely absent, a dangling symlink included: a journal that
852// cannot be read is not proof there is none.
853func TestLayoutJournalPresentIsLstatNotAbsent(t *testing.T) {
854 dir := t.TempDir()
855 present := LayoutJournalPresent(dir)
856 if present("a.example.com") {
857 t.Fatal("no journal directory, but a journal was reported")
858 }
859 link := provision.LayoutJournalPath(dir, "a.example.com")
860 if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
861 t.Fatal(err)
862 }
863 if err := os.Symlink(filepath.Join(dir, "nowhere"), link); err != nil {
864 t.Fatal(err)
865 }
866 if !present("a.example.com") {
867 t.Fatal("a dangling symlink at the journal path was read as no journal")
868 }
869 if present("b.example.com") {
870 t.Fatal("another host was reported held")
871 }
872}
873