@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.
Files changed (88) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +18 -2
  3. package/MODULE_PRIMITIVES.md +25 -7
  4. package/drizzle/0026_module_integrity_version.sql +20 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +187 -2
  11. package/src/capabilities/validation.ts +53 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-audit.ts +5 -2
  14. package/src/cli/commands/module-remove.ts +34 -2
  15. package/src/cli/commands/module-update.test.ts +238 -3
  16. package/src/cli/commands/module-update.ts +206 -12
  17. package/src/cli/commands/module-verify.ts +77 -13
  18. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  19. package/src/cli/commands/service-set-credentials.ts +115 -0
  20. package/src/cli/commands/system-audit.ts +17 -0
  21. package/src/cli/commands/system-doctor.ts +78 -2
  22. package/src/cli/commands/system-migrate.ts +6 -4
  23. package/src/cli/commands/system-update.ts +33 -3
  24. package/src/cli/completion.ts +16 -1
  25. package/src/cli/index.ts +11 -2
  26. package/src/cli/tui/audit-state.ts +11 -3
  27. package/src/cli/tui/audit-tui.tsx +10 -4
  28. package/src/cli/tui/icons.ts +9 -2
  29. package/src/cli/tui/modals/analyzing.tsx +3 -0
  30. package/src/db/client.ts +10 -8
  31. package/src/db/migrate.test.ts +147 -0
  32. package/src/db/migrate.ts +69 -1
  33. package/src/db/schema.ts +5 -0
  34. package/src/hooks/capability-loader.test.ts +55 -0
  35. package/src/hooks/capability-loader.ts +16 -1
  36. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  37. package/src/manifest/schema.ts +23 -0
  38. package/src/module/import.ts +56 -40
  39. package/src/module/packaging/audit.ts +103 -28
  40. package/src/module/packaging/build.ts +12 -53
  41. package/src/module/packaging/classify-module-path.test.ts +104 -0
  42. package/src/module/packaging/extract.ts +31 -3
  43. package/src/module/packaging/generated-plane.test.ts +79 -0
  44. package/src/module/packaging/generated-plane.ts +134 -0
  45. package/src/module/packaging/host-plane.test.ts +132 -0
  46. package/src/module/packaging/host-plane.ts +135 -0
  47. package/src/module/packaging/package-rules.ts +62 -0
  48. package/src/policy/module-business-baseline.ts +0 -11
  49. package/src/services/alerting/monitors.ts +54 -2
  50. package/src/services/alerting/sweep-runner.ts +38 -1
  51. package/src/services/audit/cli-version.test.ts +6 -2
  52. package/src/services/audit/cli-version.ts +20 -6
  53. package/src/services/audit/detect-without-converge.test.ts +91 -0
  54. package/src/services/audit/detect-without-converge.ts +81 -0
  55. package/src/services/audit/disk-space.test.ts +5 -2
  56. package/src/services/audit/disk-space.ts +5 -3
  57. package/src/services/audit/health.test.ts +39 -0
  58. package/src/services/audit/index.test.ts +7 -1
  59. package/src/services/audit/index.ts +12 -0
  60. package/src/services/audit/module-integrity.test.ts +146 -0
  61. package/src/services/audit/module-integrity.ts +113 -0
  62. package/src/services/audit/module-versions.ts +4 -1
  63. package/src/services/audit/schema.test.ts +7 -2
  64. package/src/services/audit/schema.ts +19 -1
  65. package/src/services/audit/terraform-plan.ts +17 -2
  66. package/src/services/audit/types.test.ts +29 -0
  67. package/src/services/audit/types.ts +30 -4
  68. package/src/services/consumer-cleanup.ts +5 -3
  69. package/src/services/container-service.test.ts +34 -0
  70. package/src/services/container-service.ts +44 -0
  71. package/src/services/deployed-systems.test.ts +101 -0
  72. package/src/services/deployed-systems.ts +43 -11
  73. package/src/services/dns-provider-backfill.ts +30 -0
  74. package/src/services/fleet-checks.test.ts +26 -0
  75. package/src/services/fleet-checks.ts +11 -1
  76. package/src/services/module-deploy.ts +109 -41
  77. package/src/services/provider-arrival.test.ts +241 -0
  78. package/src/services/provider-arrival.ts +213 -0
  79. package/src/services/restore-from-file.ts +4 -0
  80. package/src/services/update/orchestrator.test.ts +2 -0
  81. package/src/templates/generator.test.ts +35 -0
  82. package/src/templates/generator.ts +29 -1
  83. package/src/variables/context.test.ts +63 -0
  84. package/src/variables/context.ts +10 -2
  85. package/src/variables/declarative-derivation.test.ts +47 -8
  86. package/src/variables/declarative-derivation.ts +6 -4
  87. package/src/services/public-web-republish.test.ts +0 -189
  88. package/src/services/public-web-republish.ts +0 -84
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { classifyModulePath } from './package-rules';
5
+
6
+ /**
7
+ * Real input, not input built by the same helper as the expectation.
8
+ *
9
+ * These fixtures are verbatim `celilo module verify` output captured from the
10
+ * live fleet on 2026-08-19 (see the fixture README). Every path below is a path
11
+ * that actually exists in an installed module tree on celilo-mgr. celilo#951
12
+ * shipped 18 false positives because its comparator's test synthesised both
13
+ * sides of the comparison and so never saw real input.
14
+ */
15
+ function fixturePaths(name: string): string[] {
16
+ const raw = readFileSync(
17
+ join(process.cwd(), 'test-fixtures', 'module-integrity', `${name}-verify.txt`),
18
+ 'utf-8',
19
+ );
20
+ const paths: string[] = [];
21
+ for (const line of raw.split('\n')) {
22
+ const match = /(?:Checksum mismatch|Unexpected file): (.+)$/.exec(line.trim());
23
+ if (match?.[1]) paths.push(match[1]);
24
+ }
25
+ return paths;
26
+ }
27
+
28
+ describe('classifyModulePath against the live fleet listings', () => {
29
+ for (const [moduleName, expectedCount] of [
30
+ ['wireguard-manager', 47],
31
+ ['wireguard', 25],
32
+ ] as const) {
33
+ describe(moduleName, () => {
34
+ const paths = fixturePaths(moduleName);
35
+
36
+ test(`fixture carries all ${expectedCount} reported paths`, () => {
37
+ expect(paths.length).toBe(expectedCount);
38
+ });
39
+
40
+ test('no path in a real installed tree classifies unknown', () => {
41
+ const unknown = paths.filter((p) => classifyModulePath(p) === 'unknown');
42
+ expect(unknown).toEqual([]);
43
+ });
44
+
45
+ test('celilo-owned paths classify derived', () => {
46
+ const derived = paths.filter(
47
+ (p) =>
48
+ p.startsWith('generated/') ||
49
+ p.includes('node_modules/') ||
50
+ p === 'checksums.json' ||
51
+ p === 'signature.sig',
52
+ );
53
+ // Guard the guard: if this is empty the assertion below is vacuous.
54
+ expect(derived.length).toBeGreaterThan(0);
55
+ for (const p of derived) {
56
+ expect(`${p} => ${classifyModulePath(p)}`).toBe(`${p} => derived`);
57
+ }
58
+ });
59
+
60
+ test("the module's own content classifies package", () => {
61
+ const own = paths.filter(
62
+ (p) =>
63
+ !p.startsWith('generated/') &&
64
+ !p.includes('node_modules/') &&
65
+ p !== 'checksums.json' &&
66
+ p !== 'signature.sig',
67
+ );
68
+ expect(own.length).toBeGreaterThan(0);
69
+ for (const p of own) {
70
+ expect(`${p} => ${classifyModulePath(p)}`).toBe(`${p} => package`);
71
+ }
72
+ });
73
+ });
74
+ }
75
+ });
76
+
77
+ describe('classifyModulePath: source-tree paths that must never be installed', () => {
78
+ test.each([
79
+ ['e2e/deploy.test.ts'],
80
+ ['.git/config'],
81
+ ['scripts/tsconfig.json'],
82
+ ['.DS_Store'],
83
+ ['server/src/api.test.ts'],
84
+ ['wireguard.netapp'],
85
+ ['node_modules/tldts/package.json'],
86
+ ])('%s is unknown', (relPath) => {
87
+ expect(classifyModulePath(relPath)).toBe('unknown');
88
+ });
89
+ });
90
+
91
+ describe('classifyModulePath: composes with includeNodeModulesPath', () => {
92
+ test('the hook runtime closure is derived, not unknown', () => {
93
+ expect(classifyModulePath('scripts/node_modules/tldts/index.js')).toBe('derived');
94
+ });
95
+
96
+ test('a .bin shim is still excluded', () => {
97
+ expect(classifyModulePath('scripts/node_modules/.bin/tsc')).toBe('unknown');
98
+ });
99
+
100
+ test('non-scripts node_modules ships only @celilo/capabilities', () => {
101
+ expect(classifyModulePath('node_modules/@celilo/capabilities/src/index.ts')).toBe('derived');
102
+ expect(classifyModulePath('node_modules/@celilo/cli/index.js')).toBe('unknown');
103
+ });
104
+ });
@@ -5,13 +5,40 @@ import { extract as tarExtract } from 'tar';
5
5
  import { z } from 'zod';
6
6
  import { parseJsonWithValidation } from '../../validation/schemas';
7
7
  import { computeFileChecksum } from './checksum';
8
+ import { classifyModulePath } from './package-rules';
8
9
  import { verifySignature } from './signature';
9
10
 
10
11
  /**
11
12
  * Integrity violation types
12
13
  */
13
14
  export interface IntegrityViolation {
14
- type: 'missing' | 'modified' | 'extra';
15
+ /**
16
+ * `stale-baseline` is not a file finding. It says the recorded checksums
17
+ * describe a DIFFERENT version of the module than the one celilo has
18
+ * installed, so every file finding beneath it is explained by the baseline
19
+ * being old rather than by the files having changed. Only `auditModule`
20
+ * produces it; package verification compares a package to its own manifest
21
+ * and cannot be stale in this sense.
22
+ *
23
+ * `stale-generated` is the second plane (D3): a verbatim role asset in
24
+ * `generated/` whose bytes are not the installed module's. That is what
25
+ * celilo#925 was, and what would ship on the next deploy.
26
+ */
27
+ type: 'missing' | 'modified' | 'extra' | 'stale-baseline' | 'stale-generated';
28
+ /**
29
+ * What the baseline says the file should hash to, and what it actually
30
+ * hashes to. Both optional because not every violation is about a digest —
31
+ * a `stale-baseline` finding is about the row, not a file.
32
+ *
33
+ * These exist so `module verify --json` can answer "is the installed tree
34
+ * the 0.3.2 package" in one call. celilo#925 stalled for days on that
35
+ * question because nothing could read a file on celilo-mgr, and the
36
+ * tempting fix — a remote read primitive — is a real security surface (every
37
+ * module's secrets and vault material live under the same tree) for a
38
+ * question file hashes answer directly (D9).
39
+ */
40
+ expectedDigest?: string;
41
+ actualDigest?: string | null;
15
42
  path: string;
16
43
  message: string;
17
44
  }
@@ -164,8 +191,9 @@ export async function verifyPackageIntegrity(
164
191
  const expectedFiles = new Set(Object.keys(expectedChecksums));
165
192
 
166
193
  for (const file of actualFiles) {
167
- // Skip checksums.json and signature.sig
168
- if (file === 'checksums.json' || file === 'signature.sig') {
194
+ // `checksums.json` / `signature.sig` and the rest of the derived set are
195
+ // never listed by the manifest they accompany.
196
+ if (classifyModulePath(file) === 'derived') {
169
197
  continue;
170
198
  }
171
199
 
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ compareVerbatimRoleAssets,
7
+ describeVerbatimDifferences,
8
+ readVerbatimRoleAssets,
9
+ } from './generated-plane';
10
+
11
+ describe('compareVerbatimRoleAssets', () => {
12
+ const asset = 'ansible/roles/vpn/files/vpn-linux-x86_64';
13
+
14
+ test('identical trees produce no differences', () => {
15
+ const both = new Map([[asset, 'aaa']]);
16
+ expect(compareVerbatimRoleAssets(both, new Map(both))).toEqual([]);
17
+ });
18
+
19
+ test('the celilo#925 shape: generated holds the previous version bytes', () => {
20
+ const differences = compareVerbatimRoleAssets(
21
+ new Map([[asset, 'new-digest']]),
22
+ new Map([[asset, 'old-digest']]),
23
+ );
24
+ expect(differences).toEqual([
25
+ {
26
+ relPath: asset,
27
+ reason: 'stale',
28
+ installedDigest: 'new-digest',
29
+ generatedDigest: 'old-digest',
30
+ },
31
+ ]);
32
+ expect(describeVerbatimDifferences(differences)[0]).toContain(asset);
33
+ });
34
+
35
+ test('an asset generation never produced is missing, not stale', () => {
36
+ expect(compareVerbatimRoleAssets(new Map([[asset, 'aaa']]), new Map())).toEqual([
37
+ { relPath: asset, reason: 'missing', installedDigest: 'aaa', generatedDigest: null },
38
+ ]);
39
+ });
40
+
41
+ test('output for a role the module no longer has is not a difference', () => {
42
+ // Nothing includes it, so refusing a deploy over it would refuse a correct
43
+ // module for a file Ansible never reads.
44
+ expect(
45
+ compareVerbatimRoleAssets(new Map(), new Map([['ansible/roles/gone/files/x', 'aaa']])),
46
+ ).toEqual([]);
47
+ });
48
+ });
49
+
50
+ describe('readVerbatimRoleAssets', () => {
51
+ test('digests role files/ assets and nothing else', async () => {
52
+ const root = mkdtempSync(join(tmpdir(), 'celilo-plane-'));
53
+ try {
54
+ mkdirSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'nested'), { recursive: true });
55
+ mkdirSync(join(root, 'ansible', 'roles', 'vpn', 'templates'), { recursive: true });
56
+ writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'bin'), 'NEW');
57
+ writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'files', 'nested', 'blob'), 'B');
58
+ // Templated, and expected to differ once generated. Must not be compared.
59
+ writeFileSync(join(root, 'ansible', 'roles', 'vpn', 'templates', 'x.j2'), '{{ v }}');
60
+
61
+ const assets = await readVerbatimRoleAssets(root);
62
+ expect([...assets.keys()].sort()).toEqual([
63
+ 'ansible/roles/vpn/files/bin',
64
+ 'ansible/roles/vpn/files/nested/blob',
65
+ ]);
66
+ } finally {
67
+ rmSync(root, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test('a module with no ansible/ at all yields nothing rather than throwing', async () => {
72
+ const root = mkdtempSync(join(tmpdir(), 'celilo-plane-'));
73
+ try {
74
+ expect((await readVerbatimRoleAssets(root)).size).toBe(0);
75
+ } finally {
76
+ rmSync(root, { recursive: true, force: true });
77
+ }
78
+ });
79
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The second plane: is what we would deploy built from what we installed?
3
+ *
4
+ * A module exists in four places — the published package, the installed tree,
5
+ * the generated project, and the host — and celilo reported a version for the
6
+ * first as though it described the last. Between them sit three copies and two
7
+ * transformations, and nothing verified any of them against the one before it
8
+ * (openspec/changes/module-integrity-rigor, D3).
9
+ *
10
+ * Only VERBATIM assets have a meaningful expected digest. Ansible templates
11
+ * most of what it writes, and a `.j2` in `generated/` is supposed to differ
12
+ * from its source. `ansible/roles/<role>/files/` is the exception: it holds
13
+ * static assets — built binaries, certs, blobs — copied byte for byte because
14
+ * they need no variable resolution and may not survive utf-8 round-tripping.
15
+ *
16
+ * That is exactly where celilo#925 lived. `copyAnsibleRoleFilesDirs` skipped an
17
+ * existing destination on bun, so a module's built binary landed in
18
+ * `generated/` once, at first generate, and no later version replaced it.
19
+ * `cp` reported no error, Ansible copied that first binary forever and reported
20
+ * `ok`, and the module's version field advanced past code that was never
21
+ * shipped. This comparison is local, takes milliseconds, needs no SSH, and is
22
+ * the check that turns that silence into a refusal.
23
+ */
24
+
25
+ import { existsSync } from 'node:fs';
26
+ import { readdir } from 'node:fs/promises';
27
+ import { join, relative } from 'node:path';
28
+ import { computeFileChecksum } from './checksum';
29
+
30
+ export interface VerbatimAssetDifference {
31
+ /** Path relative to the module root, identical in both trees. */
32
+ relPath: string;
33
+ /** `missing`: generation never produced it. `stale`: it holds other bytes. */
34
+ reason: 'missing' | 'stale';
35
+ installedDigest: string;
36
+ generatedDigest: string | null;
37
+ }
38
+
39
+ /**
40
+ * Pure. Both sides are digest maps keyed by the same module-relative path.
41
+ *
42
+ * An asset present in `generated` but absent from `installed` is NOT reported.
43
+ * That is a role the module no longer has, whose old output nothing includes,
44
+ * and failing a deploy for it would refuse a correct module for a stale file
45
+ * Ansible never reads.
46
+ */
47
+ export function compareVerbatimRoleAssets(
48
+ installed: ReadonlyMap<string, string>,
49
+ generated: ReadonlyMap<string, string>,
50
+ ): VerbatimAssetDifference[] {
51
+ const differences: VerbatimAssetDifference[] = [];
52
+ for (const [relPath, installedDigest] of installed) {
53
+ const generatedDigest = generated.get(relPath) ?? null;
54
+ if (generatedDigest === null) {
55
+ differences.push({ relPath, reason: 'missing', installedDigest, generatedDigest: null });
56
+ } else if (generatedDigest !== installedDigest) {
57
+ differences.push({ relPath, reason: 'stale', installedDigest, generatedDigest });
58
+ }
59
+ }
60
+ return differences;
61
+ }
62
+
63
+ /**
64
+ * Digest every `ansible/roles/<role>/files/**` asset under `root`, keyed by its
65
+ * path relative to `root`. The generated project mirrors that layout, so the
66
+ * two maps this produces are directly comparable.
67
+ */
68
+ export async function readVerbatimRoleAssets(root: string): Promise<Map<string, string>> {
69
+ const assets = new Map<string, string>();
70
+ const rolesDir = join(root, 'ansible', 'roles');
71
+ if (!existsSync(rolesDir)) return assets;
72
+
73
+ for (const role of await readdir(rolesDir, { withFileTypes: true })) {
74
+ if (!role.isDirectory()) continue;
75
+ const filesDir = join(rolesDir, role.name, 'files');
76
+ if (!existsSync(filesDir)) continue;
77
+ for (const filePath of await listFiles(filesDir)) {
78
+ assets.set(relative(root, filePath), await computeFileChecksum(filePath));
79
+ }
80
+ }
81
+ return assets;
82
+ }
83
+
84
+ async function listFiles(dir: string): Promise<string[]> {
85
+ const found: string[] = [];
86
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
87
+ const full = join(dir, entry.name);
88
+ if (entry.isDirectory()) {
89
+ found.push(...(await listFiles(full)));
90
+ } else if (entry.isFile()) {
91
+ found.push(full);
92
+ }
93
+ }
94
+ return found;
95
+ }
96
+
97
+ /**
98
+ * One line per difference, naming the file. celilo#925 took days partly because
99
+ * nothing anywhere named the file that had gone stale.
100
+ */
101
+ export function describeVerbatimDifferences(differences: VerbatimAssetDifference[]): string[] {
102
+ return differences.map((d) =>
103
+ d.reason === 'missing'
104
+ ? `${d.relPath}: generation never produced it (installed ${d.installedDigest})`
105
+ : `${d.relPath}: generated holds ${d.generatedDigest}, installed is ${d.installedDigest}`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The deploy pre-flight (D6). Returns the refusal message, or `null` to proceed.
111
+ *
112
+ * Local, milliseconds, no SSH, and it runs before anything contacts a system.
113
+ * It deliberately does NOT verify after the deploy: Ansible's `changed` is
114
+ * truthful about what Ansible did, and the lie in celilo#925 was upstream of
115
+ * Ansible.
116
+ */
117
+ export async function refuseIfGeneratedIsStale(
118
+ moduleId: string,
119
+ modulePath: string,
120
+ generatedPath: string,
121
+ ): Promise<string | null> {
122
+ const differences = compareVerbatimRoleAssets(
123
+ await readVerbatimRoleAssets(modulePath),
124
+ await readVerbatimRoleAssets(generatedPath),
125
+ );
126
+ if (differences.length === 0) return null;
127
+ return [
128
+ `Refusing to deploy ${moduleId}: the generated project does not match the installed module.`,
129
+ ` ${differences.length} verbatim asset(s) differ:`,
130
+ ...describeVerbatimDifferences(differences).map((line) => ` ${line}`),
131
+ '',
132
+ `Deploying would ship these bytes and report success. Run 'celilo module generate ${moduleId}' and try again.`,
133
+ ].join('\n');
134
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ModuleManifest } from '../../manifest/schema';
6
+ import { classifyHostRecap, verifyModuleOnHosts } from './host-plane';
7
+
8
+ const recap = (over: Partial<Parameters<typeof classifyHostRecap>[0]> = {}) => ({
9
+ host: 'vpn-manager',
10
+ changed: 0,
11
+ unreachable: 0,
12
+ failed: 0,
13
+ skipped: 0,
14
+ ...over,
15
+ });
16
+
17
+ describe('classifyHostRecap', () => {
18
+ test('nothing would change → converged', () => {
19
+ expect(classifyHostRecap(recap(), 'm').state).toBe('converged');
20
+ });
21
+
22
+ test('something would change → drift, and never blocked', () => {
23
+ const finding = classifyHostRecap(recap({ changed: 3 }), 'wireguard-manager');
24
+ expect(finding.state).toBe('drift');
25
+ expect(finding.detail).toContain('celilo module deploy wireguard-manager');
26
+ });
27
+
28
+ test('unreachable → unmeasured, not converged', () => {
29
+ expect(classifyHostRecap(recap({ unreachable: 1 }), 'm').state).toBe('unmeasured');
30
+ });
31
+
32
+ test('failed → unmeasured', () => {
33
+ expect(classifyHostRecap(recap({ failed: 1 }), 'm').state).toBe('unmeasured');
34
+ });
35
+
36
+ test('THE case that must not read as converged: check mode skipped tasks', () => {
37
+ // Ansible does not evaluate a task it cannot support — it SKIPS it. A role
38
+ // of `command:` / `shell:` tasks can finish with changed=0 having never
39
+ // been applied to the host at all. Two states would call that converged.
40
+ const finding = classifyHostRecap(recap({ skipped: 4 }), 'm');
41
+ expect(finding.state).toBe('unmeasured');
42
+ expect(finding.detail).toContain('4 task(s)');
43
+ });
44
+
45
+ test('a skip outranks a change, because the run as a whole was not measured', () => {
46
+ expect(classifyHostRecap(recap({ skipped: 1, changed: 2 }), 'm').state).toBe('unmeasured');
47
+ });
48
+ });
49
+
50
+ describe('verifyModuleOnHosts', () => {
51
+ function generatedTreeWithPlaybook(): string {
52
+ const root = mkdtempSync(join(tmpdir(), 'celilo-hostplane-'));
53
+ mkdirSync(join(root, 'ansible'), { recursive: true });
54
+ writeFileSync(join(root, 'ansible', 'playbook.yml'), '---\n- hosts: all\n');
55
+ return root;
56
+ }
57
+
58
+ const manifest = { id: 'm', name: 'M', version: '1.0.0' } as unknown as ModuleManifest;
59
+
60
+ test('a manifest opt-out is carried back, not silently dropped', async () => {
61
+ const optedOut = {
62
+ ...manifest,
63
+ verify: { deep: false, reason: 'the role is command:-driven' },
64
+ } as unknown as ModuleManifest;
65
+
66
+ const result = await verifyModuleOnHosts({
67
+ moduleId: 'm',
68
+ manifest: optedOut,
69
+ generatedPath: '/nonexistent',
70
+ execute: async () => {
71
+ throw new Error('must not run ansible for an opted-out module');
72
+ },
73
+ });
74
+ expect(result.optedOut?.reason).toBe('the role is command:-driven');
75
+ expect(result.findings).toEqual([]);
76
+ });
77
+
78
+ test('no generated playbook is unmeasured, not clean', async () => {
79
+ const result = await verifyModuleOnHosts({
80
+ moduleId: 'm',
81
+ manifest,
82
+ generatedPath: '/nonexistent',
83
+ execute: async () => {
84
+ throw new Error('must not run ansible without a playbook');
85
+ },
86
+ });
87
+ expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']);
88
+ });
89
+
90
+ test('a run producing no PLAY RECAP is unmeasured, not clean', async () => {
91
+ // celilo#951's shape in another comparator: nothing measured, rendered as
92
+ // nothing wrong.
93
+ const root = generatedTreeWithPlaybook();
94
+ try {
95
+ const result = await verifyModuleOnHosts({
96
+ moduleId: 'm',
97
+ manifest,
98
+ generatedPath: root,
99
+ execute: async () => ({ success: false, output: 'ssh: connect refused', error: 'boom' }),
100
+ });
101
+ expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']);
102
+ expect(result.findings[0]?.detail).toContain('no PLAY RECAP');
103
+ } finally {
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('one finding per host in the recap', async () => {
109
+ const root = generatedTreeWithPlaybook();
110
+ try {
111
+ const result = await verifyModuleOnHosts({
112
+ moduleId: 'm',
113
+ manifest,
114
+ generatedPath: root,
115
+ execute: async () => ({
116
+ success: true,
117
+ output: [
118
+ 'PLAY RECAP *********',
119
+ 'vpn-manager : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0',
120
+ 'edge : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0',
121
+ ].join('\n'),
122
+ }),
123
+ });
124
+ expect(result.findings).toEqual([
125
+ { hostname: 'vpn-manager', state: 'converged' },
126
+ expect.objectContaining({ hostname: 'edge', state: 'drift' }),
127
+ ]);
128
+ } finally {
129
+ rmSync(root, { recursive: true, force: true });
130
+ }
131
+ });
132
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The third plane: is the code running on the host the code we generated?
3
+ *
4
+ * Measured by asking Ansible, not by hashing files. celilo does not know where
5
+ * Ansible puts what it writes — the destination lives in the role's
6
+ * `tasks/main.yml` `dest:`, which celilo does not parse and should not start
7
+ * parsing. Ansible already computes exactly this, for every task, including
8
+ * templated files whose content celilo could not predict
9
+ * (openspec/changes/module-integrity-rigor, D4).
10
+ *
11
+ * This SSHes to every system the module deploys, so it is gated behind
12
+ * `--deep` and never runs in a default pass.
13
+ *
14
+ * The honest caveat, in the spec and not only in the code: check mode does not
15
+ * evaluate a task it cannot support — it SKIPS it — so a role built from
16
+ * `command:` / `shell:` tasks can finish a check run with `changed=0` having
17
+ * never been applied to the host at all. So a skip is `unmeasured`, never a
18
+ * pass, and a finding here is `drift`, never `blocked`. A check that cries wolf
19
+ * is the disease; shipping one here would be an unusually stupid way to catch
20
+ * it.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import type { ModuleManifest } from '../../manifest/schema';
26
+ import { executeAnsible, parseAnsibleRecap } from '../../services/deploy-ansible';
27
+
28
+ /**
29
+ * `unmeasured` is not a hedge and must never collapse into `converged`.
30
+ * Absence of a change is not evidence of convergence when nothing was assessed.
31
+ */
32
+ export type HostPlaneState = 'converged' | 'drift' | 'unmeasured';
33
+
34
+ export interface HostPlaneFinding {
35
+ hostname: string;
36
+ state: HostPlaneState;
37
+ /** Why, in operator-facing words. Always set for anything but `converged`. */
38
+ detail?: string;
39
+ }
40
+
41
+ export interface HostPlaneResult {
42
+ findings: HostPlaneFinding[];
43
+ /**
44
+ * Set when the module declared `verify.deep: false`. Carried so callers can
45
+ * PRINT the opt-out — an opt-out nobody sees is a check that quietly
46
+ * disappeared.
47
+ */
48
+ optedOut?: { reason: string };
49
+ }
50
+
51
+ /**
52
+ * Classify one Ansible `PLAY RECAP` line. Pure, so the interesting decisions
53
+ * are testable without an SSH round trip.
54
+ */
55
+ export function classifyHostRecap(
56
+ recap: { host: string; changed: number; unreachable: number; failed: number; skipped: number },
57
+ moduleId: string,
58
+ ): HostPlaneFinding {
59
+ if (recap.unreachable > 0 || recap.failed > 0) {
60
+ return {
61
+ hostname: recap.host,
62
+ state: 'unmeasured',
63
+ detail: 'The host could not be evaluated — it did not answer, or the play errored on it.',
64
+ };
65
+ }
66
+ if (recap.skipped > 0) {
67
+ return {
68
+ hostname: recap.host,
69
+ state: 'unmeasured',
70
+ detail: `${recap.skipped} task(s) check mode cannot evaluate were skipped, so convergence was not measured. Prefer check-capable Ansible modules (copy, template, lineinfile, file, package, service), or give a command/shell task an honest changed_when:.`,
71
+ };
72
+ }
73
+ if (recap.changed > 0) {
74
+ return {
75
+ hostname: recap.host,
76
+ state: 'drift',
77
+ detail: `The playbook would change ${recap.changed} thing(s) on this host, so what is running is not what celilo generated. Run 'celilo module deploy ${moduleId}' to converge it.`,
78
+ };
79
+ }
80
+ return { hostname: recap.host, state: 'converged' };
81
+ }
82
+
83
+ /**
84
+ * Evaluate the module's generated playbook against its systems in check mode.
85
+ *
86
+ * `executeAnsible(..., { check: true })` is the existing plumbing and the only
87
+ * one — `--check` is one argument, not a second execution path.
88
+ */
89
+ export async function verifyModuleOnHosts(args: {
90
+ moduleId: string;
91
+ manifest: ModuleManifest;
92
+ generatedPath: string;
93
+ /** Injected in tests, exactly as `verifyAspectCoverage` does it. */
94
+ execute?: typeof executeAnsible;
95
+ }): Promise<HostPlaneResult> {
96
+ const { moduleId, manifest, generatedPath } = args;
97
+
98
+ if (manifest.verify?.deep === false) {
99
+ return { findings: [], optedOut: { reason: manifest.verify.reason } };
100
+ }
101
+
102
+ const playbookPath = join(generatedPath, 'ansible', 'playbook.yml');
103
+ if (!existsSync(playbookPath)) {
104
+ return {
105
+ findings: [
106
+ {
107
+ hostname: '(none)',
108
+ state: 'unmeasured',
109
+ detail: `No generated playbook at ${playbookPath}. Run 'celilo module generate ${moduleId}' first.`,
110
+ },
111
+ ],
112
+ };
113
+ }
114
+
115
+ const execute = args.execute ?? executeAnsible;
116
+ const result = await execute(generatedPath, { check: true, noInteractive: true });
117
+ const recaps = parseAnsibleRecap(result.output ?? '');
118
+
119
+ if (recaps.length === 0) {
120
+ // A run that produced no recap at all must not read as success. This is
121
+ // the shape celilo#951 got wrong in another comparator: nothing measured,
122
+ // rendered as nothing wrong.
123
+ return {
124
+ findings: [
125
+ {
126
+ hostname: '(none)',
127
+ state: 'unmeasured',
128
+ detail: `Ansible produced no PLAY RECAP, so nothing was measured.${result.error ? ` ${result.error}` : ''}`,
129
+ },
130
+ ],
131
+ };
132
+ }
133
+
134
+ return { findings: recaps.map((recap) => classifyHostRecap(recap, moduleId)) };
135
+ }