@celilo/cli 1.5.0 → 1.6.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_SUBSYSTEMS.md +16 -2
- package/MODULE_PRIMITIVES.md +19 -6
- package/drizzle/0026_module_integrity_version.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-update.test.ts +90 -2
- package/src/cli/commands/module-update.ts +112 -6
- package/src/cli/commands/module-verify.ts +77 -13
- package/src/cli/commands/system-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/index.ts +2 -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/schema.ts +5 -0
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +36 -35
- 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/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/module-deploy.ts +21 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
|
@@ -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('');
|
|
@@ -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/index.ts
CHANGED
|
@@ -1577,9 +1577,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1577
1577
|
case 'changeset':
|
|
1578
1578
|
return handleModuleChangeset(parsed.args, parsed.flags);
|
|
1579
1579
|
case 'audit':
|
|
1580
|
-
return moduleAudit(parsed.args);
|
|
1580
|
+
return moduleAudit(parsed.args, parsed.flags);
|
|
1581
1581
|
case 'verify':
|
|
1582
|
-
return moduleVerify(parsed.args);
|
|
1582
|
+
return moduleVerify(parsed.args, parsed.flags);
|
|
1583
1583
|
case 'config': {
|
|
1584
1584
|
// Config requires additional subcommand (set/get)
|
|
1585
1585
|
const configSubcommand = parsed.args[0];
|
|
@@ -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/schema.ts
CHANGED
|
@@ -241,6 +241,11 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
|
|
|
241
241
|
.unique()
|
|
242
242
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
243
243
|
checksums: text('checksums', { mode: 'json' }).$type<Record<string, string>>().notNull(), // { "path/to/file": "xxhash", ... }
|
|
244
|
+
// Which version these checksums describe. NULL means the row was written
|
|
245
|
+
// before celilo stamped versions — a fact `module verify` reports rather
|
|
246
|
+
// than papers over, because a baseline whose version is unknown cannot be
|
|
247
|
+
// compared to the module's and is not evidence of anything.
|
|
248
|
+
version: text('version'),
|
|
244
249
|
signature: text('signature'), // Nullable - null for directory imports, populated for .netapp packages
|
|
245
250
|
importedAt: integer('imported_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
246
251
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
* under both the Zod schema (ModuleManifestSchema) and the exported JSON
|
|
4
4
|
* Schema at <repo>/schemas/module-manifest.schema.json.
|
|
5
5
|
*
|
|
6
|
-
* This
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* This is the INDIRECT drift detector, not the primary guard. `bun run
|
|
7
|
+
* check:schema` (a step in the CI validate job) regenerates the schema and
|
|
8
|
+
* compares it byte-for-byte, so it fails the moment schema.ts changes without a
|
|
9
|
+
* regenerate. This test only fails once some real manifest in the repo happens
|
|
10
|
+
* to declare something a stale schema rejects, which can be days later and in
|
|
11
|
+
* an unrelated PR. Do not mistake one for the other: if you add a field to
|
|
12
|
+
* schema.ts, `check:schema` is what tells you to run `bun run export:schema`.
|
|
13
|
+
*
|
|
14
|
+
* What this test is actually for:
|
|
15
|
+
* 1. Every manifest the repo ships really does validate under both
|
|
16
|
+
* validators, so a stale schema cannot sit unnoticed once it starts
|
|
17
|
+
* rejecting live manifests.
|
|
10
18
|
* 2. zod-to-json-schema's translation hasn't lost fidelity in a way that
|
|
11
19
|
* would let bad manifests pass the editor (red underlines) while still
|
|
12
20
|
* failing at module import time. If a real manifest in the repo is
|
package/src/manifest/schema.ts
CHANGED
|
@@ -677,6 +677,29 @@ export const ModuleManifestSchema = z
|
|
|
677
677
|
|
|
678
678
|
hooks: z.object(HOOK_SCHEMAS).strict().optional(),
|
|
679
679
|
|
|
680
|
+
/**
|
|
681
|
+
* How much of `celilo module verify` this module can honestly answer.
|
|
682
|
+
*
|
|
683
|
+
* `deep: false` opts the module out of the host plane — the `--deep` pass
|
|
684
|
+
* that evaluates its generated playbook in check mode. The escape hatch
|
|
685
|
+
* exists because Ansible's check mode SKIPS a task it cannot evaluate, so a
|
|
686
|
+
* role driven by `command:` / `shell:` can report `changed=0` having never
|
|
687
|
+
* been applied. For most roles that lands as `unmeasured`, which is honest.
|
|
688
|
+
* A role where it would land as a persistent false `drift` should say so
|
|
689
|
+
* here instead of training operators to ignore the output.
|
|
690
|
+
*
|
|
691
|
+
* `reason` is REQUIRED. An opt-out with no stated reason is a check that
|
|
692
|
+
* disappeared, and celilo prints the reason wherever the module is verified
|
|
693
|
+
* (openspec/changes/module-integrity-rigor, D4 / task 6.3).
|
|
694
|
+
*/
|
|
695
|
+
verify: z
|
|
696
|
+
.object({
|
|
697
|
+
deep: z.literal(false),
|
|
698
|
+
reason: z.string().min(1),
|
|
699
|
+
})
|
|
700
|
+
.strict()
|
|
701
|
+
.optional(),
|
|
702
|
+
|
|
680
703
|
build: z
|
|
681
704
|
.object({
|
|
682
705
|
/** Inline shell command to build the module (run via bash -c). Mutually exclusive with script. */
|
package/src/module/import.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getWellKnownCapability, isWellKnown } from '../capabilities/well-known';
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from '../manifest/validate';
|
|
24
24
|
import { parseJsonWithValidation } from '../validation/schemas';
|
|
25
25
|
import { cleanupTempDir, extractPackage, verifyPackageIntegrity } from './packaging/extract';
|
|
26
|
+
import { classifyModulePath } from './packaging/package-rules';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Phase-timing helper for `celilo module import`. Set CELILO_IMPORT_DEBUG=1
|
|
@@ -232,29 +233,14 @@ export async function copyModuleFiles(sourcePath: string, targetPath: string): P
|
|
|
232
233
|
for (const entry of entries) {
|
|
233
234
|
const srcPath = join(src, entry.name);
|
|
234
235
|
const destPath = join(dest, entry.name);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
// runtime deps (@celilo/capabilities etc.). Top-level node_modules
|
|
244
|
-
// (monorepo workspace deps, build tools) are skipped.
|
|
245
|
-
if (
|
|
246
|
-
entry.isDirectory() &&
|
|
247
|
-
(entry.name === '.git' || entry.name === '.next' || entry.name === '.cache')
|
|
248
|
-
) {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
entry.isDirectory() &&
|
|
254
|
-
entry.name === 'node_modules' &&
|
|
255
|
-
!src.endsWith('/scripts') &&
|
|
256
|
-
!src.endsWith('\\scripts')
|
|
257
|
-
) {
|
|
236
|
+
const relPath = relative(sourcePath, srcPath);
|
|
237
|
+
|
|
238
|
+
// `unknown` is everything that belongs to the module's SOURCE tree and
|
|
239
|
+
// not to its install: `.git/`, `e2e/`, tests, `tsconfig.json`, the
|
|
240
|
+
// node_modules the canonical rule drops. Skipping it here is what keeps
|
|
241
|
+
// the `unknown` class empty on a healthy install, so any `unknown` that
|
|
242
|
+
// audit later reports is real. `package` and `derived` both land.
|
|
243
|
+
if (classifyModulePath(relPath) === 'unknown') {
|
|
258
244
|
continue;
|
|
259
245
|
}
|
|
260
246
|
|
|
@@ -699,17 +685,32 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
699
685
|
// Execution: Store integrity data from the package's checksums + signature.
|
|
700
686
|
// Directory imports go through the packager too (see top of importModule),
|
|
701
687
|
// so by the time we reach this point we always have these.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
688
|
+
//
|
|
689
|
+
// UPSERT, not INSERT. `moduleId` is UNIQUE, so a re-import of an already
|
|
690
|
+
// imported module used to collide, get caught by a warn-and-continue, and
|
|
691
|
+
// leave the FIRST import's checksums in place forever. Every file that
|
|
692
|
+
// legitimately changed since then read as [MODIFIED] and the baseline
|
|
693
|
+
// described a version nobody could name. Failing to record the baseline is
|
|
694
|
+
// not "non-fatal": it is the state that made `module verify` useless, so
|
|
695
|
+
// this no longer swallows its own errors.
|
|
696
|
+
const integrityData: NewModuleIntegrity = {
|
|
697
|
+
moduleId: manifest.id,
|
|
698
|
+
checksums: checksums ?? {},
|
|
699
|
+
version: manifest.version,
|
|
700
|
+
signature: signature?.trim() ?? null,
|
|
701
|
+
};
|
|
702
|
+
db.insert(moduleIntegrity)
|
|
703
|
+
.values(integrityData)
|
|
704
|
+
.onConflictDoUpdate({
|
|
705
|
+
target: moduleIntegrity.moduleId,
|
|
706
|
+
set: {
|
|
707
|
+
checksums: integrityData.checksums,
|
|
708
|
+
version: integrityData.version,
|
|
709
|
+
signature: integrityData.signature,
|
|
710
|
+
updatedAt: new Date(),
|
|
711
|
+
},
|
|
712
|
+
})
|
|
713
|
+
.run();
|
|
713
714
|
|
|
714
715
|
// Record a successful build entry so deploy-time validation sees the
|
|
715
716
|
// artifacts in place and skips rebuild. The packager runs the manifest
|