@celilo/cli 1.5.0 → 1.7.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -472,7 +472,67 @@ async function renderAspectCoverage(opts: { fix: boolean }): Promise<{
|
|
|
472
472
|
return { lines, failCount: missing.length, warnCount: unknown.length };
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
-
|
|
475
|
+
/**
|
|
476
|
+
* The fleet-section line for module integrity, one row per module.
|
|
477
|
+
*
|
|
478
|
+
* `module verify` is the detailed surface and `system audit` is the machine
|
|
479
|
+
* one; doctor gets the summary, because doctor is where an operator looks
|
|
480
|
+
* first. Same implementation behind all three.
|
|
481
|
+
*/
|
|
482
|
+
async function renderModuleIntegrity(opts: {
|
|
483
|
+
db: ReturnType<typeof getDb>;
|
|
484
|
+
deep: boolean;
|
|
485
|
+
}): Promise<{ lines: string[]; failCount: number; warnCount: number }> {
|
|
486
|
+
const { auditModule } = await import('../../module/packaging/audit');
|
|
487
|
+
const { auditModuleIntegrity } = await import('../../services/audit/module-integrity');
|
|
488
|
+
const { modules: modulesTable } = await import('../../db/schema');
|
|
489
|
+
|
|
490
|
+
const installed = opts.db.select().from(modulesTable).all();
|
|
491
|
+
if (installed.length === 0) return { lines: [], failCount: 0, warnCount: 0 };
|
|
492
|
+
|
|
493
|
+
const results = await Promise.all(
|
|
494
|
+
installed.map((m) => auditModule(m.id, opts.db, { deep: opts.deep })),
|
|
495
|
+
);
|
|
496
|
+
const findings = auditModuleIntegrity({ results });
|
|
497
|
+
|
|
498
|
+
const drifted = findings.filter((f) => f.severity === 'drift');
|
|
499
|
+
const unmeasured = findings.filter((f) => f.severity === 'unmeasured');
|
|
500
|
+
|
|
501
|
+
if (findings.length === 0) {
|
|
502
|
+
const scope = opts.deep
|
|
503
|
+
? 'including what is running on their hosts'
|
|
504
|
+
: 'installed and generated';
|
|
505
|
+
return {
|
|
506
|
+
lines: [
|
|
507
|
+
` ${ANSI.green}✔${ANSI.reset} Module integrity ${ANSI.dim}— ${installed.length} module(s) match their recorded version (${scope})${ANSI.reset}`,
|
|
508
|
+
],
|
|
509
|
+
failCount: 0,
|
|
510
|
+
warnCount: 0,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const lines: string[] = [
|
|
515
|
+
` ${drifted.length > 0 ? `${ANSI.red}✗${ANSI.reset}` : `${ANSI.yellow}?${ANSI.reset}`} Module integrity ${ANSI.dim}— ${drifted.length} drifted, ${unmeasured.length} unmeasured, of ${installed.length} module(s)${ANSI.reset}`,
|
|
516
|
+
];
|
|
517
|
+
// Every finding, never just the first. celilo#951 printed `drift[0]` of 19,
|
|
518
|
+
// so a real finding would have arrived at position 19 and never been seen.
|
|
519
|
+
for (const f of findings) {
|
|
520
|
+
lines.push(` ${ANSI.dim}${f.message}${ANSI.reset}`);
|
|
521
|
+
if (f.remediation) lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
|
|
522
|
+
}
|
|
523
|
+
if (!opts.deep) {
|
|
524
|
+
lines.push(
|
|
525
|
+
` ${ANSI.dim}Run \`celilo system doctor --deep\` to also ask each host what it is running.${ANSI.reset}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
return { lines, failCount: drifted.length, warnCount: unmeasured.length };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function renderFleetSection(opts: {
|
|
532
|
+
forced: boolean;
|
|
533
|
+
fix: boolean;
|
|
534
|
+
deep: boolean;
|
|
535
|
+
}): Promise<{
|
|
476
536
|
lines: string[];
|
|
477
537
|
failCount: number;
|
|
478
538
|
warnCount: number;
|
|
@@ -518,6 +578,18 @@ async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Prom
|
|
|
518
578
|
if (f.status === 'fail') failCount++;
|
|
519
579
|
else if (f.status === 'warn') warnCount++;
|
|
520
580
|
}
|
|
581
|
+
|
|
582
|
+
// Module integrity: is the code on each box the code celilo thinks it is?
|
|
583
|
+
// Shallow here by design — the installed tree against its baseline and the
|
|
584
|
+
// generated project against the installed tree are both local and take
|
|
585
|
+
// milliseconds. The host plane is one SSH per system and is reached with
|
|
586
|
+
// `--deep`, alongside aspect coverage, for the same reason
|
|
587
|
+
// (openspec/changes/module-integrity-rigor, D8).
|
|
588
|
+
const integrity = await renderModuleIntegrity({ db, deep: opts.deep });
|
|
589
|
+
lines.push(...integrity.lines);
|
|
590
|
+
failCount += integrity.failCount;
|
|
591
|
+
warnCount += integrity.warnCount;
|
|
592
|
+
|
|
521
593
|
return { lines, failCount, warnCount };
|
|
522
594
|
} finally {
|
|
523
595
|
bus.close();
|
|
@@ -647,7 +719,11 @@ export async function handleSystemDoctor(
|
|
|
647
719
|
|
|
648
720
|
// Fleet-runtime section (state-aware; only renders on a management
|
|
649
721
|
// plane with a celilo DB, or when --fleet forces it).
|
|
650
|
-
const fleet = await renderFleetSection({
|
|
722
|
+
const fleet = await renderFleetSection({
|
|
723
|
+
forced: flags.fleet === true,
|
|
724
|
+
fix,
|
|
725
|
+
deep: flags.deep === true,
|
|
726
|
+
});
|
|
651
727
|
if (fleet.lines.length > 0) {
|
|
652
728
|
lines.push(...fleet.lines);
|
|
653
729
|
lines.push('');
|
|
@@ -108,9 +108,11 @@ export async function handleSystemMigrate(
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
// getDb() auto-migrates on open
|
|
112
|
-
//
|
|
113
|
-
//
|
|
111
|
+
// getDb() auto-migrates on open, and repairs a frozen `__drizzle_migrations`
|
|
112
|
+
// watermark itself when the declared schema is already complete. What reaches
|
|
113
|
+
// this catch is the case it will not guess at: schema that is only PARTLY
|
|
114
|
+
// there, where stamping would record migrations that never ran. Caught so it
|
|
115
|
+
// says what to do instead of surfacing a raw migrator error.
|
|
114
116
|
let db: ReturnType<typeof getDb>;
|
|
115
117
|
try {
|
|
116
118
|
db = getDb();
|
|
@@ -118,7 +120,7 @@ export async function handleSystemMigrate(
|
|
|
118
120
|
const msg = error instanceof Error ? error.message : String(error);
|
|
119
121
|
return {
|
|
120
122
|
success: false,
|
|
121
|
-
error: `Migration failed: ${msg}\n\
|
|
123
|
+
error: `Migration failed: ${msg}\n\nThis DB's \`__drizzle_migrations\` watermark disagrees with a schema that is only partly applied, which celilo will not resolve on its own. It needs a one-time remediation by hand (create the genuinely missing objects from their migration .sql, then stamp the watermark to the latest migration) — runbook in celilo#169.`,
|
|
122
124
|
};
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -495,6 +495,11 @@ export async function handleSystemUpdate(
|
|
|
495
495
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
496
496
|
const healthResults = await runAllHealthChecks(db);
|
|
497
497
|
|
|
498
|
+
// Module integrity, shallow. Local and milliseconds; the host plane is one
|
|
499
|
+
// SSH per system and belongs to `module verify --deep`.
|
|
500
|
+
const { auditModule } = await import('../../module/packaging/audit');
|
|
501
|
+
const integrityResults = await Promise.all(upgradableModules.map((m) => auditModule(m.id, db)));
|
|
502
|
+
|
|
498
503
|
const latestBackupByModule = new Map<string, number>();
|
|
499
504
|
try {
|
|
500
505
|
const successfulBackups = db
|
|
@@ -563,6 +568,14 @@ export async function handleSystemUpdate(
|
|
|
563
568
|
configs: configsByModule.get(m.id) ?? {},
|
|
564
569
|
})),
|
|
565
570
|
},
|
|
571
|
+
moduleIntegrity: { results: integrityResults },
|
|
572
|
+
detectWithoutConverge: {
|
|
573
|
+
modules: upgradableModules.map((m) => ({
|
|
574
|
+
id: m.id,
|
|
575
|
+
state: m.state,
|
|
576
|
+
manifest: m.manifestData as ModuleManifest,
|
|
577
|
+
})),
|
|
578
|
+
},
|
|
566
579
|
health: { results: healthResults },
|
|
567
580
|
backups: {
|
|
568
581
|
modules: upgradableModules.map((m) => ({
|
|
@@ -714,7 +727,7 @@ export async function handleSystemUpdate(
|
|
|
714
727
|
// orchestrator was reacting to).
|
|
715
728
|
const successfulModuleSteps = result.modules.filter((m) => m.step === 'done');
|
|
716
729
|
if (result.ok && successfulModuleSteps.length > 0) {
|
|
717
|
-
const refreshedAudit = await runAudit(rebuildAuditDepsForRerun(auditDeps, db));
|
|
730
|
+
const refreshedAudit = await runAudit(await rebuildAuditDepsForRerun(auditDeps, db));
|
|
718
731
|
result.audit = refreshedAudit;
|
|
719
732
|
}
|
|
720
733
|
|
|
@@ -747,10 +760,10 @@ type AuditDeps = Parameters<typeof runAudit>[0];
|
|
|
747
760
|
* reflect post-upgrade reality (e.g., a module_versions drift
|
|
748
761
|
* finding for a module we just upgraded is no longer reported).
|
|
749
762
|
*/
|
|
750
|
-
export function rebuildAuditDepsForRerun(
|
|
763
|
+
export async function rebuildAuditDepsForRerun(
|
|
751
764
|
original: AuditDeps,
|
|
752
765
|
db: ReturnType<typeof getDb>,
|
|
753
|
-
): AuditDeps {
|
|
766
|
+
): Promise<AuditDeps> {
|
|
754
767
|
const installed = db.select().from(modules).all();
|
|
755
768
|
const upgradeEligibleStates = new Set(['INSTALLED', 'VERIFIED', 'IMPORTED']);
|
|
756
769
|
const upgradable = installed.filter((m) => upgradeEligibleStates.has(m.state));
|
|
@@ -763,6 +776,8 @@ export function rebuildAuditDepsForRerun(
|
|
|
763
776
|
configsByModule.set(c.moduleId, m);
|
|
764
777
|
}
|
|
765
778
|
|
|
779
|
+
const { auditModule } = await import('../../module/packaging/audit');
|
|
780
|
+
|
|
766
781
|
// Backup recency is unchanged across an orchestrator run (only
|
|
767
782
|
// celilo-DB snapshots happen, not per-module backup writes), so
|
|
768
783
|
// we look up each module's prior lastSuccessfulBackupAt by id
|
|
@@ -793,6 +808,21 @@ export function rebuildAuditDepsForRerun(
|
|
|
793
808
|
configs: configsByModule.get(m.id) ?? {},
|
|
794
809
|
})),
|
|
795
810
|
},
|
|
811
|
+
// Re-MEASURED, not carried over. An upgrade rewrites the installed tree and
|
|
812
|
+
// the baseline, so the pre-upgrade result describes files that are no
|
|
813
|
+
// longer on disk. Reusing it would be reporting a stored claim about a
|
|
814
|
+
// state that has since changed, which is the exact mistake this whole
|
|
815
|
+
// change exists to remove.
|
|
816
|
+
moduleIntegrity: {
|
|
817
|
+
results: await Promise.all(upgradable.map((m) => auditModule(m.id, db))),
|
|
818
|
+
},
|
|
819
|
+
detectWithoutConverge: {
|
|
820
|
+
modules: upgradable.map((m) => ({
|
|
821
|
+
id: m.id,
|
|
822
|
+
state: m.state,
|
|
823
|
+
manifest: m.manifestData as ModuleManifest,
|
|
824
|
+
})),
|
|
825
|
+
},
|
|
796
826
|
// Unchanged across an orchestrator run — an upgrade does not reclaim
|
|
797
827
|
// abandoned operations, so re-reading them would be the same rows.
|
|
798
828
|
abandonedOperations: original.abandonedOperations,
|
package/src/cli/completion.ts
CHANGED
|
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
if (command === 'service' && currentIndex === 1) {
|
|
328
|
-
const subcommands = [
|
|
328
|
+
const subcommands = [
|
|
329
|
+
'add',
|
|
330
|
+
'list',
|
|
331
|
+
'verify',
|
|
332
|
+
'reconfigure',
|
|
333
|
+
'remove',
|
|
334
|
+
'config',
|
|
335
|
+
'set-credentials',
|
|
336
|
+
];
|
|
329
337
|
return filterSuggestions(subcommands, args[1] || '');
|
|
330
338
|
}
|
|
331
339
|
|
|
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
356
364
|
return filterSuggestions(serviceIds, args[2] || '');
|
|
357
365
|
}
|
|
358
366
|
|
|
367
|
+
// Service set-credentials - complete with service IDs
|
|
368
|
+
if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
|
|
369
|
+
const services = await listContainerServices();
|
|
370
|
+
const serviceIds = services.map((s) => s.serviceId);
|
|
371
|
+
return filterSuggestions(serviceIds, args[2] || '');
|
|
372
|
+
}
|
|
373
|
+
|
|
359
374
|
// Service config operations
|
|
360
375
|
if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
|
|
361
376
|
const operations = ['get', 'set'];
|
package/src/cli/index.ts
CHANGED
|
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
|
|
|
109
109
|
import { handleServiceList } from './commands/service-list';
|
|
110
110
|
import { handleServiceReconfigure } from './commands/service-reconfigure';
|
|
111
111
|
import { handleServiceRemove } from './commands/service-remove';
|
|
112
|
+
import { handleServiceSetCredentials } from './commands/service-set-credentials';
|
|
112
113
|
import { handleServiceVerify } from './commands/service-verify';
|
|
113
114
|
import { handleStatus } from './commands/status';
|
|
114
115
|
import { handleSubscribersAdd } from './commands/subscribers-add';
|
|
@@ -721,6 +722,7 @@ Subcommands:
|
|
|
721
722
|
Options:
|
|
722
723
|
--zone <zone> Filter by network zone
|
|
723
724
|
verify <service-id> Re-verify a container service connection
|
|
725
|
+
set-credentials <service-id> Update a provider endpoint or API credential
|
|
724
726
|
reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
|
|
725
727
|
remove <id> Remove a container service
|
|
726
728
|
Options:
|
|
@@ -751,6 +753,9 @@ Examples:
|
|
|
751
753
|
# Verify a service connection
|
|
752
754
|
celilo service verify proxmox-home-lab
|
|
753
755
|
|
|
756
|
+
# Move a Proxmox endpoint while retaining its existing token
|
|
757
|
+
celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
|
|
758
|
+
|
|
754
759
|
# Get service configuration
|
|
755
760
|
celilo service config get proxmox-home-lab
|
|
756
761
|
celilo service config get proxmox-home-lab name
|
|
@@ -1577,9 +1582,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1577
1582
|
case 'changeset':
|
|
1578
1583
|
return handleModuleChangeset(parsed.args, parsed.flags);
|
|
1579
1584
|
case 'audit':
|
|
1580
|
-
return moduleAudit(parsed.args);
|
|
1585
|
+
return moduleAudit(parsed.args, parsed.flags);
|
|
1581
1586
|
case 'verify':
|
|
1582
|
-
return moduleVerify(parsed.args);
|
|
1587
|
+
return moduleVerify(parsed.args, parsed.flags);
|
|
1583
1588
|
case 'config': {
|
|
1584
1589
|
// Config requires additional subcommand (set/get)
|
|
1585
1590
|
const configSubcommand = parsed.args[0];
|
|
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1750
1755
|
return handleServiceVerify(parsed.args, parsed.flags);
|
|
1751
1756
|
}
|
|
1752
1757
|
|
|
1758
|
+
if (parsed.subcommand === 'set-credentials') {
|
|
1759
|
+
return handleServiceSetCredentials(parsed.args, parsed.flags);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1753
1762
|
if (parsed.subcommand === 'reconfigure') {
|
|
1754
1763
|
return handleServiceReconfigure(parsed.args, parsed.flags);
|
|
1755
1764
|
}
|
|
@@ -56,12 +56,12 @@ export type ModalState =
|
|
|
56
56
|
/**
|
|
57
57
|
* Per-category progress state — drives the analyzing modal's
|
|
58
58
|
* fuel-gauges. `done` carries the verdict so the row can show the
|
|
59
|
-
* right icon (✓ clean / ▲ drift / × blocked).
|
|
59
|
+
* right icon (✓ clean / ? unmeasured / ▲ drift / × blocked).
|
|
60
60
|
*/
|
|
61
61
|
export type CategoryStatus =
|
|
62
62
|
| 'pending'
|
|
63
63
|
| 'running'
|
|
64
|
-
| { kind: 'done'; verdict: 'clean' | 'drift' | 'blocked' };
|
|
64
|
+
| { kind: 'done'; verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' };
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* The full set of categories the audit emits — used to seed the
|
|
@@ -90,9 +90,13 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
90
90
|
'transport_reads',
|
|
91
91
|
'trusted_sources',
|
|
92
92
|
'interface_classification',
|
|
93
|
+
'module_integrity',
|
|
94
|
+
'detect_without_converge',
|
|
93
95
|
];
|
|
94
96
|
|
|
95
97
|
export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
98
|
+
module_integrity: 'Module integrity',
|
|
99
|
+
detect_without_converge: 'Drift without converge',
|
|
96
100
|
interface_classification: 'Firewall interfaces',
|
|
97
101
|
cli_version: 'CLI version',
|
|
98
102
|
schema: 'Schema migrations',
|
|
@@ -181,7 +185,11 @@ export type AuditTuiAction =
|
|
|
181
185
|
| { type: 'select-finding'; index: number }
|
|
182
186
|
// Per-category audit-progress lifecycle.
|
|
183
187
|
| { type: 'category-start'; category: DriftCategory }
|
|
184
|
-
| {
|
|
188
|
+
| {
|
|
189
|
+
type: 'category-end';
|
|
190
|
+
category: DriftCategory;
|
|
191
|
+
verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked';
|
|
192
|
+
}
|
|
185
193
|
| { type: 'reset-category-progress' };
|
|
186
194
|
|
|
187
195
|
const PANE_ORDER: PaneId[] = ['summary', 'categories', 'findings', 'detail', 'log'];
|
|
@@ -129,11 +129,17 @@ export function AuditTui({ source, theme: themeName }: Props) {
|
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
const findings = event.findings ?? [];
|
|
132
|
-
|
|
132
|
+
// Same ranking as `computeVerdict`: an unmeasured category is not clean
|
|
133
|
+
// and is not the same statement as a measured difference (D7).
|
|
134
|
+
const verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' = findings.some(
|
|
135
|
+
(f) => f.severity === 'blocked',
|
|
136
|
+
)
|
|
133
137
|
? 'blocked'
|
|
134
|
-
: findings.
|
|
135
|
-
? '
|
|
136
|
-
:
|
|
138
|
+
: findings.some((f) => f.severity === 'unmeasured')
|
|
139
|
+
? 'unmeasured'
|
|
140
|
+
: findings.length > 0
|
|
141
|
+
? 'drift'
|
|
142
|
+
: 'clean';
|
|
137
143
|
dispatch({ type: 'category-end', category: event.category, verdict });
|
|
138
144
|
};
|
|
139
145
|
|
package/src/cli/tui/icons.ts
CHANGED
|
@@ -8,12 +8,16 @@ export interface SeverityVisual {
|
|
|
8
8
|
|
|
9
9
|
export const SEVERITY_VISUALS: Record<DriftSeverity, SeverityVisual> = {
|
|
10
10
|
blocked: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
11
|
+
// Deliberately NOT green and NOT gray. An unmeasured check is not a pass and
|
|
12
|
+
// is not a reminder — it is a hole in what celilo knows.
|
|
13
|
+
unmeasured: { icon: '?', color: 'yellow', label: 'UNMEASURED' },
|
|
11
14
|
drift: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
12
15
|
todo: { icon: '➤', color: 'gray', label: 'TODO' },
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
16
19
|
BLOCKED: { icon: '×', color: 'red', label: 'BLOCKED' },
|
|
20
|
+
UNKNOWN: { icon: '?', color: 'yellow', label: 'UNKNOWN' },
|
|
17
21
|
DRIFT: { icon: '▲', color: 'yellow', label: 'DRIFT' },
|
|
18
22
|
READY: { icon: '✓', color: 'green', label: 'READY' },
|
|
19
23
|
};
|
|
@@ -23,6 +27,9 @@ export const VERDICT_VISUALS: Record<AuditVerdict, SeverityVisual> = {
|
|
|
23
27
|
// what actually needs attention.
|
|
24
28
|
export function severityRank(s: DriftSeverity): number {
|
|
25
29
|
if (s === 'blocked') return 0;
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
// Above drift, for the same reason UNKNOWN outranks DRIFT: you cannot act on
|
|
31
|
+
// a diff you are not sure you have.
|
|
32
|
+
if (s === 'unmeasured') return 1;
|
|
33
|
+
if (s === 'drift') return 2;
|
|
34
|
+
return 3; // todo
|
|
28
35
|
}
|
|
@@ -36,6 +36,9 @@ function StatusRow({ category, status }: { category: string; status: CategorySta
|
|
|
36
36
|
if (status.verdict === 'clean') {
|
|
37
37
|
return <Text color="green">✓ {padded}clean</Text>;
|
|
38
38
|
}
|
|
39
|
+
if (status.verdict === 'unmeasured') {
|
|
40
|
+
return <Text color="yellow">? {padded}unmeasured</Text>;
|
|
41
|
+
}
|
|
39
42
|
if (status.verdict === 'drift') {
|
|
40
43
|
return <Text color="yellow">▲ {padded}drift</Text>;
|
|
41
44
|
}
|
package/src/db/client.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
|
|
6
6
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
7
|
-
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
8
7
|
import { getDbPath } from '../config/paths';
|
|
8
|
+
import { runMigrationsOn } from './migrate';
|
|
9
9
|
import * as schema from './schema';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
83
83
|
// idempotent: it applies every migration newer than the latest recorded in
|
|
84
84
|
// `__drizzle_migrations` and no-ops once current.
|
|
85
85
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
86
|
+
// A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
|
|
87
|
+
// so drizzle re-runs already-applied migrations and throws (celilo#169).
|
|
88
|
+
// runMigrationsOn repairs that itself where the schema is already complete.
|
|
89
|
+
// It has to happen HERE and not only in `celilo system migrate`, because
|
|
90
|
+
// that command reaches its own repair through getDb() — this line — and so
|
|
91
|
+
// would die before getting there. A PARTIALLY applied schema still throws
|
|
92
|
+
// and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
|
|
93
|
+
// the drift.
|
|
91
94
|
if (!readonly) {
|
|
92
95
|
try {
|
|
93
|
-
|
|
94
|
-
migrate(db, { migrationsFolder });
|
|
96
|
+
runMigrationsOn(db);
|
|
95
97
|
} catch (error) {
|
|
96
98
|
console.error('Failed to run migrations:', error);
|
|
97
99
|
throw error;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen-watermark recurrence gate (celilo#169).
|
|
3
|
+
*
|
|
4
|
+
* The state under test is one no correct deploy can produce, so it is seeded
|
|
5
|
+
* by hand rather than reached: a database written by a celilo from the
|
|
6
|
+
* imperative hand-list era, where schema changes were applied directly and
|
|
7
|
+
* `__drizzle_migrations` never recorded them. Its ledger therefore remembers
|
|
8
|
+
* an old migration while the tables and columns of every later one are already
|
|
9
|
+
* present.
|
|
10
|
+
*
|
|
11
|
+
* That is the carve-out CLAUDE.md draws around seeding state: deploying
|
|
12
|
+
* anything cannot reproduce this row, because current celilo has recorded
|
|
13
|
+
* every migration it applied since ISS-0100 made drizzle authoritative. A
|
|
14
|
+
* suite that starts from an empty database can only ever prove the forward
|
|
15
|
+
* invariant, and says nothing about the installed base.
|
|
16
|
+
*
|
|
17
|
+
* What made it a hazard: drizzle's migrator is watermark-only. It re-runs
|
|
18
|
+
* every migration newer than the newest ledger row, so the first `ALTER TABLE
|
|
19
|
+
* ... ADD` dies on `duplicate column name`, the transaction rolls back, and
|
|
20
|
+
* the throw happens inside `createDbClient` — so EVERY celilo command on that
|
|
21
|
+
* box fails at database open, not just a migrate. celilo-mgr was remediated by
|
|
22
|
+
* hand once. This gate is what stops the next one needing a runbook.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { type DbClient, createDbClient } from './client';
|
|
30
|
+
import { runMigrationsOn } from './migrate';
|
|
31
|
+
import { findSchemaDrift } from './schema-introspection';
|
|
32
|
+
|
|
33
|
+
describe('runMigrationsOn — a database from the hand-list era', () => {
|
|
34
|
+
const paths: string[] = [];
|
|
35
|
+
|
|
36
|
+
const freshDb = (): { db: DbClient; path: string } => {
|
|
37
|
+
const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
|
|
38
|
+
paths.push(path);
|
|
39
|
+
return { db: createDbClient({ path }), path };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const path of paths.splice(0)) {
|
|
44
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
45
|
+
rmSync(`${path}${suffix}`, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Freeze the ledger to its oldest entry, leaving the schema fully applied.
|
|
52
|
+
* This is the hand-list-era shape: the objects exist, the ledger has
|
|
53
|
+
* forgotten who made them.
|
|
54
|
+
*/
|
|
55
|
+
const freezeWatermark = (db: DbClient): void => {
|
|
56
|
+
db.$client.run(
|
|
57
|
+
'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const appliedCount = (db: DbClient): number =>
|
|
62
|
+
db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
|
|
63
|
+
?.c ?? 0;
|
|
64
|
+
|
|
65
|
+
test('converges instead of dying on the first already-applied statement', () => {
|
|
66
|
+
const { db } = freshDb();
|
|
67
|
+
const migrationCount = appliedCount(db);
|
|
68
|
+
freezeWatermark(db);
|
|
69
|
+
expect(appliedCount(db)).toBe(1);
|
|
70
|
+
|
|
71
|
+
// Red before the ledger repair: drizzle re-runs 0001 and throws
|
|
72
|
+
// "duplicate column name: role", leaving the ledger frozen.
|
|
73
|
+
expect(() => runMigrationsOn(db)).not.toThrow();
|
|
74
|
+
|
|
75
|
+
expect(appliedCount(db)).toBe(migrationCount);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('leaves the schema whole, so the drift detector goes green', () => {
|
|
79
|
+
const { db } = freshDb();
|
|
80
|
+
freezeWatermark(db);
|
|
81
|
+
|
|
82
|
+
runMigrationsOn(db);
|
|
83
|
+
|
|
84
|
+
const drift = findSchemaDrift(db.$client);
|
|
85
|
+
expect(drift.missingTables).toEqual([]);
|
|
86
|
+
expect(drift.missingColumns).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('is idempotent — a second pass applies nothing and still converges', () => {
|
|
90
|
+
const { db } = freshDb();
|
|
91
|
+
freezeWatermark(db);
|
|
92
|
+
runMigrationsOn(db);
|
|
93
|
+
const afterBaseline = appliedCount(db);
|
|
94
|
+
|
|
95
|
+
runMigrationsOn(db);
|
|
96
|
+
|
|
97
|
+
expect(appliedCount(db)).toBe(afterBaseline);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The repair must not become a blanket "assume it already ran". It fires only
|
|
102
|
+
* where the answer is unambiguous — the declared schema is entirely present,
|
|
103
|
+
* so every migration plainly did run and the ledger is what is wrong. A
|
|
104
|
+
* PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
|
|
105
|
+
* was actually in: it carried 0011's column and not 0010's table. Stamping
|
|
106
|
+
* there would record migrations that never ran and bury the missing schema
|
|
107
|
+
* for good, so it keeps failing and a human decides.
|
|
108
|
+
*/
|
|
109
|
+
test('refuses to stamp when the schema is only partly there', () => {
|
|
110
|
+
const { db } = freshDb();
|
|
111
|
+
freezeWatermark(db);
|
|
112
|
+
db.$client.run('DROP TABLE `dns_registrations`');
|
|
113
|
+
|
|
114
|
+
expect(() => runMigrationsOn(db)).toThrow();
|
|
115
|
+
// The ledger is left exactly as found, so the drift is still diagnosable.
|
|
116
|
+
expect(appliedCount(db)).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The path that actually matters. Every celilo command opens the database
|
|
121
|
+
* through createDbClient, which migrates on open, so a frozen watermark
|
|
122
|
+
* failed there rather than anywhere an operator could aim a fix at — and
|
|
123
|
+
* `celilo system migrate`, the command whose whole job is repairing this,
|
|
124
|
+
* reached its own repair through the same open and died first.
|
|
125
|
+
*/
|
|
126
|
+
test('repairs on database open, not just when migrate is called by hand', () => {
|
|
127
|
+
const { db, path } = freshDb();
|
|
128
|
+
const migrationCount = appliedCount(db);
|
|
129
|
+
freezeWatermark(db);
|
|
130
|
+
db.$client.close();
|
|
131
|
+
|
|
132
|
+
const reopened = createDbClient({ path });
|
|
133
|
+
|
|
134
|
+
expect(appliedCount(reopened)).toBe(migrationCount);
|
|
135
|
+
expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a healthy database is untouched by the fallback', () => {
|
|
139
|
+
const { db } = freshDb();
|
|
140
|
+
const before = appliedCount(db);
|
|
141
|
+
|
|
142
|
+
runMigrationsOn(db);
|
|
143
|
+
|
|
144
|
+
expect(appliedCount(db)).toBe(before);
|
|
145
|
+
expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/db/migrate.ts
CHANGED
|
@@ -1,14 +1,82 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
1
2
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
3
|
+
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
|
2
4
|
import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
|
|
5
|
+
import { findSchemaDrift } from './schema-introspection';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Record every migration past the ledger's watermark as applied, running none
|
|
9
|
+
* of them. Only safe when the caller has already established that the schema
|
|
10
|
+
* the code declares is entirely present.
|
|
11
|
+
*
|
|
12
|
+
* Replaying the statements instead would be wrong, and quietly so. Migrations
|
|
13
|
+
* are not idempotent: `0021_dns_registration_consumers` rebuilds a table by
|
|
14
|
+
* copying it aside, `DROP TABLE`-ing the original and renaming the copy over
|
|
15
|
+
* it. Run that against a schema that is already current and it drops a live
|
|
16
|
+
* table. Skipping the statements that fail with "already exists" does not save
|
|
17
|
+
* you either, because `DROP` and `INSERT ... SELECT` do not fail that way.
|
|
18
|
+
*
|
|
19
|
+
* So the ledger is corrected and the schema is left alone.
|
|
20
|
+
*/
|
|
21
|
+
function stampLedgerAsApplied(sqlite: Database, migrationsFolder: string): void {
|
|
22
|
+
const newest = sqlite
|
|
23
|
+
.query<{ created_at: number }, []>(
|
|
24
|
+
'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1',
|
|
25
|
+
)
|
|
26
|
+
.get();
|
|
27
|
+
const watermark = Number(newest?.created_at ?? 0);
|
|
28
|
+
|
|
29
|
+
sqlite.run('BEGIN');
|
|
30
|
+
try {
|
|
31
|
+
for (const migration of readMigrationFiles({ migrationsFolder })) {
|
|
32
|
+
if (migration.folderMillis <= watermark) continue;
|
|
33
|
+
sqlite.run('INSERT INTO `__drizzle_migrations` ("hash", "created_at") VALUES (?, ?)', [
|
|
34
|
+
migration.hash,
|
|
35
|
+
migration.folderMillis,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
sqlite.run('COMMIT');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
sqlite.run('ROLLBACK');
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
3
44
|
|
|
4
45
|
/**
|
|
5
46
|
* Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
|
|
6
47
|
* only migrations newer than the latest recorded in `__drizzle_migrations`.
|
|
7
48
|
* The single migration mechanism (ISS-0100); createDbClient also calls this
|
|
8
49
|
* shape on open (auto-migrate).
|
|
50
|
+
*
|
|
51
|
+
* The stock migrator runs first and handles every database celilo has written
|
|
52
|
+
* since ISS-0100, so a healthy box takes exactly the path it took before.
|
|
53
|
+
*
|
|
54
|
+
* It fails on one database celilo did not write: one from the imperative
|
|
55
|
+
* hand-list era, where schema changes were applied directly and
|
|
56
|
+
* `__drizzle_migrations` never recorded them. Its ledger remembers an old
|
|
57
|
+
* migration while the objects of every later one are already there, and
|
|
58
|
+
* drizzle — being watermark-only — re-runs them and dies on the first
|
|
59
|
+
* `ALTER TABLE ... ADD`. The throw happens inside `createDbClient`, so every
|
|
60
|
+
* celilo command on that box fails at database open. celilo-mgr, the one box
|
|
61
|
+
* in that state, was remediated by hand (celilo#169); this is so the next one
|
|
62
|
+
* is not.
|
|
63
|
+
*
|
|
64
|
+
* The repair is only attempted when the schema the code declares is ALREADY
|
|
65
|
+
* COMPLETE, because that is the one case with an unambiguous answer: every
|
|
66
|
+
* migration has plainly run, so the ledger is what is wrong. A database missing
|
|
67
|
+
* some of it is the genuinely hard case — celilo-mgr had 0011's column and not
|
|
68
|
+
* 0010's table — and there is no safe automatic answer, so it keeps failing
|
|
69
|
+
* with drizzle's own error and a human decides.
|
|
9
70
|
*/
|
|
10
71
|
export function runMigrationsOn(db: DbClient): void {
|
|
11
|
-
|
|
72
|
+
const migrationsFolder = findMigrationsFolder();
|
|
73
|
+
try {
|
|
74
|
+
migrate(db, { migrationsFolder });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const drift = findSchemaDrift(db.$client);
|
|
77
|
+
if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) throw error;
|
|
78
|
+
stampLedgerAsApplied(db.$client, migrationsFolder);
|
|
79
|
+
}
|
|
12
80
|
}
|
|
13
81
|
|
|
14
82
|
/**
|