@celilo/cli 2.1.0 → 2.2.1
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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- 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 +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- 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.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- 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 +168 -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 +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- 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 +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- 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 +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -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/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -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/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- 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/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
package/src/cli/index.ts
CHANGED
|
@@ -631,6 +631,7 @@ Subcommands:
|
|
|
631
631
|
--preflight Run pre-flight validation only (no deployment)
|
|
632
632
|
--verbose Keep all sub-events visible (no collapse-on-success)
|
|
633
633
|
--stop-after-interview Exit after config+secrets interview (no infra/hooks)
|
|
634
|
+
--keep Retain the generated project after a successful deploy (it is deleted by design)
|
|
634
635
|
|
|
635
636
|
health [module-id] Run health checks (transitions to VERIFIED on success)
|
|
636
637
|
Options:
|
|
@@ -951,9 +952,10 @@ Usage:
|
|
|
951
952
|
Shows how celilo classifies every interface on a firewall: the zone it
|
|
952
953
|
matched, the external edge, or ALIEN when nothing accounts for it.
|
|
953
954
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
955
|
+
It reads the interface table LIVE from the box, the same way the next
|
|
956
|
+
converge will, so its answer is the answer the converge will act on. If the
|
|
957
|
+
box cannot be reached, it says so and classifies the interface snapshot
|
|
958
|
+
recorded at machine add instead, labelled as possibly stale.
|
|
957
959
|
|
|
958
960
|
With no hostname, every machine celilo classifies as a router is shown.
|
|
959
961
|
|
|
@@ -2237,7 +2239,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2237
2239
|
}
|
|
2238
2240
|
const configArgs = parsed.args.slice(1);
|
|
2239
2241
|
if (configSubcommand === 'set') {
|
|
2240
|
-
return handleSystemConfigSet(configArgs);
|
|
2242
|
+
return await handleSystemConfigSet(configArgs, parsed.flags);
|
|
2241
2243
|
}
|
|
2242
2244
|
if (configSubcommand === 'get') {
|
|
2243
2245
|
return handleSystemConfigGet(configArgs);
|
|
@@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { closeDb, getDb } from '../db/client';
|
|
17
17
|
import { runMigrations } from '../db/migrate';
|
|
18
18
|
import { modules } from '../db/schema';
|
|
19
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
19
20
|
import { runCli } from './index';
|
|
20
21
|
|
|
21
22
|
describe('celilo restore', () => {
|
|
@@ -32,7 +33,7 @@ describe('celilo restore', () => {
|
|
|
32
33
|
|
|
33
34
|
afterEach(() => {
|
|
34
35
|
closeDb();
|
|
35
|
-
|
|
36
|
+
resetTestDbPath();
|
|
36
37
|
process.env.CELILO_DATA_DIR = undefined;
|
|
37
38
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
38
39
|
process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
|
|
@@ -0,0 +1,160 @@
|
|
|
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 { resetTestDbPath } from '../test-utils/db-path';
|
|
32
|
+
import { runCli } from './index';
|
|
33
|
+
|
|
34
|
+
const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures');
|
|
35
|
+
const STAGING_HOOK = join(FIXTURES_DIR, 'on-restore-staging-hook.ts');
|
|
36
|
+
const MODULE_ID = 'restore-migration-test';
|
|
37
|
+
|
|
38
|
+
/** The manifest the module row carries: one on_restore hook, the fixture. */
|
|
39
|
+
const MODULE_MANIFEST = {
|
|
40
|
+
celilo_contract: '1.0',
|
|
41
|
+
id: MODULE_ID,
|
|
42
|
+
name: 'Restore Migration Test',
|
|
43
|
+
version: '0.0.1',
|
|
44
|
+
hooks: {
|
|
45
|
+
on_restore: {
|
|
46
|
+
script: STAGING_HOOK,
|
|
47
|
+
timeout: 60000,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build an encrypted module artifact whose envelope carries `dbBytes` as the
|
|
54
|
+
* backed-up celilo.db. The fixture on_restore hook stages it into system/ so
|
|
55
|
+
* the restore swaps it over the live DB before the migration step runs.
|
|
56
|
+
*/
|
|
57
|
+
async function buildModuleArtifact(dir: string, dbBytes: string): Promise<string> {
|
|
58
|
+
const envelopeDir = join(dir, 'envelope-build');
|
|
59
|
+
mkdirSync(join(envelopeDir, 'data'), { recursive: true });
|
|
60
|
+
writeFileSync(
|
|
61
|
+
join(envelopeDir, 'manifest.json'),
|
|
62
|
+
JSON.stringify(buildManifest({ kind: 'module', moduleId: MODULE_ID, moduleVersion: '0.0.1' })),
|
|
63
|
+
);
|
|
64
|
+
writeFileSync(join(envelopeDir, 'data', 'celilo.db'), dbBytes);
|
|
65
|
+
const tarPath = join(dir, 'envelope.tar');
|
|
66
|
+
await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']);
|
|
67
|
+
const masterKey = await getOrCreateMasterKey();
|
|
68
|
+
const artifactPath = join(dir, 'module.backup');
|
|
69
|
+
await encryptFileToFile(tarPath, artifactPath, masterKey);
|
|
70
|
+
return artifactPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('restore migration failure reporting (celilo#1269)', () => {
|
|
74
|
+
let dir: string;
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-restore-mig-fail-test-'));
|
|
78
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
79
|
+
process.env.CELILO_DATA_DIR = dir;
|
|
80
|
+
process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
|
|
81
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
82
|
+
|
|
83
|
+
// The artifact's module must already be imported (restore-from-file's
|
|
84
|
+
// contract with bootstrap.sh). Its sourcePath points at the workspace
|
|
85
|
+
// fixture dir: the canonical <dataDir>/modules/<id> does not exist here,
|
|
86
|
+
// so restore-from-file falls back to sourcePath for the hook.
|
|
87
|
+
const db = getDb();
|
|
88
|
+
db.insert(modules)
|
|
89
|
+
.values({
|
|
90
|
+
id: MODULE_ID,
|
|
91
|
+
name: 'Restore Migration Test',
|
|
92
|
+
version: '0.0.1',
|
|
93
|
+
sourcePath: FIXTURES_DIR,
|
|
94
|
+
manifestData: MODULE_MANIFEST,
|
|
95
|
+
})
|
|
96
|
+
.run();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
closeDb();
|
|
101
|
+
resetTestDbPath();
|
|
102
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
103
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
104
|
+
try {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a restore whose migration step fails reports failure and names the recovery path', async () => {
|
|
112
|
+
// The artifact's celilo.db is not a database at all. The swap lands it
|
|
113
|
+
// (staging bytes are deliberately tolerated so the file-copy path stays
|
|
114
|
+
// testable), and the migration step then fails on open — the same
|
|
115
|
+
// failure surface a locked or half-restored DB produces.
|
|
116
|
+
const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
|
|
117
|
+
const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(false);
|
|
120
|
+
const err = result.success ? '' : (result.error ?? '');
|
|
121
|
+
expect(err).toContain('migrat');
|
|
122
|
+
expect(err).toContain('celilo system migrate');
|
|
123
|
+
expect(err).toContain('celilo system doctor');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the swap happened even when migrations failed (the error says so)', async () => {
|
|
127
|
+
const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
|
|
128
|
+
const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
|
|
129
|
+
|
|
130
|
+
expect(result.success).toBe(false);
|
|
131
|
+
const err = result.success ? '' : (result.error ?? '');
|
|
132
|
+
// The operator must learn the swap DID land — the failure is about the
|
|
133
|
+
// schema step, not a lost restore.
|
|
134
|
+
expect(err).toContain('restored');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The timeout is 30s, not the 5s default: the holder pins the write lock
|
|
138
|
+
// for the full busy_timeout window before the migration step gives up, and
|
|
139
|
+
// the two windows are the same 5s.
|
|
140
|
+
test('a connection held across the migration step throws naming the lock', async () => {
|
|
141
|
+
// Current schema, ledger emptied so drizzle has work to do, then a write
|
|
142
|
+
// lock held across the step. Deleting one ledger row is not enough: the
|
|
143
|
+
// watermark is created_at-based and every migration shares one timestamp,
|
|
144
|
+
// so the whole ledger has to go. The re-run hits "already exists", the
|
|
145
|
+
// ledger-repair path then tries to write, and that write blocks on the
|
|
146
|
+
// holder until busy_timeout expires.
|
|
147
|
+
const livePath = getDbPath();
|
|
148
|
+
const ledger = new Database(livePath);
|
|
149
|
+
ledger.run('DELETE FROM __drizzle_migrations');
|
|
150
|
+
ledger.close();
|
|
151
|
+
|
|
152
|
+
const holder = new Database(livePath);
|
|
153
|
+
holder.run('BEGIN IMMEDIATE');
|
|
154
|
+
try {
|
|
155
|
+
await expect(migrateRestoredDb()).rejects.toThrow(/locked/i);
|
|
156
|
+
} finally {
|
|
157
|
+
holder.close();
|
|
158
|
+
}
|
|
159
|
+
}, 30000);
|
|
160
|
+
});
|
package/src/config/paths.test.ts
CHANGED
|
@@ -141,7 +141,7 @@ describe('paths configuration', () => {
|
|
|
141
141
|
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
142
142
|
'returns data dir + celilo.db on macOS in production',
|
|
143
143
|
() => {
|
|
144
|
-
process.env.CELILO_DB_PATH
|
|
144
|
+
delete process.env.CELILO_DB_PATH;
|
|
145
145
|
process.env.CELILO_DATA_DIR = undefined;
|
|
146
146
|
process.env.ENVIRONMENT = undefined;
|
|
147
147
|
|
|
@@ -156,7 +156,7 @@ describe('paths configuration', () => {
|
|
|
156
156
|
);
|
|
157
157
|
|
|
158
158
|
it('returns celilo-data/celilo.db in development mode', () => {
|
|
159
|
-
process.env.CELILO_DB_PATH
|
|
159
|
+
delete process.env.CELILO_DB_PATH;
|
|
160
160
|
process.env.CELILO_DATA_DIR = undefined;
|
|
161
161
|
process.env.ENVIRONMENT = 'dev';
|
|
162
162
|
|
|
@@ -165,7 +165,7 @@ describe('paths configuration', () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('respects CELILO_DATA_DIR override', () => {
|
|
168
|
-
process.env.CELILO_DB_PATH
|
|
168
|
+
delete process.env.CELILO_DB_PATH;
|
|
169
169
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
170
170
|
process.env.ENVIRONMENT = undefined;
|
|
171
171
|
|
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,74 @@
|
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
23
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
24
|
+
import { closeDb, getDb } from './client';
|
|
25
|
+
import { runMigrations } from './migrate';
|
|
26
|
+
import { systemConfig } from './schema';
|
|
27
|
+
|
|
28
|
+
describe('runMigrations connection lifecycle', () => {
|
|
29
|
+
it("does not close the caller's singleton connection", async () => {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-close-test-'));
|
|
31
|
+
try {
|
|
32
|
+
const livePath = join(dir, 'celilo.db');
|
|
33
|
+
process.env.CELILO_DB_PATH = livePath;
|
|
34
|
+
await runMigrations(livePath);
|
|
35
|
+
|
|
36
|
+
// A caller with the singleton open (e.g. the restore CLI between swap
|
|
37
|
+
// and resync) runs migrations on a path and keeps using ITS OWN
|
|
38
|
+
// connection. The old teardown called closeDb(), which closes the
|
|
39
|
+
// singleton — a different connection — so the caller's next query died
|
|
40
|
+
// with a closed-connection error.
|
|
41
|
+
const db = getDb();
|
|
42
|
+
db.insert(systemConfig).values({ key: 'probe', value: 'before' }).run();
|
|
43
|
+
|
|
44
|
+
const otherPath = join(dir, 'other.db');
|
|
45
|
+
await runMigrations(otherPath);
|
|
46
|
+
|
|
47
|
+
// The singleton must have survived the migration on the other path.
|
|
48
|
+
expect(() => db.select().from(systemConfig).limit(1).all()).not.toThrow();
|
|
49
|
+
closeDb();
|
|
50
|
+
} finally {
|
|
51
|
+
closeDb();
|
|
52
|
+
resetTestDbPath();
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
54
|
}
|
|
48
55
|
});
|
|
49
56
|
|
|
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([]);
|
|
57
|
+
it('closes its own connection: the db reopens cleanly afterwards', async () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-own-conn-test-'));
|
|
59
|
+
try {
|
|
60
|
+
const dbPath = join(dir, 'celilo.db');
|
|
61
|
+
await runMigrations(dbPath);
|
|
62
|
+
|
|
63
|
+
// A fresh connection must be able to take the database over. The old
|
|
64
|
+
// closeDb() closed nothing here (the singleton was null), so this
|
|
65
|
+
// statement ran against a connection held open by nobody's bookkeeping
|
|
66
|
+
// — released only by GC, at a time of GC's choosing.
|
|
67
|
+
const probe = new Database(dbPath, { readonly: true });
|
|
68
|
+
expect(probe.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()).toBeDefined();
|
|
69
|
+
probe.close();
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
146
73
|
});
|
|
147
74
|
});
|
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
|
/**
|
|
@@ -80,21 +80,26 @@ export function runMigrationsOn(db: DbClient): void {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
83
|
+
* Open a database at `dbPath` with migrations applied, then close it.
|
|
84
|
+
* Standalone entrypoint (`bun run src/db/migrate.ts`); tests also use it to
|
|
85
|
+
* prepare a fresh scratch database.
|
|
86
|
+
*
|
|
87
|
+
* createDbClient migrates synchronously on open (its own runMigrationsOn
|
|
88
|
+
* call), so this function is open + close. It deliberately does NOT call
|
|
89
|
+
* runMigrationsOn again: a second pass is always a ledger no-op when the
|
|
90
|
+
* first succeeded, and keeping it invited a double-migrate everywhere this
|
|
91
|
+
* entrypoint is used (celilo#1315).
|
|
85
92
|
*/
|
|
86
93
|
export async function runMigrations(dbPath?: string) {
|
|
87
94
|
console.log('Running database migrations...');
|
|
88
95
|
const db = createDbClient(dbPath ? { path: dbPath } : undefined);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
closeDb();
|
|
97
|
-
}
|
|
96
|
+
console.log('Migrations completed successfully');
|
|
97
|
+
// Close the connection THIS call created. It is not the singleton —
|
|
98
|
+
// createDbClient does not register one — so an old closeDb() here closed
|
|
99
|
+
// whatever else was open (usually nothing) and leaked this connection,
|
|
100
|
+
// which then held the file in WAL and blocked every later journal-mode
|
|
101
|
+
// switch on it (celilo#1269).
|
|
102
|
+
db.$client.close();
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
// Run migrations if executed directly
|
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
|
},
|