@celilo/cli 0.4.0 → 0.4.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/package.json
CHANGED
|
@@ -98,6 +98,11 @@ export async function handleRestore(
|
|
|
98
98
|
if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
|
|
99
99
|
if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
|
|
100
100
|
if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
|
|
101
|
+
if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` • module source laid down for ${result.moduleSourcesApplied} module(s) (paths reconciled to this box)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
101
106
|
if (result.crossModuleApplied && result.crossModuleApplied.length > 0) {
|
|
102
107
|
lines.push(
|
|
103
108
|
` • terraform state restored for ${result.crossModuleApplied.length} module(s): ${result.crossModuleApplied.join(', ')}`,
|
package/src/module/import.ts
CHANGED
|
@@ -402,11 +402,16 @@ async function installScriptDependencies(
|
|
|
402
402
|
targetPath: string,
|
|
403
403
|
manifest: ModuleManifest,
|
|
404
404
|
): Promise<void> {
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
405
|
+
// A module's scripts/ holds hook scripts AND/OR capability-provider
|
|
406
|
+
// implementations (e.g. dns_registrar's register-host.ts, referenced via
|
|
407
|
+
// provides.capabilities, not hooks). BOTH import @celilo/capabilities and
|
|
408
|
+
// need deps. A capability-only provider (hooks: {}) still has scripts to
|
|
409
|
+
// resolve — keying solely off hooks left its node_modules un-vendored, so
|
|
410
|
+
// the capability-loader's import() failed with ENOENT @celilo/capabilities.
|
|
411
|
+
const hasHooks = Boolean(manifest.hooks && Object.keys(manifest.hooks).length > 0);
|
|
412
|
+
const hasCapabilities = (manifest.provides?.capabilities?.length ?? 0) > 0;
|
|
413
|
+
if (!hasHooks && !hasCapabilities) {
|
|
414
|
+
return; // No hook or capability scripts → nothing to install
|
|
410
415
|
}
|
|
411
416
|
|
|
412
417
|
const scriptsDir = join(targetPath, 'scripts');
|
|
@@ -147,11 +147,30 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
147
147
|
).toBe(true);
|
|
148
148
|
expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true);
|
|
149
149
|
|
|
150
|
-
expect(result.schema_version).toBe('1.
|
|
150
|
+
expect(result.schema_version).toBe('1.1');
|
|
151
151
|
expect(result.artifact_count).toBeGreaterThan(0);
|
|
152
152
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it('captures module SOURCE (excluding generated/) into module_src/', async () => {
|
|
156
|
+
// stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
|
|
157
|
+
const modSrc = join(dir, 'modules', 'caddy');
|
|
158
|
+
mkdirSync(join(modSrc, 'scripts'), { recursive: true });
|
|
159
|
+
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
160
|
+
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
161
|
+
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
162
|
+
writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
|
|
163
|
+
|
|
164
|
+
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
165
|
+
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
166
|
+
|
|
167
|
+
// Source files captured...
|
|
168
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
|
|
169
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
|
|
170
|
+
// ...but generated/ excluded (TF state travels via cross_module_state).
|
|
171
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
155
174
|
it('machine-pool.json is valid JSON (array)', async () => {
|
|
156
175
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
157
176
|
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
@@ -168,7 +187,7 @@ describe('celilo-mgmt on_backup', () => {
|
|
|
168
187
|
|
|
169
188
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
170
189
|
expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
|
|
171
|
-
expect(result.schema_version).toBe('1.
|
|
190
|
+
expect(result.schema_version).toBe('1.1');
|
|
172
191
|
});
|
|
173
192
|
|
|
174
193
|
it('proceeds (with a warning) when master.key is missing on disk', async () => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* via this service) is an e2e concern, not exercised at the unit level.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { Database } from 'bun:sqlite';
|
|
11
12
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
12
13
|
import {
|
|
13
14
|
existsSync,
|
|
@@ -38,11 +39,14 @@ describe('applyStagedSystemFiles', () => {
|
|
|
38
39
|
keyPath = join(dir, 'master.key');
|
|
39
40
|
process.env.CELILO_DB_PATH = livePath;
|
|
40
41
|
process.env.CELILO_MASTER_KEY_PATH = keyPath;
|
|
42
|
+
process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
afterEach(() => {
|
|
46
|
+
closeDb();
|
|
44
47
|
process.env.CELILO_DB_PATH = undefined;
|
|
45
48
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
49
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
46
50
|
try {
|
|
47
51
|
rmSync(dir, { recursive: true, force: true });
|
|
48
52
|
} catch {
|
|
@@ -124,6 +128,48 @@ describe('applyStagedSystemFiles', () => {
|
|
|
124
128
|
expect(result.dbApplied).toBe(false);
|
|
125
129
|
expect(result.keyApplied).toBe(false);
|
|
126
130
|
});
|
|
131
|
+
|
|
132
|
+
it('lays down staged module source dirs at the modules storage path', () => {
|
|
133
|
+
const stagedSrc = join(stagingDir, 'module_src');
|
|
134
|
+
mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true });
|
|
135
|
+
writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy');
|
|
136
|
+
writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook');
|
|
137
|
+
|
|
138
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
139
|
+
expect(result.moduleSourcesApplied).toBe(1);
|
|
140
|
+
// getModuleStoragePath() = <CELILO_DATA_DIR>/modules = dir/modules.
|
|
141
|
+
const laid = join(dir, 'modules', 'caddy');
|
|
142
|
+
expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy');
|
|
143
|
+
expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rewrites every module's source_path to this box on the staged DB before swap", () => {
|
|
147
|
+
// A real staged SQLite DB whose module points at a FOREIGN (macOS) path —
|
|
148
|
+
// exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal
|
|
149
|
+
// self-contained DB (no runMigrations, which would hold a connection and
|
|
150
|
+
// lock the journal-mode switch the rewrite needs).
|
|
151
|
+
const stagedDbPath = join(stagingDir, 'celilo.db');
|
|
152
|
+
const seed = new Database(stagedDbPath);
|
|
153
|
+
seed.run(
|
|
154
|
+
'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)',
|
|
155
|
+
);
|
|
156
|
+
seed.run(
|
|
157
|
+
"INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')",
|
|
158
|
+
);
|
|
159
|
+
seed.close();
|
|
160
|
+
|
|
161
|
+
const result = applyStagedSystemFiles(stagingDir);
|
|
162
|
+
expect(result.dbApplied).toBe(true);
|
|
163
|
+
|
|
164
|
+
// The swapped-in live DB must now point at THIS box's modules dir, not the
|
|
165
|
+
// source box's dead macOS path.
|
|
166
|
+
const live = new Database(livePath);
|
|
167
|
+
const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as {
|
|
168
|
+
sp: string;
|
|
169
|
+
};
|
|
170
|
+
live.close();
|
|
171
|
+
expect(row.sp).toBe(join(dir, 'modules', 'caddy'));
|
|
172
|
+
});
|
|
127
173
|
});
|
|
128
174
|
|
|
129
175
|
describe('restoreFromArtifactFile error paths', () => {
|
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
* of the restore the hook itself cannot do (live DB is open).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { Database } from 'bun:sqlite';
|
|
19
20
|
import { execSync } from 'node:child_process';
|
|
20
21
|
import {
|
|
21
22
|
chmodSync,
|
|
23
|
+
closeSync,
|
|
22
24
|
copyFileSync,
|
|
25
|
+
cpSync,
|
|
23
26
|
existsSync,
|
|
24
27
|
mkdirSync,
|
|
28
|
+
openSync,
|
|
25
29
|
readFileSync,
|
|
30
|
+
readSync,
|
|
26
31
|
readdirSync,
|
|
27
32
|
rmSync,
|
|
28
33
|
writeFileSync,
|
|
@@ -30,7 +35,7 @@ import {
|
|
|
30
35
|
import { tmpdir } from 'node:os';
|
|
31
36
|
import { dirname, join } from 'node:path';
|
|
32
37
|
import { eq } from 'drizzle-orm';
|
|
33
|
-
import { getDbPath, getMasterKeyPath } from '../config/paths';
|
|
38
|
+
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
34
39
|
import { closeDb, getDb } from '../db/client';
|
|
35
40
|
import { runMigrations } from '../db/migrate';
|
|
36
41
|
import { modules } from '../db/schema';
|
|
@@ -61,6 +66,8 @@ export interface RestoreFromFileResult {
|
|
|
61
66
|
masterKeyApplied?: boolean;
|
|
62
67
|
/** Was the fleet SSH keypair restored to <dataDir>/.ssh/? */
|
|
63
68
|
sshKeyApplied?: boolean;
|
|
69
|
+
/** How many module source dirs were laid down at the target's modules dir. */
|
|
70
|
+
moduleSourcesApplied?: number;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
export interface RestoreFromFileOptions {
|
|
@@ -258,6 +265,7 @@ export async function restoreFromArtifactFile(
|
|
|
258
265
|
systemDbApplied: apply.dbApplied,
|
|
259
266
|
masterKeyApplied: apply.keyApplied,
|
|
260
267
|
sshKeyApplied: apply.sshApplied,
|
|
268
|
+
moduleSourcesApplied: apply.moduleSourcesApplied,
|
|
261
269
|
};
|
|
262
270
|
} finally {
|
|
263
271
|
try {
|
|
@@ -272,6 +280,61 @@ export interface StagedSystemApplyResult {
|
|
|
272
280
|
dbApplied: boolean;
|
|
273
281
|
keyApplied: boolean;
|
|
274
282
|
sshApplied: boolean;
|
|
283
|
+
/** How many module source dirs were laid down at the target's modules dir. */
|
|
284
|
+
moduleSourcesApplied: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Cheap check for the SQLite file magic without reading the whole DB. */
|
|
288
|
+
function looksLikeSqlite(path: string): boolean {
|
|
289
|
+
let fd: number;
|
|
290
|
+
try {
|
|
291
|
+
fd = openSync(path, 'r');
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const buf = Buffer.alloc(16);
|
|
297
|
+
readSync(fd, buf, 0, 16, 0);
|
|
298
|
+
// Magic header is "SQLite format 3" (15 bytes) + a NUL terminator.
|
|
299
|
+
return buf.subarray(0, 15).toString('utf-8') === 'SQLite format 3' && buf[15] === 0;
|
|
300
|
+
} finally {
|
|
301
|
+
closeSync(fd);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Recompute every module's source_path to THIS box's modules dir, on the
|
|
307
|
+
* STAGED DB file (pre-swap). The artifact carries the SOURCE box's absolute
|
|
308
|
+
* source_path (e.g. macOS `~/Library/Application Support/celilo/modules/<id>`),
|
|
309
|
+
* which doesn't exist on a different box/OS — so a restored DB references module
|
|
310
|
+
* code at a dead path and no deploy can generate. By construction (import.ts)
|
|
311
|
+
* source_path is always `${dataDir}/modules/<id>`, so it's safe to recompute for
|
|
312
|
+
* the target; a no-op when the paths already match (same-OS restore).
|
|
313
|
+
*
|
|
314
|
+
* Done on the staged file (a fresh connection that's about to be swapped in),
|
|
315
|
+
* never the live DB, so there's no post-swap in-process reopen (ISS-0037). The
|
|
316
|
+
* PRAGMA forces commits into the main file (no -wal sibling) so the copy below
|
|
317
|
+
* captures the rewrite; the live DB re-enters WAL when celilo reopens it.
|
|
318
|
+
*/
|
|
319
|
+
function rewriteStagedModuleSourcePaths(stagedDbPath: string): void {
|
|
320
|
+
// Tolerate non-SQLite staged files: some callers/tests stage placeholder
|
|
321
|
+
// bytes to exercise the file-copy path. A real artifact DB always opens
|
|
322
|
+
// with the "SQLite format 3\0" magic header — skip anything that doesn't
|
|
323
|
+
// (a valid-header-but-corrupt DB still surfaces by throwing below).
|
|
324
|
+
if (!looksLikeSqlite(stagedDbPath)) return;
|
|
325
|
+
|
|
326
|
+
const moduleStorageBase = getModuleStoragePath();
|
|
327
|
+
const staged = new Database(stagedDbPath);
|
|
328
|
+
try {
|
|
329
|
+
staged.query('PRAGMA journal_mode = DELETE').run();
|
|
330
|
+
const rows = staged.query('SELECT id FROM modules').all() as Array<{ id: string }>;
|
|
331
|
+
const update = staged.query('UPDATE modules SET source_path = ? WHERE id = ?');
|
|
332
|
+
for (const { id } of rows) {
|
|
333
|
+
update.run(join(moduleStorageBase, id), id);
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
staged.close();
|
|
337
|
+
}
|
|
275
338
|
}
|
|
276
339
|
|
|
277
340
|
/**
|
|
@@ -291,14 +354,16 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
291
354
|
let dbApplied = false;
|
|
292
355
|
let keyApplied = false;
|
|
293
356
|
let sshApplied = false;
|
|
357
|
+
let moduleSourcesApplied = 0;
|
|
294
358
|
|
|
295
359
|
if (!existsSync(systemStagingDir)) {
|
|
296
|
-
return { dbApplied, keyApplied, sshApplied };
|
|
360
|
+
return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
|
|
297
361
|
}
|
|
298
362
|
|
|
299
363
|
const stagedDb = join(systemStagingDir, 'celilo.db');
|
|
300
364
|
const stagedKey = join(systemStagingDir, 'master.key');
|
|
301
365
|
const stagedSsh = join(systemStagingDir, 'ssh');
|
|
366
|
+
const stagedModuleSrc = join(systemStagingDir, 'module_src');
|
|
302
367
|
|
|
303
368
|
// master.key first: it's not load-bearing for the running process
|
|
304
369
|
// (already in memory if the daemon's running). Safe to swap before
|
|
@@ -329,9 +394,31 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
329
394
|
sshApplied = true;
|
|
330
395
|
}
|
|
331
396
|
|
|
397
|
+
// Module SOURCE code → lay down at THIS box's modules dir so the restored
|
|
398
|
+
// box has the code for EVERY module (incl. non-registry ones like lunacycle).
|
|
399
|
+
// on_backup captured each module's source; without this the restored DB
|
|
400
|
+
// references modules whose code isn't on disk and no deploy can generate.
|
|
401
|
+
// The artifact carries no generated/ subtree, so any existing generated/
|
|
402
|
+
// under a module dir (e.g. restored TF state) is preserved by the merge.
|
|
403
|
+
if (existsSync(stagedModuleSrc)) {
|
|
404
|
+
const moduleStorageBase = getModuleStoragePath();
|
|
405
|
+
mkdirSync(moduleStorageBase, { recursive: true });
|
|
406
|
+
for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
|
|
407
|
+
if (!entry.isDirectory()) continue;
|
|
408
|
+
cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
|
|
409
|
+
recursive: true,
|
|
410
|
+
});
|
|
411
|
+
moduleSourcesApplied += 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
332
415
|
// celilo.db: must close the live DB connection before replacing
|
|
333
416
|
// the file or SQLite gets confused (macOS holds a vnode handle).
|
|
334
417
|
if (existsSync(stagedDb)) {
|
|
418
|
+
// Reconcile module source_paths to THIS box on the staged file BEFORE the
|
|
419
|
+
// swap (and before closeDb) — fixes cross-host / cross-OS restores. See
|
|
420
|
+
// rewriteStagedModuleSourcePaths.
|
|
421
|
+
rewriteStagedModuleSourcePaths(stagedDb);
|
|
335
422
|
closeDb();
|
|
336
423
|
const livePath = getDbPath();
|
|
337
424
|
mkdirSync(join(livePath, '..'), { recursive: true });
|
|
@@ -348,7 +435,7 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
348
435
|
dbApplied = true;
|
|
349
436
|
}
|
|
350
437
|
|
|
351
|
-
return { dbApplied, keyApplied, sshApplied };
|
|
438
|
+
return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
|
|
352
439
|
}
|
|
353
440
|
|
|
354
441
|
/**
|