@celilo/cli 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -18,6 +18,7 @@ import {
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join, relative } from 'node:path';
20
20
  import { eq } from 'drizzle-orm';
21
+ import { create as tarCreate } from 'tar';
21
22
  import { type DbClient, getDb } from '../../db/client';
22
23
  import { moduleIntegrity, modules } from '../../db/schema';
23
24
  import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update';
@@ -556,3 +557,84 @@ describe('updateOne — an update that fails partway leaves the install untouche
556
557
  expect(mod?.version).toBe('1.0.0');
557
558
  });
558
559
  });
560
+
561
+ describe('updateOne — a .netapp package source', () => {
562
+ let tempDir: string;
563
+ let installedDir: string;
564
+ let db: DbClient;
565
+
566
+ beforeEach(() => {
567
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-netapp-'));
568
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
569
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
570
+
571
+ installedDir = join(tempDir, 'installed', 'testmod');
572
+ mkdirSync(installedDir, { recursive: true });
573
+ writeFileSync(
574
+ join(installedDir, 'manifest.yml'),
575
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
576
+ );
577
+
578
+ db = getDb();
579
+ db.insert(modules)
580
+ .values({
581
+ id: 'testmod',
582
+ name: 'Test Module',
583
+ sourcePath: installedDir,
584
+ version: '1.0.0+5',
585
+ manifestData: {
586
+ celilo_contract: '1.0',
587
+ id: 'testmod',
588
+ name: 'Test Module',
589
+ version: '1.0.0',
590
+ },
591
+ })
592
+ .run();
593
+ });
594
+
595
+ afterEach(() => {
596
+ rmSync(tempDir, { recursive: true, force: true });
597
+ process.env.CELILO_DB_PATH = undefined;
598
+ process.env.CELILO_ORIGINAL_CWD = undefined;
599
+ });
600
+
601
+ // ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
602
+ // `replaceInstalledModule` still reads the extracted package through
603
+ // `actualPath`, which IS the temp dir for a .netapp source. Every
604
+ // registry-driven upgrade died with `ENOENT ... scandir
605
+ // .tmp-module-extract/<ts>`. Directory sources never set tempDir, which
606
+ // is why the path-source tests above stayed green. This test drives the
607
+ // real extraction path to keep that ordering honest.
608
+ test('the staged swap reads the package before the temp dir is cleaned', async () => {
609
+ const srcDir = join(tempDir, 'pkg-src');
610
+ mkdirSync(srcDir, { recursive: true });
611
+ writeFileSync(
612
+ join(srcDir, 'manifest.yml'),
613
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
614
+ );
615
+ const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
616
+ await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
617
+
618
+ const extractRoot = join(process.cwd(), '.tmp-module-extract');
619
+ const extractCountBefore = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0;
620
+
621
+ // Registry packages are pre-verified at publish time; updateOne gets
622
+ // skip-verify from the sweep, so no signature.sig is needed here.
623
+ const result = await updateOne(
624
+ pkgPath,
625
+ db,
626
+ { 'skip-verify': true },
627
+ { quiet: true, displayVersion: '2.0.0+7' },
628
+ );
629
+
630
+ expect(result.status).toBe('success');
631
+ if (result.status !== 'success') return;
632
+ expect(result.previousVersion).toBe('1.0.0+5');
633
+ expect(result.newVersion).toBe('2.0.0+7');
634
+ expect(readFileSync(join(installedDir, 'manifest.yml'), 'utf-8')).toContain('version: 2.0.0');
635
+
636
+ // The extraction scratch space is gone again once the swap has read it.
637
+ const extractCountAfter = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0;
638
+ expect(extractCountAfter).toBe(extractCountBefore);
639
+ });
640
+ });
@@ -128,7 +128,13 @@ export async function fetchAndUpdate(
128
128
  ): Promise<UpdateOutcome> {
129
129
  const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
130
130
  try {
131
- const pkgData = await client.download(moduleId, version);
131
+ // The index entry carries the published sha256. Look it up here rather than
132
+ // thread it through five call sites, so a short download is caught as a bad
133
+ // transfer instead of surfacing later as "zlib: unexpected end of file".
134
+ // Absence is tolerated: an entry predating cksum still downloads.
135
+ const entries = await client.getIndex(moduleId).catch(() => []);
136
+ const cksum = entries.find((entry) => entry.vers === version)?.cksum;
137
+ const pkgData = await client.download(moduleId, version, cksum);
132
138
  await Bun.write(tmpPath, pkgData);
133
139
  } catch (err) {
134
140
  return {
@@ -410,9 +416,6 @@ export async function updateOne(
410
416
  ? readFileSync(packagedSignaturePath, 'utf-8').trim()
411
417
  : null;
412
418
 
413
- // Clean up temp dir if we extracted a .netapp
414
- if (tempDir) await cleanupTempDir(tempDir);
415
-
416
419
  // Remove what the new version dropped. `updateOne` only ever overlaid files,
417
420
  // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
418
421
  // code celilo no longer believes it has installed, which is the same class of
@@ -440,6 +443,13 @@ export async function updateOne(
440
443
  // below it runs only once the files are really in place.
441
444
  replaceInstalledModule(actualPath, installedPath, pruneDropped);
442
445
 
446
+ // Clean up the extracted package. This waits until after the swap because
447
+ // `replaceInstalledModule` reads `actualPath`, which IS `tempDir` for a
448
+ // .netapp source — deleting first is what made every registry-driven
449
+ // upgrade die with `ENOENT ... scandir .tmp-module-extract/<ts>`
450
+ // (ce-h4of, a regression from the celilo#1008 staging swap).
451
+ if (tempDir) await cleanupTempDir(tempDir);
452
+
443
453
  // Record it. `updateOne` never touched this table, so the baseline stayed
444
454
  // frozen at the module's FIRST import no matter how many times it was
445
455
  // updated. That is why `module verify` reported the same violations whether
@@ -17,6 +17,7 @@ import {
17
17
  runBuiltinCheckForMonitor,
18
18
  } from '../../services/alerting/builtin-source';
19
19
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
20
+ import { renderMonitorRunMessage } from '../../services/alerting/format';
20
21
  import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
21
22
  import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
22
23
  import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
@@ -198,16 +199,7 @@ async function handleRun(args: string[]): Promise<CommandResult> {
198
199
  const summary = await runOneMonitor(db, monitor, buildDeps());
199
200
  promoteReadyAlerts(db, new Date());
200
201
 
201
- if (summary.outcome === 'error') {
202
- return {
203
- success: true,
204
- message: `${target}: check could not run — ${summary.errorMessage ?? 'unknown error'}`,
205
- };
206
- }
207
- return {
208
- success: true,
209
- message: `${target}: ${summary.failingKeyCount} failing, ${summary.resolvedIds.length} resolved`,
210
- };
202
+ return { success: true, message: renderMonitorRunMessage(target, summary) };
211
203
  }
212
204
 
213
205
  function handleToggle(args: string[], enabled: boolean): CommandResult {
@@ -82,15 +82,25 @@ export async function handleRestore(
82
82
 
83
83
  // Run Drizzle migrations on the restored DB so a backup from an
84
84
  // older celilo opens cleanly. No-op if the DB schema is already
85
- // current. Best-effort: if migrations fail (e.g., a destructive
86
- // backward migration would be needed), surface the error but the
87
- // restore itself already landed.
85
+ // current. A failure here FAILS the restore (celilo#1269): the swap
86
+ // has landed, so the box holds restored state it cannot yet open
87
+ // safely, and reporting success over an unmigrated database trains
88
+ // operators to ignore the one warning that matters on a recovery
89
+ // path. The error names the command that retries the migration and
90
+ // the check that says whether the schema is complete.
91
+
92
+ let migrationError: string | undefined;
88
93
  try {
89
94
  await migrateRestoredDb();
90
95
  } catch (err) {
91
- log.warn(
92
- `Restore complete but migrations failed: ${err instanceof Error ? err.message : String(err)}. The restored DB may need manual migration.`,
93
- );
96
+ migrationError = err instanceof Error ? err.message : String(err);
97
+ }
98
+
99
+ if (migrationError) {
100
+ return {
101
+ success: false,
102
+ error: `Restore landed but migrations failed: ${migrationError}. The database was restored to this box, but its schema may not match this celilo build and celilo commands may fail against it. Run \`celilo system migrate\` to retry the migrations, and \`celilo system doctor\` to check whether the schema is complete before using the box.`,
103
+ };
94
104
  }
95
105
 
96
106
  // Re-register event-bus subscriptions from the restored modules' manifests.
@@ -362,7 +362,7 @@ _celilo_system_config_keys() {
362
362
  'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)'
363
363
  'network.secure-mgmt.gateway:Control-plane gateway IP'
364
364
  'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)'
365
- 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs)'
365
+ 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs or JSON array)'
366
366
  'dns.primary:Primary DNS server'
367
367
  'dns.fallback:Fallback DNS servers'
368
368
  'routing.internal_gateway:Internal gateway IP'
package/src/cli/index.ts CHANGED
@@ -951,9 +951,10 @@ Usage:
951
951
  Shows how celilo classifies every interface on a firewall: the zone it
952
952
  matched, the external edge, or ALIEN when nothing accounts for it.
953
953
 
954
- Read-only. It reads the recorded interface table and the declared zone
955
- subnets and classifies in memory — it never touches the box, so it is safe
956
- to run against a firewall whose converge is currently refusing.
954
+ It reads the interface table LIVE from the box, the same way the next
955
+ converge will, so its answer is the answer the converge will act on. If the
956
+ box cannot be reached, it says so and classifies the interface snapshot
957
+ recorded at machine add instead, labelled as possibly stale.
957
958
 
958
959
  With no hostname, every machine celilo classifies as a router is shown.
959
960
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for the restore migration contract (celilo#1269):
3
+ *
4
+ * 1. A restore whose post-swap migration step fails reports FAILURE, not a
5
+ * warning over a success result. The failure names the recovery command
6
+ * (`celilo system migrate`) and the check that says whether the schema is
7
+ * complete (`celilo system doctor`).
8
+ * 2. A connection held across the migration step is observable at the step's
9
+ * own boundary: migrateRestoredDb throws naming the lock instead of
10
+ * quietly proceeding over a locked database.
11
+ *
12
+ * The round-trip here (encrypted envelope → on_restore hook → staged system
13
+ * files → swap → migrate) is the first module-artifact round-trip at unit
14
+ * level; the envelope mechanics mirror backup-envelope-roundtrip.test.ts.
15
+ */
16
+
17
+ import { Database } from 'bun:sqlite';
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { create as tarCreate } from 'tar';
23
+ import { getDbPath } from '../config/paths';
24
+ import { closeDb, getDb } from '../db/client';
25
+ import { runMigrations } from '../db/migrate';
26
+ import { modules } from '../db/schema';
27
+ import { getOrCreateMasterKey } from '../secrets/master-key';
28
+ import { encryptFileToFile } from '../services/backup-cipher';
29
+ import { buildManifest } from '../services/backup-manifest';
30
+ import { migrateRestoredDb } from '../services/restore-from-file';
31
+ import { runCli } from './index';
32
+
33
+ const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures');
34
+ const STAGING_HOOK = join(FIXTURES_DIR, 'on-restore-staging-hook.ts');
35
+ const MODULE_ID = 'restore-migration-test';
36
+
37
+ /** The manifest the module row carries: one on_restore hook, the fixture. */
38
+ const MODULE_MANIFEST = {
39
+ celilo_contract: '1.0',
40
+ id: MODULE_ID,
41
+ name: 'Restore Migration Test',
42
+ version: '0.0.1',
43
+ hooks: {
44
+ on_restore: {
45
+ script: STAGING_HOOK,
46
+ timeout: 60000,
47
+ },
48
+ },
49
+ };
50
+
51
+ /**
52
+ * Build an encrypted module artifact whose envelope carries `dbBytes` as the
53
+ * backed-up celilo.db. The fixture on_restore hook stages it into system/ so
54
+ * the restore swaps it over the live DB before the migration step runs.
55
+ */
56
+ async function buildModuleArtifact(dir: string, dbBytes: string): Promise<string> {
57
+ const envelopeDir = join(dir, 'envelope-build');
58
+ mkdirSync(join(envelopeDir, 'data'), { recursive: true });
59
+ writeFileSync(
60
+ join(envelopeDir, 'manifest.json'),
61
+ JSON.stringify(buildManifest({ kind: 'module', moduleId: MODULE_ID, moduleVersion: '0.0.1' })),
62
+ );
63
+ writeFileSync(join(envelopeDir, 'data', 'celilo.db'), dbBytes);
64
+ const tarPath = join(dir, 'envelope.tar');
65
+ await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']);
66
+ const masterKey = await getOrCreateMasterKey();
67
+ const artifactPath = join(dir, 'module.backup');
68
+ await encryptFileToFile(tarPath, artifactPath, masterKey);
69
+ return artifactPath;
70
+ }
71
+
72
+ describe('restore migration failure reporting (celilo#1269)', () => {
73
+ let dir: string;
74
+
75
+ beforeEach(async () => {
76
+ dir = mkdtempSync(join(tmpdir(), 'celilo-restore-mig-fail-test-'));
77
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
78
+ process.env.CELILO_DATA_DIR = dir;
79
+ process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
80
+ await runMigrations(process.env.CELILO_DB_PATH);
81
+
82
+ // The artifact's module must already be imported (restore-from-file's
83
+ // contract with bootstrap.sh). Its sourcePath points at the workspace
84
+ // fixture dir: the canonical <dataDir>/modules/<id> does not exist here,
85
+ // so restore-from-file falls back to sourcePath for the hook.
86
+ const db = getDb();
87
+ db.insert(modules)
88
+ .values({
89
+ id: MODULE_ID,
90
+ name: 'Restore Migration Test',
91
+ version: '0.0.1',
92
+ sourcePath: FIXTURES_DIR,
93
+ manifestData: MODULE_MANIFEST,
94
+ })
95
+ .run();
96
+ });
97
+
98
+ afterEach(() => {
99
+ closeDb();
100
+ process.env.CELILO_DB_PATH = undefined;
101
+ process.env.CELILO_DATA_DIR = undefined;
102
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
103
+ try {
104
+ rmSync(dir, { recursive: true, force: true });
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ });
109
+
110
+ test('a restore whose migration step fails reports failure and names the recovery path', async () => {
111
+ // The artifact's celilo.db is not a database at all. The swap lands it
112
+ // (staging bytes are deliberately tolerated so the file-copy path stays
113
+ // testable), and the migration step then fails on open — the same
114
+ // failure surface a locked or half-restored DB produces.
115
+ const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
116
+ const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
117
+
118
+ expect(result.success).toBe(false);
119
+ const err = result.success ? '' : (result.error ?? '');
120
+ expect(err).toContain('migrat');
121
+ expect(err).toContain('celilo system migrate');
122
+ expect(err).toContain('celilo system doctor');
123
+ });
124
+
125
+ test('the swap happened even when migrations failed (the error says so)', async () => {
126
+ const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
127
+ const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
128
+
129
+ expect(result.success).toBe(false);
130
+ const err = result.success ? '' : (result.error ?? '');
131
+ // The operator must learn the swap DID land — the failure is about the
132
+ // schema step, not a lost restore.
133
+ expect(err).toContain('restored');
134
+ });
135
+
136
+ // The timeout is 30s, not the 5s default: the holder pins the write lock
137
+ // for the full busy_timeout window before the migration step gives up, and
138
+ // the two windows are the same 5s.
139
+ test('a connection held across the migration step throws naming the lock', async () => {
140
+ // Current schema, ledger emptied so drizzle has work to do, then a write
141
+ // lock held across the step. Deleting one ledger row is not enough: the
142
+ // watermark is created_at-based and every migration shares one timestamp,
143
+ // so the whole ledger has to go. The re-run hits "already exists", the
144
+ // ledger-repair path then tries to write, and that write blocks on the
145
+ // holder until busy_timeout expires.
146
+ const livePath = getDbPath();
147
+ const ledger = new Database(livePath);
148
+ ledger.run('DELETE FROM __drizzle_migrations');
149
+ ledger.close();
150
+
151
+ const holder = new Database(livePath);
152
+ holder.run('BEGIN IMMEDIATE');
153
+ try {
154
+ await expect(migrateRestoredDb()).rejects.toThrow(/locked/i);
155
+ } finally {
156
+ holder.close();
157
+ }
158
+ }, 30000);
159
+ });
package/src/db/client.ts CHANGED
@@ -96,6 +96,10 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
96
96
  runMigrationsOn(db);
97
97
  } catch (error) {
98
98
  console.error('Failed to run migrations:', error);
99
+ // Release the file before rethrowing: the caller never receives this
100
+ // connection, so leaving it open holds the WAL lock on a db it cannot
101
+ // use (celilo#1269).
102
+ sqlite.close();
99
103
  throw error;
100
104
  }
101
105
  }
@@ -120,6 +124,7 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
120
124
  }
121
125
  } catch (error) {
122
126
  console.error('Failed to backfill module_systems:', error);
127
+ sqlite.close();
123
128
  throw error;
124
129
  }
125
130
  }
@@ -1,147 +1,73 @@
1
1
  /**
2
- * The frozen-watermark recurrence gate (celilo#169).
2
+ * Tests for the standalone migration entrypoint's connection lifecycle.
3
3
  *
4
- * The state under test is one no correct deploy can produce, so it is seeded
5
- * by hand rather than reached: a database written by a celilo from the
6
- * imperative hand-list era, where schema changes were applied directly and
7
- * `__drizzle_migrations` never recorded them. Its ledger therefore remembers
8
- * an old migration while the tables and columns of every later one are already
9
- * present.
4
+ * runMigrations opens its own connection via createDbClient — which is NOT
5
+ * the process-wide singleton. Its teardown used to call closeDb(), which
6
+ * closes the SINGLETON: a different connection. Two consequences, both bad:
7
+ * the connection this call created was never closed, and a caller that had
8
+ * the singleton open had it closed out from under it.
10
9
  *
11
- * That is the carve-out CLAUDE.md draws around seeding state: deploying
12
- * anything cannot reproduce this row, because current celilo has recorded
13
- * every migration it applied since ISS-0100 made drizzle authoritative. A
14
- * suite that starts from an empty database can only ever prove the forward
15
- * invariant, and says nothing about the installed base.
16
- *
17
- * What made it a hazard: drizzle's migrator is watermark-only. It re-runs
18
- * every migration newer than the newest ledger row, so the first `ALTER TABLE
19
- * ... ADD` dies on `duplicate column name`, the transaction rolls back, and
20
- * the throw happens inside `createDbClient` — so EVERY celilo command on that
21
- * box fails at database open, not just a migrate. celilo-mgr was remediated by
22
- * hand once. This gate is what stops the next one needing a runbook.
10
+ * One bun:sqlite caveat this suite deliberately does NOT paper over: closing
11
+ * a drizzle-wrapped connection releases the file only once drizzle's prepared
12
+ * statements are collected, so a just-closed connection can still hold locks
13
+ * until the next GC. The restore path (the only in-tree caller) does not need
14
+ * the file again after migrating, so the caveat is harmless there — the thing
15
+ * that must hold is that the CLOSE TARGETS THE RIGHT CONNECTION.
23
16
  */
24
17
 
25
- import { afterEach, describe, expect, test } from 'bun:test';
26
- import { rmSync } from 'node:fs';
18
+ import { Database } from 'bun:sqlite';
19
+ import { describe, expect, it } from 'bun:test';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
27
21
  import { tmpdir } from 'node:os';
28
22
  import { join } from 'node:path';
29
- import { type DbClient, createDbClient } from './client';
30
- import { runMigrationsOn } from './migrate';
31
- import { findSchemaDrift } from './schema-introspection';
32
-
33
- describe('runMigrationsOn — a database from the hand-list era', () => {
34
- const paths: string[] = [];
35
-
36
- const freshDb = (): { db: DbClient; path: string } => {
37
- const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
38
- paths.push(path);
39
- return { db: createDbClient({ path }), path };
40
- };
41
-
42
- afterEach(() => {
43
- for (const path of paths.splice(0)) {
44
- for (const suffix of ['', '-wal', '-shm']) {
45
- rmSync(`${path}${suffix}`, { force: true });
46
- }
23
+ import { closeDb, getDb } from './client';
24
+ import { runMigrations } from './migrate';
25
+ import { systemConfig } from './schema';
26
+
27
+ describe('runMigrations connection lifecycle', () => {
28
+ it("does not close the caller's singleton connection", async () => {
29
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-close-test-'));
30
+ try {
31
+ const livePath = join(dir, 'celilo.db');
32
+ process.env.CELILO_DB_PATH = livePath;
33
+ await runMigrations(livePath);
34
+
35
+ // A caller with the singleton open (e.g. the restore CLI between swap
36
+ // and resync) runs migrations on a path and keeps using ITS OWN
37
+ // connection. The old teardown called closeDb(), which closes the
38
+ // singleton — a different connection — so the caller's next query died
39
+ // with a closed-connection error.
40
+ const db = getDb();
41
+ db.insert(systemConfig).values({ key: 'probe', value: 'before' }).run();
42
+
43
+ const otherPath = join(dir, 'other.db');
44
+ await runMigrations(otherPath);
45
+
46
+ // The singleton must have survived the migration on the other path.
47
+ expect(() => db.select().from(systemConfig).limit(1).all()).not.toThrow();
48
+ closeDb();
49
+ } finally {
50
+ closeDb();
51
+ process.env.CELILO_DB_PATH = undefined;
52
+ rmSync(dir, { recursive: true, force: true });
47
53
  }
48
54
  });
49
55
 
50
- /**
51
- * Freeze the ledger to its oldest entry, leaving the schema fully applied.
52
- * This is the hand-list-era shape: the objects exist, the ledger has
53
- * forgotten who made them.
54
- */
55
- const freezeWatermark = (db: DbClient): void => {
56
- db.$client.run(
57
- 'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
58
- );
59
- };
60
-
61
- const appliedCount = (db: DbClient): number =>
62
- db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
63
- ?.c ?? 0;
64
-
65
- test('converges instead of dying on the first already-applied statement', () => {
66
- const { db } = freshDb();
67
- const migrationCount = appliedCount(db);
68
- freezeWatermark(db);
69
- expect(appliedCount(db)).toBe(1);
70
-
71
- // Red before the ledger repair: drizzle re-runs 0001 and throws
72
- // "duplicate column name: role", leaving the ledger frozen.
73
- expect(() => runMigrationsOn(db)).not.toThrow();
74
-
75
- expect(appliedCount(db)).toBe(migrationCount);
76
- });
77
-
78
- test('leaves the schema whole, so the drift detector goes green', () => {
79
- const { db } = freshDb();
80
- freezeWatermark(db);
81
-
82
- runMigrationsOn(db);
83
-
84
- const drift = findSchemaDrift(db.$client);
85
- expect(drift.missingTables).toEqual([]);
86
- expect(drift.missingColumns).toEqual([]);
87
- });
88
-
89
- test('is idempotent — a second pass applies nothing and still converges', () => {
90
- const { db } = freshDb();
91
- freezeWatermark(db);
92
- runMigrationsOn(db);
93
- const afterBaseline = appliedCount(db);
94
-
95
- runMigrationsOn(db);
96
-
97
- expect(appliedCount(db)).toBe(afterBaseline);
98
- });
99
-
100
- /**
101
- * The repair must not become a blanket "assume it already ran". It fires only
102
- * where the answer is unambiguous — the declared schema is entirely present,
103
- * so every migration plainly did run and the ledger is what is wrong. A
104
- * PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
105
- * was actually in: it carried 0011's column and not 0010's table. Stamping
106
- * there would record migrations that never ran and bury the missing schema
107
- * for good, so it keeps failing and a human decides.
108
- */
109
- test('refuses to stamp when the schema is only partly there', () => {
110
- const { db } = freshDb();
111
- freezeWatermark(db);
112
- db.$client.run('DROP TABLE `dns_registrations`');
113
-
114
- expect(() => runMigrationsOn(db)).toThrow();
115
- // The ledger is left exactly as found, so the drift is still diagnosable.
116
- expect(appliedCount(db)).toBe(1);
117
- });
118
-
119
- /**
120
- * The path that actually matters. Every celilo command opens the database
121
- * through createDbClient, which migrates on open, so a frozen watermark
122
- * failed there rather than anywhere an operator could aim a fix at — and
123
- * `celilo system migrate`, the command whose whole job is repairing this,
124
- * reached its own repair through the same open and died first.
125
- */
126
- test('repairs on database open, not just when migrate is called by hand', () => {
127
- const { db, path } = freshDb();
128
- const migrationCount = appliedCount(db);
129
- freezeWatermark(db);
130
- db.$client.close();
131
-
132
- const reopened = createDbClient({ path });
133
-
134
- expect(appliedCount(reopened)).toBe(migrationCount);
135
- expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
136
- });
137
-
138
- test('a healthy database is untouched by the fallback', () => {
139
- const { db } = freshDb();
140
- const before = appliedCount(db);
141
-
142
- runMigrationsOn(db);
143
-
144
- expect(appliedCount(db)).toBe(before);
145
- expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
56
+ it('closes its own connection: the db reopens cleanly afterwards', async () => {
57
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-own-conn-test-'));
58
+ try {
59
+ const dbPath = join(dir, 'celilo.db');
60
+ await runMigrations(dbPath);
61
+
62
+ // A fresh connection must be able to take the database over. The old
63
+ // closeDb() closed nothing here (the singleton was null), so this
64
+ // statement ran against a connection held open by nobody's bookkeeping
65
+ // — released only by GC, at a time of GC's choosing.
66
+ const probe = new Database(dbPath, { readonly: true });
67
+ expect(probe.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()).toBeDefined();
68
+ probe.close();
69
+ } finally {
70
+ rmSync(dir, { recursive: true, force: true });
71
+ }
146
72
  });
147
73
  });
package/src/db/migrate.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
3
3
  import { readMigrationFiles } from 'drizzle-orm/migrator';
4
- import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
4
+ import { type DbClient, createDbClient, findMigrationsFolder } from './client';
5
5
  import { findSchemaDrift } from './schema-introspection';
6
6
 
7
7
  /**
@@ -93,7 +93,12 @@ export async function runMigrations(dbPath?: string) {
93
93
  console.error('Migration failed:', error);
94
94
  throw error;
95
95
  } finally {
96
- closeDb();
96
+ // Close the connection THIS call created. It is not the singleton —
97
+ // createDbClient does not register one — so the old closeDb() here closed
98
+ // whatever else was open (usually nothing) and leaked this connection,
99
+ // which then held the file in WAL and blocked every later journal-mode
100
+ // switch on it (celilo#1269).
101
+ db.$client.close();
97
102
  }
98
103
  }
99
104
 
package/src/db/schema.ts CHANGED
@@ -126,6 +126,16 @@ export const moduleConfigs = sqliteTable(
126
126
  key: text('key').notNull(),
127
127
  value: text('value').notNull(),
128
128
  valueJson: text('value_json'), // JSON for complex types (arrays, objects)
129
+ /**
130
+ * Who owns the row (hook-owned-state design D7 — one column shared with
131
+ * `derived-value-recomputation`). 'hook' means the owning module's own
132
+ * hook wrote it via `context.config.set`, and no operator path may touch
133
+ * it. NULL is every row written before the column existed — operator
134
+ * intent, legacy derived seeds, and framework keys alike — and stays the
135
+ * value for operator writes until derived-value-recomputation lands its
136
+ * own classification.
137
+ */
138
+ source: text('source'),
129
139
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
130
140
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
131
141
  },