@celilo/cli 1.4.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 +18 -4
- 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 +41 -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-script-scan.test.ts +164 -0
- package/src/policy/module-script-scan.ts +143 -0
- package/src/policy/no-hand-built-ssh.test.ts +22 -62
- 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
- package/src/templates/copy-role-files.test.ts +69 -0
- package/src/templates/generator.ts +23 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { AuditResult } from '../../module/packaging/audit';
|
|
3
|
+
import { auditModuleIntegrity } from './module-integrity';
|
|
4
|
+
import { computeVerdict } from './types';
|
|
5
|
+
|
|
6
|
+
const clean = (moduleId: string): AuditResult => ({
|
|
7
|
+
success: true,
|
|
8
|
+
moduleId,
|
|
9
|
+
violations: [],
|
|
10
|
+
moduleVersion: '1.0.0',
|
|
11
|
+
baselineVersion: '1.0.0',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('auditModuleIntegrity', () => {
|
|
15
|
+
test('a clean module produces nothing', () => {
|
|
16
|
+
expect(auditModuleIntegrity({ results: [clean('caddy')] })).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a check that could not run is unmeasured, and the verdict is UNKNOWN', () => {
|
|
20
|
+
const findings = auditModuleIntegrity({
|
|
21
|
+
results: [
|
|
22
|
+
{
|
|
23
|
+
success: false,
|
|
24
|
+
moduleId: 'caddy',
|
|
25
|
+
violations: [],
|
|
26
|
+
error: "No integrity data found for module 'caddy'.",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
expect(findings.map((f) => f.severity)).toEqual(['unmeasured']);
|
|
31
|
+
expect(computeVerdict(findings)).toBe('UNKNOWN');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a stale baseline is unmeasured, and it SUPPRESSES the file findings it explains', () => {
|
|
35
|
+
// The 72-violation shape. One frozen row made every legitimately-changed
|
|
36
|
+
// file read as [MODIFIED], and printing all of them is what made `module
|
|
37
|
+
// verify` unreadable. The baseline is the finding; the files are its
|
|
38
|
+
// consequence.
|
|
39
|
+
const findings = auditModuleIntegrity({
|
|
40
|
+
results: [
|
|
41
|
+
{
|
|
42
|
+
...clean('wireguard'),
|
|
43
|
+
success: false,
|
|
44
|
+
baselineVersion: '0.7.0',
|
|
45
|
+
moduleVersion: '0.8.0',
|
|
46
|
+
violations: [
|
|
47
|
+
{
|
|
48
|
+
type: 'stale-baseline',
|
|
49
|
+
path: 'checksums.json',
|
|
50
|
+
message: 'Baseline describes 0.7.0, module records 0.8.0.',
|
|
51
|
+
},
|
|
52
|
+
{ type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
|
|
53
|
+
{ type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
expect(findings).toHaveLength(1);
|
|
59
|
+
expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
|
|
60
|
+
expect(findings[0]?.severity).toBe('unmeasured');
|
|
61
|
+
expect(findings[0]?.remediation).toBe('celilo module update wireguard');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
|
|
65
|
+
const findings = auditModuleIntegrity({
|
|
66
|
+
results: [
|
|
67
|
+
{
|
|
68
|
+
...clean('wireguard'),
|
|
69
|
+
success: false,
|
|
70
|
+
violations: [
|
|
71
|
+
{ type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
|
|
72
|
+
{ type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
expect(findings).toHaveLength(1);
|
|
78
|
+
expect(findings[0]?.severity).toBe('drift');
|
|
79
|
+
// Never truncated to the first item. celilo#951's real finding would have
|
|
80
|
+
// arrived at position 19 of 19 and never been printed.
|
|
81
|
+
expect(findings[0]?.details).toContain('manifest.yml');
|
|
82
|
+
expect(findings[0]?.details).toContain('scripts/new.ts');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a stale generated project is drift, and says a deploy would ship the wrong bytes', () => {
|
|
86
|
+
const findings = auditModuleIntegrity({
|
|
87
|
+
results: [
|
|
88
|
+
{
|
|
89
|
+
...clean('wireguard-manager'),
|
|
90
|
+
success: false,
|
|
91
|
+
violations: [
|
|
92
|
+
{
|
|
93
|
+
type: 'stale-generated',
|
|
94
|
+
path: 'ansible/roles/vpn/files/bin',
|
|
95
|
+
message: 'Generated project holds different bytes',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
expect(findings.map((f) => f.code)).toEqual(['module_generated_stale']);
|
|
102
|
+
expect(findings[0]?.severity).toBe('drift');
|
|
103
|
+
expect(findings[0]?.remediation).toBe('celilo module generate wireguard-manager');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('host-plane results carry through with the right severity', () => {
|
|
107
|
+
const findings = auditModuleIntegrity({
|
|
108
|
+
results: [
|
|
109
|
+
{
|
|
110
|
+
...clean('wireguard'),
|
|
111
|
+
hostPlane: {
|
|
112
|
+
findings: [
|
|
113
|
+
{ hostname: 'a', state: 'converged' },
|
|
114
|
+
{ hostname: 'b', state: 'drift', detail: 'would change 2 things' },
|
|
115
|
+
{ hostname: 'c', state: 'unmeasured', detail: 'did not answer' },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
expect(findings.map((f) => [f.code, f.severity])).toEqual([
|
|
122
|
+
['module_host_drifted', 'drift'],
|
|
123
|
+
['module_host_unmeasured', 'unmeasured'],
|
|
124
|
+
]);
|
|
125
|
+
// An unreachable host must not let the run read as READY.
|
|
126
|
+
expect(computeVerdict(findings)).toBe('UNKNOWN');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('every remediation is a runnable celilo command', () => {
|
|
130
|
+
const findings = auditModuleIntegrity({
|
|
131
|
+
results: [
|
|
132
|
+
{ success: false, moduleId: 'a', violations: [], error: 'gone' },
|
|
133
|
+
{
|
|
134
|
+
...clean('b'),
|
|
135
|
+
success: false,
|
|
136
|
+
violations: [{ type: 'modified', path: 'x', message: 'x' }],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
141
|
+
for (const f of findings) {
|
|
142
|
+
expect(`${f.code}: ${f.remediation}`).toMatch(/: celilo /);
|
|
143
|
+
expect(f.actionable).toBe(true);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module integrity as a drift category
|
|
3
|
+
* (openspec/changes/module-integrity-rigor, D8).
|
|
4
|
+
*
|
|
5
|
+
* One implementation, three surfaces: `celilo module verify <id>` for one
|
|
6
|
+
* module, `celilo system audit` for every module, and `system doctor`'s fleet
|
|
7
|
+
* section. `system doctor` answers "can this box run celilo, and is the runtime
|
|
8
|
+
* wired up"; `system audit` answers "has anything drifted from desired state",
|
|
9
|
+
* and module integrity is a drift category by every structural test.
|
|
10
|
+
*
|
|
11
|
+
* Pure of I/O in the same way its siblings are: the caller does the auditing
|
|
12
|
+
* and passes the results in, so this file is testable without a filesystem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AuditResult } from '../../module/packaging/audit';
|
|
16
|
+
import type { DriftFinding } from './types';
|
|
17
|
+
|
|
18
|
+
export interface ModuleIntegrityAuditDeps {
|
|
19
|
+
/** One `auditModule` result per installed module. */
|
|
20
|
+
results: AuditResult[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A verify failure celilo could not perform at all — no baseline row, module
|
|
25
|
+
* directory gone — is `unmeasured`, not `drift`. It is the difference between
|
|
26
|
+
* "the files moved" and "I never looked", which is the whole point of D7.
|
|
27
|
+
*/
|
|
28
|
+
export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFinding[] {
|
|
29
|
+
const findings: DriftFinding[] = [];
|
|
30
|
+
|
|
31
|
+
for (const result of deps.results) {
|
|
32
|
+
if (result.error) {
|
|
33
|
+
findings.push({
|
|
34
|
+
category: 'module_integrity',
|
|
35
|
+
severity: 'unmeasured',
|
|
36
|
+
code: 'module_integrity_unmeasured',
|
|
37
|
+
message: `${result.moduleId}: integrity could not be checked — ${result.error}`,
|
|
38
|
+
remediation: `celilo module verify ${result.moduleId}`,
|
|
39
|
+
actionable: true,
|
|
40
|
+
subject: result.moduleId,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Ahead of the file findings it explains. When the baseline describes a
|
|
46
|
+
// different version, every file difference beneath it is a consequence of
|
|
47
|
+
// that and not independent evidence.
|
|
48
|
+
const stale = result.violations.filter((v) => v.type === 'stale-baseline');
|
|
49
|
+
for (const violation of stale) {
|
|
50
|
+
findings.push({
|
|
51
|
+
category: 'module_integrity',
|
|
52
|
+
severity: 'unmeasured',
|
|
53
|
+
code: 'module_integrity_stale_baseline',
|
|
54
|
+
message: `${result.moduleId}: ${violation.message}`,
|
|
55
|
+
remediation: `celilo module update ${result.moduleId}`,
|
|
56
|
+
actionable: true,
|
|
57
|
+
subject: result.moduleId,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const generated = result.violations.filter((v) => v.type === 'stale-generated');
|
|
62
|
+
if (generated.length > 0) {
|
|
63
|
+
findings.push({
|
|
64
|
+
category: 'module_integrity',
|
|
65
|
+
severity: 'drift',
|
|
66
|
+
code: 'module_generated_stale',
|
|
67
|
+
message: `${result.moduleId}: ${generated.length} generated asset(s) do not match the installed module — a deploy would ship the wrong bytes`,
|
|
68
|
+
details: generated.map((v) => ` • ${v.path}`).join('\n'),
|
|
69
|
+
remediation: `celilo module generate ${result.moduleId}`,
|
|
70
|
+
actionable: true,
|
|
71
|
+
subject: result.moduleId,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// File differences are only meaningful once the baseline is trustworthy.
|
|
76
|
+
// Reporting them under a stale baseline is what made `module verify`
|
|
77
|
+
// unreadable: 72 violations across two healthy modules, nearly all of them
|
|
78
|
+
// consequences of one frozen row.
|
|
79
|
+
const files = result.violations.filter(
|
|
80
|
+
(v) => v.type === 'modified' || v.type === 'missing' || v.type === 'extra',
|
|
81
|
+
);
|
|
82
|
+
if (files.length > 0 && stale.length === 0) {
|
|
83
|
+
findings.push({
|
|
84
|
+
category: 'module_integrity',
|
|
85
|
+
severity: 'drift',
|
|
86
|
+
code: 'module_files_drifted',
|
|
87
|
+
message: `${result.moduleId}: ${files.length} installed file(s) do not match the ${result.baselineVersion ?? 'recorded'} baseline`,
|
|
88
|
+
details: files.map((v) => ` • [${v.type}] ${v.path}`).join('\n'),
|
|
89
|
+
remediation: `celilo module verify ${result.moduleId} --json`,
|
|
90
|
+
actionable: true,
|
|
91
|
+
subject: result.moduleId,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const host of result.hostPlane?.findings ?? []) {
|
|
96
|
+
if (host.state === 'converged') continue;
|
|
97
|
+
findings.push({
|
|
98
|
+
category: 'module_integrity',
|
|
99
|
+
severity: host.state === 'drift' ? 'drift' : 'unmeasured',
|
|
100
|
+
code: host.state === 'drift' ? 'module_host_drifted' : 'module_host_unmeasured',
|
|
101
|
+
message: `${result.moduleId} on ${host.hostname}: ${host.detail}`,
|
|
102
|
+
remediation:
|
|
103
|
+
host.state === 'drift'
|
|
104
|
+
? `celilo module deploy ${result.moduleId}`
|
|
105
|
+
: `celilo module verify ${result.moduleId} --deep`,
|
|
106
|
+
actionable: true,
|
|
107
|
+
subject: result.moduleId,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
@@ -102,7 +102,10 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
|
|
|
102
102
|
if (r.status === 'rejected') {
|
|
103
103
|
findings.push({
|
|
104
104
|
category: 'module_versions',
|
|
105
|
-
|
|
105
|
+
// A registry that did not answer is not a version difference. Reporting
|
|
106
|
+
// it as `drift` said "this module is behind" when the truth was "I
|
|
107
|
+
// could not ask" — the ambiguity D7 exists to remove.
|
|
108
|
+
severity: 'unmeasured',
|
|
106
109
|
code: 'module_version_lookup_failed',
|
|
107
110
|
message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`,
|
|
108
111
|
remediation: 'Check network connectivity to the registry, then press R to re-audit.',
|
|
@@ -11,13 +11,18 @@ const journalWith = (tags: string[]) => () => ({
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
describe('auditSchema', () => {
|
|
14
|
-
test('
|
|
14
|
+
test('an unreadable journal is unmeasured, not clean', async () => {
|
|
15
|
+
// Was `expect(result).toEqual([])`. Silence rendered as READY, and pending
|
|
16
|
+
// migrations are the difference between a `.deb` that installed and a
|
|
17
|
+
// `.deb` that works — `apt upgrade` does not run them (celilo#169).
|
|
15
18
|
const result = await auditSchema({
|
|
16
19
|
journal: () => null,
|
|
17
20
|
applied: () => [],
|
|
18
21
|
db: fakeDb,
|
|
19
22
|
});
|
|
20
|
-
expect(result).
|
|
23
|
+
expect(result).toHaveLength(1);
|
|
24
|
+
expect(result[0]?.severity).toBe('unmeasured');
|
|
25
|
+
expect(result[0]?.code).toBe('schema_journal_unreadable');
|
|
21
26
|
});
|
|
22
27
|
|
|
23
28
|
test('no finding when every journal entry is applied', async () => {
|
|
@@ -78,7 +78,25 @@ export interface SchemaAuditDeps {
|
|
|
78
78
|
|
|
79
79
|
export async function auditSchema(deps: SchemaAuditDeps): Promise<DriftFinding[]> {
|
|
80
80
|
const journal = deps.journal();
|
|
81
|
-
if (!journal)
|
|
81
|
+
if (!journal) {
|
|
82
|
+
// Pending migrations are the difference between a `.deb` that installed and
|
|
83
|
+
// a `.deb` that works — `apt upgrade` does not run them (celilo#169). With
|
|
84
|
+
// no journal there is nothing to compare against, and silence used to
|
|
85
|
+
// render that as READY.
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
category: 'schema',
|
|
89
|
+
severity: 'unmeasured',
|
|
90
|
+
code: 'schema_journal_unreadable',
|
|
91
|
+
message:
|
|
92
|
+
'No drizzle migration journal could be read, so pending schema migrations are unknown',
|
|
93
|
+
remediation:
|
|
94
|
+
'The installed @celilo/cli is missing its `drizzle/meta/_journal.json`. Reinstall it, then re-audit. This records that the comparison did not happen, not that the schema is current.',
|
|
95
|
+
actionable: false,
|
|
96
|
+
subject: 'system',
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
82
100
|
|
|
83
101
|
// drizzle stores SHA-256 hashes (not tag names) in __drizzle_migrations,
|
|
84
102
|
// so we can't intersect tags. The next-best signal is "did all the
|
|
@@ -112,8 +112,23 @@ export async function auditTerraformPlan(deps: TerraformPlanAuditDeps): Promise<
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
const summary = parsePlanSummary(result.stdout);
|
|
115
|
-
if (!summary
|
|
116
|
-
//
|
|
115
|
+
if (!summary) {
|
|
116
|
+
// Terraform exited zero and celilo could not read its answer. That is not
|
|
117
|
+
// "no drift" — it is no measurement, and it used to be indistinguishable
|
|
118
|
+
// from a clean plan (D7).
|
|
119
|
+
findings.push({
|
|
120
|
+
category: 'terraform_plan',
|
|
121
|
+
severity: 'unmeasured',
|
|
122
|
+
code: 'terraform_plan_unparseable',
|
|
123
|
+
message: `${m.id}: terraform plan succeeded but its summary could not be parsed, so infrastructure drift is unknown`,
|
|
124
|
+
details: result.stdout.slice(0, 500),
|
|
125
|
+
remediation: `Run terraform plan in ${m.terraformDir} and read it directly. This records that the comparison did not happen, not that the infrastructure matches.`,
|
|
126
|
+
actionable: false,
|
|
127
|
+
subject: m.id,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (summary.add === 0 && summary.change === 0 && summary.destroy === 0) {
|
|
117
132
|
continue;
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -17,6 +17,14 @@ const blocked: DriftFinding = {
|
|
|
17
17
|
subject: 'lunacycle',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const unmeasured: DriftFinding = {
|
|
21
|
+
category: 'module_integrity',
|
|
22
|
+
severity: 'unmeasured',
|
|
23
|
+
code: 'module_integrity_unmeasured',
|
|
24
|
+
message: 'wireguard-manager: the host did not answer, so nothing was measured',
|
|
25
|
+
subject: 'wireguard-manager',
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
const todo: DriftFinding = {
|
|
21
29
|
category: 'undeployed_modules',
|
|
22
30
|
severity: 'todo',
|
|
@@ -59,4 +67,25 @@ describe('computeVerdict', () => {
|
|
|
59
67
|
test('BLOCKED still wins over todos', () => {
|
|
60
68
|
expect(computeVerdict([todo, drift, blocked])).toBe('BLOCKED');
|
|
61
69
|
});
|
|
70
|
+
|
|
71
|
+
// D7. An unmeasured check is not a pass, and it is not the same statement as
|
|
72
|
+
// a measured difference. Before this, a category that could not reach its
|
|
73
|
+
// subject contributed nothing, and contributing nothing rendered as READY.
|
|
74
|
+
test('UNKNOWN when something could not be measured', () => {
|
|
75
|
+
expect(computeVerdict([unmeasured])).toBe('UNKNOWN');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an unmeasured finding never lets the verdict return READY', () => {
|
|
79
|
+
expect(computeVerdict([todo, unmeasured])).not.toBe('READY');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('UNKNOWN outranks DRIFT — you cannot act on a diff you are not sure you have', () => {
|
|
83
|
+
expect(computeVerdict([drift, unmeasured])).toBe('UNKNOWN');
|
|
84
|
+
expect(computeVerdict([unmeasured, drift])).toBe('UNKNOWN');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('BLOCKED still outranks UNKNOWN — a hard stop is still a hard stop', () => {
|
|
88
|
+
expect(computeVerdict([unmeasured, blocked])).toBe('BLOCKED');
|
|
89
|
+
expect(computeVerdict([blocked, unmeasured, drift, todo])).toBe('BLOCKED');
|
|
90
|
+
});
|
|
62
91
|
});
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The overall verdict is computed from the findings:
|
|
11
11
|
* - any `blocked` finding → BLOCKED
|
|
12
|
-
* - any `
|
|
12
|
+
* - any `unmeasured` finding (no blocked) → UNKNOWN
|
|
13
|
+
* - any `drift` finding (no blocked, no unmeasured) → DRIFT
|
|
13
14
|
* - only `todo` findings (or none) → READY
|
|
14
15
|
*
|
|
15
16
|
* `todo` exists because some categories surface "next-step reminders"
|
|
@@ -42,11 +43,35 @@ export type DriftCategory =
|
|
|
42
43
|
| 'disk_space'
|
|
43
44
|
| 'transport_reads'
|
|
44
45
|
| 'trusted_sources'
|
|
45
|
-
| 'interface_classification'
|
|
46
|
+
| 'interface_classification'
|
|
47
|
+
| 'module_integrity'
|
|
48
|
+
| 'detect_without_converge';
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* `unmeasured` is a check that could not reach its subject
|
|
52
|
+
* (openspec/changes/module-integrity-rigor, D7).
|
|
53
|
+
*
|
|
54
|
+
* It exists because there was nowhere to put that. A category that could not
|
|
55
|
+
* measure either invented a finding of the wrong severity or contributed
|
|
56
|
+
* nothing, and contributing nothing renders as READY — the same defect as
|
|
57
|
+
* everything else this change is about, sitting in the framework that is
|
|
58
|
+
* supposed to catch it.
|
|
59
|
+
*
|
|
60
|
+
* Every claim celilo makes about the fleet is either measured against the thing
|
|
61
|
+
* it describes, or reported as unmeasured. There is no third category, and
|
|
62
|
+
* unmeasured never renders as green.
|
|
63
|
+
*/
|
|
64
|
+
export type DriftSeverity = 'todo' | 'drift' | 'unmeasured' | 'blocked';
|
|
48
65
|
|
|
49
|
-
|
|
66
|
+
/**
|
|
67
|
+
* `UNKNOWN` is deliberately distinct from `DRIFT` rather than folded into it.
|
|
68
|
+
* "I could not tell" and "I can tell, and it is wrong" call for different
|
|
69
|
+
* operator responses, and collapsing them recreates exactly the ambiguity D1
|
|
70
|
+
* exists to remove one level down. It ranks below `BLOCKED` — a hard stop is
|
|
71
|
+
* still a hard stop — and above `DRIFT`, because you cannot act on a diff you
|
|
72
|
+
* are not sure you have.
|
|
73
|
+
*/
|
|
74
|
+
export type AuditVerdict = 'READY' | 'DRIFT' | 'UNKNOWN' | 'BLOCKED';
|
|
50
75
|
|
|
51
76
|
/**
|
|
52
77
|
* A single drift finding produced by a category check.
|
|
@@ -102,6 +127,7 @@ export interface SystemAuditReport {
|
|
|
102
127
|
*/
|
|
103
128
|
export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
|
|
104
129
|
if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
|
|
130
|
+
if (findings.some((f) => f.severity === 'unmeasured')) return 'UNKNOWN';
|
|
105
131
|
if (findings.some((f) => f.severity === 'drift')) return 'DRIFT';
|
|
106
132
|
return 'READY';
|
|
107
133
|
}
|
|
@@ -559,6 +559,27 @@ async function deployModuleImpl(
|
|
|
559
559
|
|
|
560
560
|
log.success('Templates generated');
|
|
561
561
|
|
|
562
|
+
// Pre-flight: refuse before contacting any system if the generated project
|
|
563
|
+
// does not match the module tree it was generated from (D6).
|
|
564
|
+
//
|
|
565
|
+
// This is the check that would have turned celilo#925 from a silent success
|
|
566
|
+
// into a refusal at the point of the fault. A verbatim role asset that
|
|
567
|
+
// failed to refresh is invisible downstream: `cp` reports no error, Ansible
|
|
568
|
+
// copies the stale bytes and reports `ok`, unchanged, and the module's
|
|
569
|
+
// version advances past code that never shipped. Local, milliseconds, no
|
|
570
|
+
// SSH. It deliberately does NOT verify after the deploy — Ansible's
|
|
571
|
+
// `changed` is truthful about what Ansible did, and the lie is upstream of
|
|
572
|
+
// Ansible.
|
|
573
|
+
const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
|
|
574
|
+
const staleGenerated = await refuseIfGeneratedIsStale(
|
|
575
|
+
moduleId,
|
|
576
|
+
module.sourcePath,
|
|
577
|
+
generatedPath,
|
|
578
|
+
);
|
|
579
|
+
if (staleGenerated) {
|
|
580
|
+
return { success: false, error: staleGenerated, phases };
|
|
581
|
+
}
|
|
582
|
+
|
|
562
583
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
563
584
|
if (manifest.hooks?.validate_config) {
|
|
564
585
|
const hookDef = manifest.hooks.validate_config;
|
|
@@ -401,8 +401,12 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
401
401
|
mkdirSync(moduleStorageBase, { recursive: true });
|
|
402
402
|
for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
|
|
403
403
|
if (!entry.isDirectory()) continue;
|
|
404
|
+
// `force` explicit: this merges over an EXISTING module dir and the
|
|
405
|
+
// restored bytes must win. bun does not honour the documented default
|
|
406
|
+
// on every cp path (see generator.ts#copyAnsibleRoleFilesDirs).
|
|
404
407
|
cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
|
|
405
408
|
recursive: true,
|
|
409
|
+
force: true,
|
|
406
410
|
});
|
|
407
411
|
moduleSourcesApplied += 1;
|
|
408
412
|
}
|
|
@@ -76,6 +76,8 @@ const cleanAudit: AuditDeps = {
|
|
|
76
76
|
terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
|
77
77
|
moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
|
|
78
78
|
moduleConfigs: { modules: [] },
|
|
79
|
+
moduleIntegrity: { results: [] },
|
|
80
|
+
detectWithoutConverge: { modules: [] },
|
|
79
81
|
health: { results: [] },
|
|
80
82
|
backups: { modules: [] },
|
|
81
83
|
abandonedOperations: { records: [] },
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `copyAnsibleRoleFilesDirs` had no test, and celilo#925 is what that cost.
|
|
3
|
+
*
|
|
4
|
+
* A module's Ansible role `files/` directory holds static assets — most often a
|
|
5
|
+
* compiled binary — that cannot go through the utf-8 template pipeline. They are
|
|
6
|
+
* copied verbatim on every generate. Except they were not: the copy omitted
|
|
7
|
+
* `force`, which Node defaults to true and bun does not, so the FIRST generate
|
|
8
|
+
* populated `generated/` and no later one ever replaced it.
|
|
9
|
+
*
|
|
10
|
+
* Nothing surfaced it. `cp` reports no error, so the caller's try/catch caught
|
|
11
|
+
* nothing; Ansible then installed the first binary forever and reported `ok`,
|
|
12
|
+
* unchanged, while the module's version field advanced past it. On the live
|
|
13
|
+
* fleet that read as a successful deploy of code that never shipped.
|
|
14
|
+
*
|
|
15
|
+
* The overwrite case is therefore the point of this file. A test that only
|
|
16
|
+
* copied into an empty directory would have passed throughout.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { copyAnsibleRoleFilesDirs } from './generator';
|
|
23
|
+
|
|
24
|
+
function moduleWithRoleFile(binary: string): string {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-rolefiles-'));
|
|
26
|
+
const filesDir = join(root, 'ansible', 'roles', 'demo', 'files');
|
|
27
|
+
mkdirSync(filesDir, { recursive: true });
|
|
28
|
+
writeFileSync(join(filesDir, 'demo-linux-x86_64'), binary);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const generatedBinary = (out: string): string =>
|
|
33
|
+
readFileSync(join(out, 'ansible', 'roles', 'demo', 'files', 'demo-linux-x86_64'), 'utf-8');
|
|
34
|
+
|
|
35
|
+
describe('copyAnsibleRoleFilesDirs', () => {
|
|
36
|
+
test('populates an empty generated tree', async () => {
|
|
37
|
+
const modulePath = moduleWithRoleFile('v1');
|
|
38
|
+
const outputPath = mkdtempSync(join(tmpdir(), 'celilo-out-'));
|
|
39
|
+
|
|
40
|
+
await copyAnsibleRoleFilesDirs(modulePath, outputPath);
|
|
41
|
+
|
|
42
|
+
expect(generatedBinary(outputPath)).toBe('v1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* celilo#925 in one assertion. This is the case that regressed, and the only
|
|
47
|
+
* one that can catch it: the destination already exists, and a rebuilt
|
|
48
|
+
* artifact has to replace it.
|
|
49
|
+
*/
|
|
50
|
+
test('OVERWRITES an artifact a previous generate already placed', async () => {
|
|
51
|
+
const outputPath = mkdtempSync(join(tmpdir(), 'celilo-out-'));
|
|
52
|
+
|
|
53
|
+
await copyAnsibleRoleFilesDirs(moduleWithRoleFile('v1'), outputPath);
|
|
54
|
+
expect(generatedBinary(outputPath)).toBe('v1');
|
|
55
|
+
|
|
56
|
+
// The module is rebuilt at a new version; generate runs again.
|
|
57
|
+
await copyAnsibleRoleFilesDirs(moduleWithRoleFile('v2'), outputPath);
|
|
58
|
+
|
|
59
|
+
expect(generatedBinary(outputPath)).toBe('v2');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a module with no role files/ directory is not an error', async () => {
|
|
63
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-norole-'));
|
|
64
|
+
mkdirSync(join(root, 'ansible', 'roles', 'demo', 'tasks'), { recursive: true });
|
|
65
|
+
const outputPath = mkdtempSync(join(tmpdir(), 'celilo-out-'));
|
|
66
|
+
|
|
67
|
+
await copyAnsibleRoleFilesDirs(root, outputPath);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -500,7 +500,29 @@ export async function copyAnsibleRoleFilesDirs(
|
|
|
500
500
|
if (!existsSync(srcFilesDir)) continue;
|
|
501
501
|
const destFilesDir = join(outputPath, 'ansible', 'roles', role.name, 'files');
|
|
502
502
|
await mkdir(dirname(destFilesDir), { recursive: true });
|
|
503
|
-
|
|
503
|
+
// `force: true` is LOAD-BEARING on bun, and its absence was celilo#925.
|
|
504
|
+
//
|
|
505
|
+
// Node defaults `force` to true, so this looked correct and is correct
|
|
506
|
+
// under Node. Bun 1.3.3 does not, on this path specifically — measured,
|
|
507
|
+
// copying "NEW" over an existing "OLD":
|
|
508
|
+
//
|
|
509
|
+
// recursive only -> NEW
|
|
510
|
+
// recursive + force -> NEW
|
|
511
|
+
// recursive + preserveTimestamps -> OLD <- what this was
|
|
512
|
+
// recursive + force + preserveTimestamps -> NEW
|
|
513
|
+
//
|
|
514
|
+
// celilo runs on bun. So a module's built binary landed in `generated/`
|
|
515
|
+
// exactly once, at first generate, and no later version ever replaced it —
|
|
516
|
+
// silently, because `cp` reports no error, so the caller's try/catch has
|
|
517
|
+
// nothing to catch. Ansible then copies that first binary forever and
|
|
518
|
+
// reports `ok`, unchanged, while the module's version field advances.
|
|
519
|
+
//
|
|
520
|
+
// The sibling call in `storage-set-path.ts:153` already passes `force`.
|
|
521
|
+
await cp(srcFilesDir, destFilesDir, {
|
|
522
|
+
recursive: true,
|
|
523
|
+
force: true,
|
|
524
|
+
preserveTimestamps: true,
|
|
525
|
+
});
|
|
504
526
|
}
|
|
505
527
|
}
|
|
506
528
|
|