@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
}
|
package/src/db/migrate.test.ts
CHANGED
|
@@ -1,147 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tests for the standalone migration entrypoint's connection lifecycle.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
26
|
-
import {
|
|
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 {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
describe('
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
},
|