@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.5
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/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +5 -0
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader.ts +24 -15
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/templates/generator.ts +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE `dns_internal_records` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`provider_module_id` text NOT NULL,
|
|
4
|
+
`consumer_module_id` text NOT NULL,
|
|
5
|
+
`host` text NOT NULL,
|
|
6
|
+
`ip` text NOT NULL,
|
|
7
|
+
`registered_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
8
|
+
FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
9
|
+
FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `backups` ADD `name` text;
|
|
@@ -71,6 +71,20 @@
|
|
|
71
71
|
"when": 1781280898000,
|
|
72
72
|
"tag": "0009_dns_registrations",
|
|
73
73
|
"breakpoints": true
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"idx": 10,
|
|
77
|
+
"version": "6",
|
|
78
|
+
"when": 1781481600000,
|
|
79
|
+
"tag": "0010_dns_internal_records",
|
|
80
|
+
"breakpoints": true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"idx": 11,
|
|
84
|
+
"version": "6",
|
|
85
|
+
"when": 1781481660000,
|
|
86
|
+
"tag": "0011_backups_name",
|
|
87
|
+
"breakpoints": true
|
|
74
88
|
}
|
|
75
89
|
]
|
|
76
90
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.5",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
55
|
-
"@celilo/capabilities": "^0.4.
|
|
55
|
+
"@celilo/capabilities": "^0.4.1",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
57
|
"@celilo/event-bus": "^0.1.6",
|
|
58
58
|
"@clack/prompts": "^1.1.0",
|
|
@@ -956,14 +956,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
956
956
|
},
|
|
957
957
|
],
|
|
958
958
|
},
|
|
959
|
+
{
|
|
960
|
+
name: 'migrate',
|
|
961
|
+
description: 'Apply pending database migrations (idempotent; safe to re-run)',
|
|
962
|
+
},
|
|
959
963
|
{
|
|
960
964
|
name: 'doctor',
|
|
961
|
-
description:
|
|
965
|
+
description:
|
|
966
|
+
'Diagnose system prerequisites, @celilo/* version drift, and (on a management plane) fleet-runtime drift',
|
|
962
967
|
flags: [
|
|
963
968
|
{
|
|
964
969
|
name: 'fix',
|
|
965
970
|
description:
|
|
966
|
-
'Repair
|
|
971
|
+
'Repair the auto-fixable findings: `bun link` drifted @celilo/* packages and resync bus subscribers',
|
|
972
|
+
takesValue: false,
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
name: 'fleet',
|
|
976
|
+
description:
|
|
977
|
+
'Force the fleet-runtime section (dispatcher, subscribers, capability chains) even without a celilo DB',
|
|
967
978
|
takesValue: false,
|
|
968
979
|
},
|
|
969
980
|
],
|
|
@@ -30,10 +30,20 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
35
|
import { dirname, join, resolve } from 'node:path';
|
|
36
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
36
37
|
import cliPkg from '../../../package.json' with { type: 'json' };
|
|
38
|
+
import { getDbPath, getEventBusPath } from '../../config/paths';
|
|
39
|
+
import { getDb } from '../../db/client';
|
|
40
|
+
import {
|
|
41
|
+
type FleetFinding,
|
|
42
|
+
type FleetFindingStatus,
|
|
43
|
+
checkSubscribers,
|
|
44
|
+
runFleetChecks,
|
|
45
|
+
} from '../../services/fleet-checks';
|
|
46
|
+
import { resyncAllSubscriptions } from '../../services/module-subscriptions';
|
|
37
47
|
import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
|
|
38
48
|
import type { CommandResult } from '../types';
|
|
39
49
|
|
|
@@ -296,6 +306,93 @@ function renderPrereqSection(): { lines: string[]; failingCount: number } {
|
|
|
296
306
|
return { lines, failingCount: failingPrerequisites(checks).length };
|
|
297
307
|
}
|
|
298
308
|
|
|
309
|
+
/**
|
|
310
|
+
* mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
|
|
311
|
+
* package.json). The fleet dispatcher check compares this against the
|
|
312
|
+
* running dispatcher's start time to spot a process on stale code. Null
|
|
313
|
+
* when the package can't be located (the staleness aspect is then skipped).
|
|
314
|
+
*/
|
|
315
|
+
function installedEventBusMtime(): number | null {
|
|
316
|
+
try {
|
|
317
|
+
const require = createRequire(import.meta.url);
|
|
318
|
+
return statSync(require.resolve('@celilo/event-bus/package.json')).mtimeMs;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const FLEET_GLYPH: Record<FleetFindingStatus, string> = {
|
|
325
|
+
ok: `${ANSI.green}✔${ANSI.reset}`,
|
|
326
|
+
warn: `${ANSI.yellow}⚠${ANSI.reset}`,
|
|
327
|
+
fail: `${ANSI.red}✗${ANSI.reset}`,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function renderFleetFinding(f: FleetFinding): string[] {
|
|
331
|
+
const lines = [` ${FLEET_GLYPH[f.status]} ${f.title} ${ANSI.dim}— ${f.summary}${ANSI.reset}`];
|
|
332
|
+
for (const d of f.detail) lines.push(` ${ANSI.dim}${d}${ANSI.reset}`);
|
|
333
|
+
if (f.remediation && f.status !== 'ok')
|
|
334
|
+
lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The fleet-runtime section: dispatcher / subscribers / capability-chain
|
|
340
|
+
* drift, read from the celilo DB + event bus. State-aware, so it's skipped
|
|
341
|
+
* cleanly on a fresh dev box with no DB unless `--fleet` forces it. `--fix`
|
|
342
|
+
* runs only the auto-fixable checks (today: subscribers resync).
|
|
343
|
+
*/
|
|
344
|
+
async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
|
|
345
|
+
lines: string[];
|
|
346
|
+
failCount: number;
|
|
347
|
+
warnCount: number;
|
|
348
|
+
}> {
|
|
349
|
+
const lines: string[] = ['Fleet runtime'];
|
|
350
|
+
const dbExists = existsSync(getDbPath());
|
|
351
|
+
|
|
352
|
+
if (!dbExists) {
|
|
353
|
+
if (opts.forced) {
|
|
354
|
+
lines.push(
|
|
355
|
+
` ${ANSI.dim}skipped — no celilo database at ${getDbPath()} (not a management plane)${ANSI.reset}`,
|
|
356
|
+
);
|
|
357
|
+
return { lines, failCount: 0, warnCount: 0 };
|
|
358
|
+
}
|
|
359
|
+
// Dev box, no --fleet: don't render the section at all.
|
|
360
|
+
return { lines: [], failCount: 0, warnCount: 0 };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
|
|
364
|
+
try {
|
|
365
|
+
const db = getDb();
|
|
366
|
+
let findings = await runFleetChecks(bus, db, {
|
|
367
|
+
installedCodeMtimeMs: installedEventBusMtime(),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (opts.fix) {
|
|
371
|
+
const subscribers = findings.find((f) => f.id === 'subscribers');
|
|
372
|
+
if (subscribers?.autoFixable && subscribers.status !== 'ok') {
|
|
373
|
+
const r = resyncAllSubscriptions();
|
|
374
|
+
lines.push(
|
|
375
|
+
` ${ANSI.dim}--fix: re-registered ${r.registered} subscription(s) from ${r.modules} module(s).${ANSI.reset}`,
|
|
376
|
+
);
|
|
377
|
+
// Re-evaluate the subscribers finding so the section shows the healed state.
|
|
378
|
+
const healed = checkSubscribers(bus, db);
|
|
379
|
+
findings = findings.map((f) => (f.id === 'subscribers' ? healed : f));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let failCount = 0;
|
|
384
|
+
let warnCount = 0;
|
|
385
|
+
for (const f of findings) {
|
|
386
|
+
lines.push(...renderFleetFinding(f));
|
|
387
|
+
if (f.status === 'fail') failCount++;
|
|
388
|
+
else if (f.status === 'warn') warnCount++;
|
|
389
|
+
}
|
|
390
|
+
return { lines, failCount, warnCount };
|
|
391
|
+
} finally {
|
|
392
|
+
bus.close();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
299
396
|
export async function handleSystemDoctor(
|
|
300
397
|
_args: string[],
|
|
301
398
|
flags: Record<string, string | boolean>,
|
|
@@ -397,61 +494,59 @@ export async function handleSystemDoctor(
|
|
|
397
494
|
|
|
398
495
|
const fix = flags.fix === true;
|
|
399
496
|
|
|
497
|
+
// Drift repair (bun link). Does NOT early-return — the fleet section
|
|
498
|
+
// still runs on a --fix invocation so a single `--fix` heals both.
|
|
400
499
|
if (fix && drifted.length > 0) {
|
|
401
500
|
lines.push(`Repairing ${drifted.length} drifted package(s) with \`bun link\`:`);
|
|
402
501
|
lines.push(...applyFix(drifted, cliRoot));
|
|
502
|
+
lines.push(`${ANSI.dim}\`bun unlink\` from each workspace dir reverses.${ANSI.reset}`);
|
|
503
|
+
lines.push('');
|
|
504
|
+
// Linked from the workspace now — no longer drift for the summary.
|
|
505
|
+
driftCount = 0;
|
|
506
|
+
drifted.length = 0;
|
|
507
|
+
} else if (fix && drifted.length === 0) {
|
|
508
|
+
lines.push(`${ANSI.dim}--fix: no drifted packages to repair.${ANSI.reset}`);
|
|
403
509
|
lines.push('');
|
|
510
|
+
} else if (drifted.length > 0) {
|
|
404
511
|
lines.push(
|
|
405
|
-
`${ANSI.dim}
|
|
512
|
+
`${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
|
|
406
513
|
);
|
|
407
|
-
|
|
408
|
-
// failed, surface that even on a successful --fix run.
|
|
409
|
-
if (prereqResult.failingCount > 0) {
|
|
410
|
-
return {
|
|
411
|
-
success: false,
|
|
412
|
-
error: `${prereqResult.failingCount} system prerequisite(s) missing or below minimum — install before running celilo`,
|
|
413
|
-
details: lines.join('\n'),
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
return {
|
|
417
|
-
success: true,
|
|
418
|
-
message: lines.join('\n'),
|
|
419
|
-
rawOutput: true,
|
|
420
|
-
};
|
|
514
|
+
lines.push('');
|
|
421
515
|
}
|
|
422
516
|
|
|
423
|
-
|
|
424
|
-
|
|
517
|
+
// Fleet-runtime section (state-aware; only renders on a management
|
|
518
|
+
// plane with a celilo DB, or when --fleet forces it).
|
|
519
|
+
const fleet = await renderFleetSection({ forced: flags.fleet === true, fix });
|
|
520
|
+
if (fleet.lines.length > 0) {
|
|
521
|
+
lines.push(...fleet.lines);
|
|
522
|
+
lines.push('');
|
|
425
523
|
}
|
|
426
524
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
// Distinguish prereq-only from drift-only from both — the
|
|
440
|
-
// operator's next move is different.
|
|
441
|
-
const errorPrefix =
|
|
442
|
-
driftCount === 0 && unresolvedCount === 0
|
|
443
|
-
? 'System prerequisites missing'
|
|
444
|
-
: prereqResult.failingCount === 0
|
|
445
|
-
? 'Drift detected'
|
|
446
|
-
: 'Issues detected';
|
|
525
|
+
// Unified summary: anything that fails the run, with a per-cause count.
|
|
526
|
+
const problems: string[] = [];
|
|
527
|
+
if (prereqResult.failingCount > 0) {
|
|
528
|
+
problems.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
|
|
529
|
+
}
|
|
530
|
+
if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
|
|
531
|
+
if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
|
|
532
|
+
if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
|
|
533
|
+
|
|
534
|
+
if (problems.length > 0) {
|
|
447
535
|
return {
|
|
448
536
|
success: false,
|
|
449
|
-
error:
|
|
537
|
+
error: `Issues detected: ${problems.join(', ')}`,
|
|
450
538
|
details: lines.join('\n'),
|
|
451
539
|
};
|
|
452
540
|
}
|
|
453
541
|
|
|
454
|
-
|
|
542
|
+
// Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
|
|
543
|
+
if (fleet.warnCount > 0) {
|
|
544
|
+
lines.push(
|
|
545
|
+
`${ANSI.yellow}OK with warnings${ANSI.reset} — ${fleet.warnCount} fleet warning(s); see above`,
|
|
546
|
+
);
|
|
547
|
+
} else {
|
|
548
|
+
lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
|
|
549
|
+
}
|
|
455
550
|
return {
|
|
456
551
|
success: true,
|
|
457
552
|
message: lines.join('\n'),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { closeDb } from '../../db/client';
|
|
6
|
+
import { handleSystemMigrate } from './system-migrate';
|
|
7
|
+
|
|
8
|
+
describe('handleSystemMigrate', () => {
|
|
9
|
+
let dir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), 'sysmig-'));
|
|
13
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
closeDb();
|
|
17
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
18
|
+
try {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
} catch {
|
|
21
|
+
/* ignore */
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports a fresh/current DB as up to date with the full schema', async () => {
|
|
26
|
+
const result = await handleSystemMigrate();
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
if (result.success) {
|
|
29
|
+
expect(result.message).toContain('up to date');
|
|
30
|
+
expect(result.message).toContain('tables');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is idempotent — a second run is also clean', async () => {
|
|
35
|
+
(await handleSystemMigrate()).success;
|
|
36
|
+
closeDb();
|
|
37
|
+
const second = await handleSystemMigrate();
|
|
38
|
+
expect(second.success).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system migrate` — apply pending DB migrations (ISS-0100).
|
|
3
|
+
*
|
|
4
|
+
* Drizzle's migrator is the single migration mechanism; createDbClient already
|
|
5
|
+
* auto-migrates on open, so this command is the explicit, operator-visible
|
|
6
|
+
* entrypoint the .deb postinst and celilo-mgmt deploy call. Idempotent: a
|
|
7
|
+
* current DB reports "up to date".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from 'bun:sqlite';
|
|
11
|
+
import { getDb } from '../../db/client';
|
|
12
|
+
import { runMigrationsOn } from '../../db/migrate';
|
|
13
|
+
import { findSchemaDrift } from '../../db/schema-introspection';
|
|
14
|
+
import type { CommandResult } from '../types';
|
|
15
|
+
|
|
16
|
+
function countApplied(sqlite: Database): number {
|
|
17
|
+
try {
|
|
18
|
+
const row = sqlite
|
|
19
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
20
|
+
.get();
|
|
21
|
+
return row?.c ?? 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function handleSystemMigrate(): Promise<CommandResult> {
|
|
28
|
+
// getDb() auto-migrates on open; do it inside try so an existing DB that
|
|
29
|
+
// predates the drizzle-authoritative change fails with an actionable message
|
|
30
|
+
// instead of a raw migrator error.
|
|
31
|
+
let db: ReturnType<typeof getDb>;
|
|
32
|
+
try {
|
|
33
|
+
db = getDb();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanly — see ISS-0100.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sqlite = db.$client;
|
|
43
|
+
const before = countApplied(sqlite);
|
|
44
|
+
try {
|
|
45
|
+
runMigrationsOn(db); // idempotent re-assert
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
}
|
|
49
|
+
const applied = countApplied(sqlite) - before;
|
|
50
|
+
|
|
51
|
+
const drift = findSchemaDrift(sqlite);
|
|
52
|
+
if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) {
|
|
53
|
+
const missing = [...drift.missingTables, ...drift.missingColumns].join(', ');
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: `Schema still behind after migrate — missing: ${missing}. This DB likely needs one-time remediation — see ISS-0100.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const lines = [
|
|
61
|
+
applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
|
|
62
|
+
`Schema current: ${drift.tableCount} tables.`,
|
|
63
|
+
];
|
|
64
|
+
return { success: true, message: lines.join('\n') };
|
|
65
|
+
}
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -89,6 +89,7 @@ import { handleSystemAudit } from './commands/system-audit';
|
|
|
89
89
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
90
90
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
91
91
|
import { handleSystemInit } from './commands/system-init';
|
|
92
|
+
import { handleSystemMigrate } from './commands/system-migrate';
|
|
92
93
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
93
94
|
import { handleSystemSecretSet } from './commands/system-secret-set';
|
|
94
95
|
import { handleSystemUpdate } from './commands/system-update';
|
|
@@ -1783,6 +1784,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1783
1784
|
return handleSystemDoctor(parsed.args, parsed.flags);
|
|
1784
1785
|
}
|
|
1785
1786
|
|
|
1787
|
+
if (parsed.subcommand === 'migrate') {
|
|
1788
|
+
return handleSystemMigrate();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1786
1791
|
return {
|
|
1787
1792
|
success: false,
|
|
1788
1793
|
error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
|
package/src/db/client.ts
CHANGED
|
@@ -41,145 +41,6 @@ export function findMigrationsFolder(): string {
|
|
|
41
41
|
throw new Error(`Could not find drizzle migrations folder. Tried: ${candidates.join(', ')}`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* Check if database needs initialization (tables don't exist)
|
|
46
|
-
*/
|
|
47
|
-
function needsMigration(sqlite: Database): boolean {
|
|
48
|
-
try {
|
|
49
|
-
// Check if the modules table exists at all (new database)
|
|
50
|
-
const result = sqlite
|
|
51
|
-
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='modules'")
|
|
52
|
-
.get();
|
|
53
|
-
if (!result) return true; // New database — run full migrations
|
|
54
|
-
|
|
55
|
-
// Existing database — apply incremental schema updates
|
|
56
|
-
// Each statement is wrapped in try/catch (no-op if already applied)
|
|
57
|
-
const alterStatements = [
|
|
58
|
-
'ALTER TABLE capabilities ADD zones text',
|
|
59
|
-
'ALTER TABLE machines ADD earmarked_module text',
|
|
60
|
-
// web_routes' subdomain/custom_domain columns were folded into a
|
|
61
|
-
// single `hostname` field by migration 0004. Don't re-add them
|
|
62
|
-
// here — the migration drops and recreates the table.
|
|
63
|
-
// Backup system tables (Phase 1)
|
|
64
|
-
`CREATE TABLE IF NOT EXISTS backup_storages (
|
|
65
|
-
id text PRIMARY KEY NOT NULL,
|
|
66
|
-
storage_id text NOT NULL UNIQUE,
|
|
67
|
-
name text NOT NULL,
|
|
68
|
-
provider_name text NOT NULL,
|
|
69
|
-
credentials_encrypted text NOT NULL,
|
|
70
|
-
provider_config text NOT NULL,
|
|
71
|
-
verified integer DEFAULT 0 NOT NULL,
|
|
72
|
-
verified_at integer,
|
|
73
|
-
verification_error text,
|
|
74
|
-
is_default integer DEFAULT 0 NOT NULL,
|
|
75
|
-
created_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
76
|
-
updated_at integer DEFAULT (unixepoch()) NOT NULL
|
|
77
|
-
)`,
|
|
78
|
-
`CREATE TABLE IF NOT EXISTS backups (
|
|
79
|
-
id text PRIMARY KEY NOT NULL,
|
|
80
|
-
module_id text REFERENCES modules(id) ON DELETE SET NULL,
|
|
81
|
-
storage_id text NOT NULL REFERENCES backup_storages(id),
|
|
82
|
-
storage_path text NOT NULL,
|
|
83
|
-
backup_type text NOT NULL,
|
|
84
|
-
module_version text,
|
|
85
|
-
schema_version text,
|
|
86
|
-
size_bytes integer,
|
|
87
|
-
metadata text DEFAULT '{}' NOT NULL,
|
|
88
|
-
status text DEFAULT 'in_progress' NOT NULL,
|
|
89
|
-
error_message text,
|
|
90
|
-
started_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
91
|
-
completed_at integer
|
|
92
|
-
)`,
|
|
93
|
-
// Backup naming support
|
|
94
|
-
'ALTER TABLE backups ADD name text',
|
|
95
|
-
// Module systems (v2/MODULE_SYSTEMS_ADDRESSING.md) — a module's 0..N
|
|
96
|
-
// deployed hosts. Fresh DBs get this via migration 0007; existing installs
|
|
97
|
-
// get it here. Replaces the scalar target_ip/vmid rows in module_configs.
|
|
98
|
-
`CREATE TABLE IF NOT EXISTS module_systems (
|
|
99
|
-
module_id text NOT NULL REFERENCES modules(id) ON DELETE cascade,
|
|
100
|
-
name text NOT NULL,
|
|
101
|
-
hostname text NOT NULL,
|
|
102
|
-
ipv4_address text NOT NULL,
|
|
103
|
-
zone text NOT NULL,
|
|
104
|
-
infra_type text NOT NULL,
|
|
105
|
-
machine_id text REFERENCES machines(id),
|
|
106
|
-
service_id text REFERENCES container_services(id),
|
|
107
|
-
vmid integer,
|
|
108
|
-
created_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
109
|
-
updated_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
110
|
-
PRIMARY KEY (module_id, name)
|
|
111
|
-
)`,
|
|
112
|
-
// Aspect consent decision (ISS-0027). Fresh DBs get this via migration
|
|
113
|
-
// 0008; existing installs get it here. Defaults to true so pre-existing
|
|
114
|
-
// rows (all approvals) keep running; false = a durable refusal.
|
|
115
|
-
'ALTER TABLE aspect_approvals ADD consented integer DEFAULT true NOT NULL',
|
|
116
|
-
];
|
|
117
|
-
|
|
118
|
-
for (const stmt of alterStatements) {
|
|
119
|
-
try {
|
|
120
|
-
sqlite.exec(stmt);
|
|
121
|
-
} catch {
|
|
122
|
-
// Column already exists — fine
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// web_routes hostname migration (CADDY_HOSTNAME_LIST design).
|
|
127
|
-
// Drizzle's auto-migrate path (`migrate()`) only runs for fresh
|
|
128
|
-
// databases — for existing celilo installs we apply the schema
|
|
129
|
-
// change here. Phase 0 + no production users = destructive
|
|
130
|
-
// rebuild; modules repopulate routes on their next deploy.
|
|
131
|
-
try {
|
|
132
|
-
const cols = sqlite.query("SELECT name FROM pragma_table_info('web_routes')").all() as Array<{
|
|
133
|
-
name: string;
|
|
134
|
-
}>;
|
|
135
|
-
const hasHostname = cols.some((c) => c.name === 'hostname');
|
|
136
|
-
const hasOldColumns = cols.some((c) => c.name === 'subdomain' || c.name === 'custom_domain');
|
|
137
|
-
if (!hasHostname && cols.length > 0) {
|
|
138
|
-
// Old shape detected (or missing hostname) — drop and recreate.
|
|
139
|
-
// Wrap in a transaction so the table is never half-migrated.
|
|
140
|
-
sqlite.exec('BEGIN');
|
|
141
|
-
try {
|
|
142
|
-
sqlite.exec('DROP TABLE web_routes');
|
|
143
|
-
sqlite.exec(`CREATE TABLE web_routes (
|
|
144
|
-
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
145
|
-
slug text NOT NULL,
|
|
146
|
-
module_id text NOT NULL,
|
|
147
|
-
type text NOT NULL,
|
|
148
|
-
path text NOT NULL,
|
|
149
|
-
hostname text NOT NULL,
|
|
150
|
-
target_host text,
|
|
151
|
-
target_port integer,
|
|
152
|
-
websocket integer DEFAULT false NOT NULL,
|
|
153
|
-
content_hash text,
|
|
154
|
-
created_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
155
|
-
updated_at integer DEFAULT (unixepoch()) NOT NULL,
|
|
156
|
-
FOREIGN KEY (module_id) REFERENCES modules(id) ON UPDATE no action ON DELETE cascade
|
|
157
|
-
)`);
|
|
158
|
-
sqlite.exec(
|
|
159
|
-
'CREATE UNIQUE INDEX web_routes_hostname_path_idx ON web_routes (hostname, path)',
|
|
160
|
-
);
|
|
161
|
-
sqlite.exec('COMMIT');
|
|
162
|
-
if (hasOldColumns) {
|
|
163
|
-
console.log(
|
|
164
|
-
'web_routes migrated to hostname-based schema. Modules will repopulate their routes on next deploy.',
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
} catch (err) {
|
|
168
|
-
sqlite.exec('ROLLBACK');
|
|
169
|
-
throw err;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} catch (err) {
|
|
173
|
-
console.error('Failed to migrate web_routes schema:', err);
|
|
174
|
-
throw err;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return false; // Schema is up to date
|
|
178
|
-
} catch {
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
44
|
/**
|
|
184
45
|
* Create database client and run migrations if needed
|
|
185
46
|
*/
|
|
@@ -209,12 +70,21 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
209
70
|
|
|
210
71
|
const db = drizzle(sqlite, { schema });
|
|
211
72
|
|
|
212
|
-
//
|
|
213
|
-
|
|
73
|
+
// Apply migrations on open (ISS-0100). Drizzle's migrator is the single
|
|
74
|
+
// migration mechanism for ALL DBs — fresh and existing — and the only place
|
|
75
|
+
// schema changes live (the old imperative hand-list is gone). migrate() is
|
|
76
|
+
// idempotent: it applies every migration newer than the latest recorded in
|
|
77
|
+
// `__drizzle_migrations` and no-ops once current.
|
|
78
|
+
//
|
|
79
|
+
// One-time caveat (ISS-0100): an existing DB from the hand-list era has a
|
|
80
|
+
// frozen `__drizzle_migrations` watermark; it must be remediated by hand
|
|
81
|
+
// (stamp the watermark to the latest migration + create any missing table)
|
|
82
|
+
// BEFORE this code opens it, or migrate() re-runs already-applied migrations
|
|
83
|
+
// and throws. `celilo system doctor` (checkSchemaDrift) detects the drift.
|
|
84
|
+
if (!readonly) {
|
|
214
85
|
try {
|
|
215
86
|
const migrationsFolder = findMigrationsFolder();
|
|
216
87
|
migrate(db, { migrationsFolder });
|
|
217
|
-
console.log('Database initialized with migrations');
|
|
218
88
|
} catch (error) {
|
|
219
89
|
console.error('Failed to run migrations:', error);
|
|
220
90
|
throw error;
|
|
@@ -222,10 +92,9 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
222
92
|
}
|
|
223
93
|
|
|
224
94
|
// One-time upgrade backfill for the target_ip → module_systems refactor
|
|
225
|
-
// (v2/MODULE_SYSTEMS_ADDRESSING.md).
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// created before the refactor has its host data only in module_configs /
|
|
95
|
+
// (v2/MODULE_SYSTEMS_ADDRESSING.md). migrate() above has ensured the
|
|
96
|
+
// module_systems table exists (migration 0007). A deployment created before
|
|
97
|
+
// the refactor has its host data only in module_configs /
|
|
229
98
|
// ip_allocations / module_infrastructure and an EMPTY module_systems, so its
|
|
230
99
|
// modules resolve to no system and the migrated hooks throw "No deployed
|
|
231
100
|
// system found". This lifts that state across. Idempotent (skips modules
|
package/src/db/migrate.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
2
|
-
import { closeDb, createDbClient, findMigrationsFolder } from './client';
|
|
2
|
+
import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
|
|
6
|
+
* only migrations newer than the latest recorded in `__drizzle_migrations`.
|
|
7
|
+
* The single migration mechanism (ISS-0100); createDbClient also calls this
|
|
8
|
+
* shape on open (auto-migrate).
|
|
9
|
+
*/
|
|
10
|
+
export function runMigrationsOn(db: DbClient): void {
|
|
11
|
+
migrate(db, { migrationsFolder: findMigrationsFolder() });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run database migrations (standalone entrypoint — `bun run src/db/migrate.ts`).
|
|
16
|
+
* createDbClient already migrates on open; this re-asserts for explicit use.
|
|
6
17
|
*/
|
|
7
18
|
export async function runMigrations(dbPath?: string) {
|
|
8
19
|
console.log('Running database migrations...');
|
|
9
|
-
|
|
10
20
|
const db = createDbClient(dbPath ? { path: dbPath } : undefined);
|
|
11
|
-
|
|
12
21
|
try {
|
|
13
|
-
|
|
14
|
-
await migrate(db, { migrationsFolder });
|
|
22
|
+
runMigrationsOn(db);
|
|
15
23
|
console.log('Migrations completed successfully');
|
|
16
24
|
} catch (error) {
|
|
17
25
|
console.error('Migration failed:', error);
|