/** * Migration gate for celilo#626 — the one class of defect the rest of the * suite is structurally incapable of seeing. * * A `dns_registrations` row written before #464/#466 carried a literal `ip`, * and `refresh_registrations` replayed it every 15 minutes as the address to * publish. Once the ISP re-leased, that republished a dead address forever, * reporting success each tick. Five public names went dark for nine days, * including the apt repo and the module registry, while every in-fleet check * stayed green (the split-horizon resolver answers with a reachable address). * * A thorough e2e gate for exactly this defect already existed and passed * throughout — `modules/celilo-website/e2e/website-deploy-new-hostname.test.ts` * moves the WAN address and fires the real refresh hook. It could only ever * exercise rows the code under test wrote, because e2e always starts from an * empty database. A suite that starts clean can prove the FORWARD invariant * and says nothing about the installed base — which is where the outage was. * * So this test builds a database at the OLD schema, puts an armed legacy row * in it, and runs the real migrator over it. Dropping the column IS the * sweep: every armed row on every fleet disarms with the schema change, * rather than waiting for someone to redeploy the module that owns it. * * See design.md D6. */ import { Database } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; import { findMigrationsFolder } from './client'; /** The last migration before this change — the schema the outage ran on. */ const LAST_LEGACY_TAG = '0019_backup_pid'; interface JournalEntry { idx: number; version: string; when: number; tag: string; breakpoints: boolean; } /** * A migrations folder truncated at `LAST_LEGACY_TAG`, so a database can be * built at the pre-change schema using the real migration files rather than a * hand-written approximation that could drift from them. */ function legacyMigrationsFolder(into: string): string { const source = findMigrationsFolder(); const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as { version: string; dialect: string; entries: JournalEntry[]; }; const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG); if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`); const kept = journal.entries.slice(0, cutoff + 1); mkdirSync(join(into, 'meta'), { recursive: true }); for (const entry of kept) { cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`)); } writeFileSync( join(into, 'meta', '_journal.json'), JSON.stringify({ ...journal, entries: kept }, null, 2), ); return into; } describe('dns_registrations migration over a pre-#464 database', () => { let tempDir: string; let dbPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsmig-')); dbPath = join(tempDir, 'legacy.db'); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); function openLegacyDatabase(): Database { const sqlite = new Database(dbPath, { create: true }); sqlite.run('PRAGMA foreign_keys = ON'); migrate(drizzle(sqlite), { migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')), }); return sqlite; } function seedModule(sqlite: Database, id: string): void { sqlite.run( 'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)', [id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })], ); } function runCurrentMigrations(sqlite: Database): void { migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() }); } test('the armed address is gone and the registration survives', () => { const sqlite = openLegacyDatabase(); seedModule(sqlite, 'namecheap'); seedModule(sqlite, 'caddy'); // The shape an older celilo wrote: a literal address the refresh replayed. // This is what no deploy can produce today, which is why the seed is here // and not in e2e (CLAUDE.md's pre-seeding carve-out). sqlite.run( 'INSERT INTO dns_registrations (provider_module_id, consumer_module_id, fqdn, ip) VALUES (?, ?, ?, ?)', ['namecheap', 'caddy', 'apt.celilo.computer', '71.36.112.98'], ); runCurrentMigrations(sqlite); const columns = ( sqlite.query('PRAGMA table_info(dns_registrations)').all() as { name: string }[] ).map((c) => c.name); expect(columns).not.toContain('ip'); expect(columns).toContain('companion'); const rows = sqlite .query('SELECT fqdn, provider_module_id, companion FROM dns_registrations') .all() as { fqdn: string; provider_module_id: string; companion: number; }[]; expect(rows).toEqual([ { fqdn: 'apt.celilo.computer', provider_module_id: 'namecheap', companion: 0 }, ]); sqlite.close(); }); test('the single consumer becomes the seed of the consumer set', () => { const sqlite = openLegacyDatabase(); seedModule(sqlite, 'namecheap'); seedModule(sqlite, 'caddy'); seedModule(sqlite, 'authentik'); sqlite.run( 'INSERT INTO dns_registrations (provider_module_id, consumer_module_id, fqdn, ip) VALUES (?, ?, ?, ?)', ['namecheap', 'caddy', 'auth.lunacycle.net', '71.36.112.98'], ); sqlite.run( 'INSERT INTO dns_registrations (provider_module_id, consumer_module_id, fqdn, ip) VALUES (?, ?, ?, ?)', ['namecheap', 'authentik', 'other.lunacycle.net', null], ); runCurrentMigrations(sqlite); const consumers = sqlite .query( `SELECT r.fqdn AS fqdn, c.module_id AS module_id FROM dns_registration_consumers c JOIN dns_registrations r ON r.id = c.registration_id ORDER BY r.fqdn`, ) .all() as { fqdn: string; module_id: string }[]; expect(consumers).toEqual([ { fqdn: 'auth.lunacycle.net', module_id: 'caddy' }, { fqdn: 'other.lunacycle.net', module_id: 'authentik' }, ]); sqlite.close(); }); test('a migrated row still dies with its provider, and now outlives a co-consumer', () => { const sqlite = openLegacyDatabase(); seedModule(sqlite, 'namecheap'); seedModule(sqlite, 'caddy'); seedModule(sqlite, 'authentik'); sqlite.run( 'INSERT INTO dns_registrations (provider_module_id, consumer_module_id, fqdn, ip) VALUES (?, ?, ?, ?)', ['namecheap', 'caddy', 'auth.lunacycle.net', '71.36.112.98'], ); runCurrentMigrations(sqlite); // authentik also depends on the name, as it does on the live fleet — the // recovery re-attributed it to caddy, and under the old single-valued // column removing caddy would have cascade-deleted a name still served. const registrationId = ( sqlite.query('SELECT id FROM dns_registrations').get() as { id: number } ).id; sqlite.run( 'INSERT INTO dns_registration_consumers (registration_id, module_id) VALUES (?, ?)', [registrationId, 'authentik'], ); sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']); expect(sqlite.query('SELECT COUNT(*) AS n FROM dns_registrations').get()).toEqual({ n: 1 }); sqlite.run('DELETE FROM modules WHERE id = ?', ['namecheap']); expect(sqlite.query('SELECT COUNT(*) AS n FROM dns_registrations').get()).toEqual({ n: 0 }); sqlite.close(); }); });