@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.
Files changed (50) hide show
  1. package/CELILO_SUBSYSTEMS.md +16 -2
  2. package/MODULE_PRIMITIVES.md +19 -6
  3. package/drizzle/0026_module_integrity_version.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/src/cli/commands/module-audit.ts +5 -2
  7. package/src/cli/commands/module-update.test.ts +90 -2
  8. package/src/cli/commands/module-update.ts +112 -6
  9. package/src/cli/commands/module-verify.ts +77 -13
  10. package/src/cli/commands/system-audit.ts +17 -0
  11. package/src/cli/commands/system-doctor.ts +78 -2
  12. package/src/cli/commands/system-update.ts +33 -3
  13. package/src/cli/index.ts +2 -2
  14. package/src/cli/tui/audit-state.ts +11 -3
  15. package/src/cli/tui/audit-tui.tsx +10 -4
  16. package/src/cli/tui/icons.ts +9 -2
  17. package/src/cli/tui/modals/analyzing.tsx +3 -0
  18. package/src/db/schema.ts +5 -0
  19. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  20. package/src/manifest/schema.ts +23 -0
  21. package/src/module/import.ts +36 -35
  22. package/src/module/packaging/audit.ts +103 -28
  23. package/src/module/packaging/build.ts +12 -53
  24. package/src/module/packaging/classify-module-path.test.ts +104 -0
  25. package/src/module/packaging/extract.ts +31 -3
  26. package/src/module/packaging/generated-plane.test.ts +79 -0
  27. package/src/module/packaging/generated-plane.ts +134 -0
  28. package/src/module/packaging/host-plane.test.ts +132 -0
  29. package/src/module/packaging/host-plane.ts +135 -0
  30. package/src/module/packaging/package-rules.ts +62 -0
  31. package/src/services/audit/cli-version.test.ts +6 -2
  32. package/src/services/audit/cli-version.ts +20 -6
  33. package/src/services/audit/detect-without-converge.test.ts +91 -0
  34. package/src/services/audit/detect-without-converge.ts +81 -0
  35. package/src/services/audit/disk-space.test.ts +5 -2
  36. package/src/services/audit/disk-space.ts +5 -3
  37. package/src/services/audit/health.test.ts +39 -0
  38. package/src/services/audit/index.test.ts +7 -1
  39. package/src/services/audit/index.ts +12 -0
  40. package/src/services/audit/module-integrity.test.ts +146 -0
  41. package/src/services/audit/module-integrity.ts +113 -0
  42. package/src/services/audit/module-versions.ts +4 -1
  43. package/src/services/audit/schema.test.ts +7 -2
  44. package/src/services/audit/schema.ts +19 -1
  45. package/src/services/audit/terraform-plan.ts +17 -2
  46. package/src/services/audit/types.test.ts +29 -0
  47. package/src/services/audit/types.ts +30 -4
  48. package/src/services/module-deploy.ts +21 -0
  49. package/src/services/restore-from-file.ts +4 -0
  50. package/src/services/update/orchestrator.test.ts +2 -0
@@ -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
+ }
@@ -42,3 +42,65 @@ export function includeNodeModulesPath(relPath: string): boolean {
42
42
  if (nmIdx + 2 >= segments.length) return true; // node_modules/@celilo dir itself
43
43
  return segments[nmIdx + 2] === 'capabilities';
44
44
  }
45
+
46
+ /**
47
+ * What a module-relative path IS, for integrity purposes.
48
+ *
49
+ * - `package` the module's own content. Belongs in `checksums.json` and must
50
+ * match it. A mismatch is a real finding.
51
+ * - `derived` celilo writes or rewrites the on-disk copy, so its content is
52
+ * not a stable integrity claim. Never a finding.
53
+ * - `unknown` neither. Never packaged, never installed, and the only kind of
54
+ * `[EXTRA]` worth printing.
55
+ */
56
+ export type ModulePathClass = 'package' | 'derived' | 'unknown';
57
+
58
+ /**
59
+ * The one answer to "what belongs to a module", replacing the four divergent
60
+ * copies that used to decide it independently (`build.ts#shouldExclude`,
61
+ * `audit.ts#FRAMEWORK_OWNED_PATHS`, `extract.ts#scanDirectory`,
62
+ * `import.ts#copyModuleFiles`). Every disagreement between them became a
63
+ * `module verify` violation — 72 of them across two healthy modules.
64
+ *
65
+ * `scripts/node_modules/**` is the interesting case: it is `derived`, because
66
+ * `module import` runs `bun install` over it and the bytes on disk stop
67
+ * matching the package's. It is nonetheless SHIPPED, because a target may have
68
+ * no reachable registry (ISS-0046) — that carve-out lives in `build.ts`, which
69
+ * composes this function with `includeNodeModulesPath` rather than restating
70
+ * either rule.
71
+ */
72
+ export function classifyModulePath(relPath: string): ModulePathClass {
73
+ const segments = relPath.split('/');
74
+ const name = segments[segments.length - 1] ?? '';
75
+
76
+ // The module's own e2e/ tree is tests plus their deps, including a
77
+ // node_modules of its own. Excluded whole, before anything below.
78
+ if (segments[0] === 'e2e') return 'unknown';
79
+
80
+ // Anything under a `node_modules` segment is decided by the canonical rule
81
+ // and by nothing else. Ordering matters: a vendored dependency ships files
82
+ // named `*.test.ts` (`@celilo/capabilities/src/remote.test.ts` is on the
83
+ // fleet right now) and those are the DEPENDENCY's, not the module's.
84
+ if (segments.includes('node_modules')) {
85
+ return includeNodeModulesPath(relPath) ? 'derived' : 'unknown';
86
+ }
87
+
88
+ // Source-tree noise. A module's git repo has it, a module's install never
89
+ // should, so on an installed tree it is a real finding.
90
+ if (segments.some((s) => s === '.git' || s === '.next' || s === '.cache')) return 'unknown';
91
+ if (name === '.DS_Store') return 'unknown';
92
+ // scripts/tsconfig.json exists so tsc can check hooks in CI. Nothing on a
93
+ // target ever runs tsc.
94
+ if (name === 'tsconfig.json') return 'unknown';
95
+ if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
96
+
97
+ // Celilo's own output under the module's install root.
98
+ if (segments[0] === 'generated' || segments[0] === 'screenshots') return 'derived';
99
+ // A checksum manifest cannot list itself, nor the signature over it.
100
+ if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
101
+ // Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).
102
+ if (relPath === 'celilo/types.d.ts') return 'derived';
103
+ if (relPath === 'cookies.json') return 'derived';
104
+
105
+ return 'package';
106
+ }
@@ -50,11 +50,15 @@ describe('auditCliVersion', () => {
50
50
  expect(result[0].message).toContain('0.1.7');
51
51
  });
52
52
 
53
- test('no finding when fetcher returns null (network failure)', async () => {
53
+ test('a registry we could not reach is unmeasured, not up-to-date', async () => {
54
+ // Was `expect(result).toEqual([])`. "I could not ask npm" and "you are on
55
+ // the latest" are different statements and rendered identically.
54
56
  const result = await auditCliVersion({
55
57
  installedVersion: '0.1.5',
56
58
  fetcher: async () => null,
57
59
  });
58
- expect(result).toEqual([]);
60
+ expect(result).toHaveLength(1);
61
+ expect(result[0]?.severity).toBe('unmeasured');
62
+ expect(result[0]?.code).toBe('cli_version_unmeasured');
59
63
  });
60
64
  });
@@ -2,11 +2,12 @@
2
2
  * CLI version drift check.
3
3
  *
4
4
  * Compares the running `@celilo/cli` version against the latest version
5
- * on npm. A newer published version produces a single `drift` finding;
6
- * a network failure (offline, npm down) produces no finding we don't
7
- * want a transient lookup failure to block `system update`. Instead the
8
- * caller logs a debug warning. Schema drift, capability ABI drift, etc.
9
- * are the BLOCKED gates; CLI version is informational only.
5
+ * on npm. A newer published version produces a single `drift` finding; a
6
+ * network failure (offline, npm down) produces an `unmeasured` one. It used to
7
+ * produce nothing, which rendered as READY "I could not ask npm" and "you are
8
+ * on the latest" are different statements and the operator should be able to
9
+ * tell them apart (D7). It still does not BLOCK: schema drift and capability
10
+ * ABI drift are the blocking gates; CLI version is informational.
10
11
  *
11
12
  * `latestVersionFetcher` is injectable so tests don't hit npm.
12
13
  */
@@ -67,7 +68,20 @@ export interface CliVersionAuditDeps {
67
68
  export async function auditCliVersion(deps: CliVersionAuditDeps): Promise<DriftFinding[]> {
68
69
  const fetcher = deps.fetcher ?? fetchLatestCliVersion;
69
70
  const latest = await fetcher();
70
- if (!latest) return []; // network failure — silently no finding
71
+ if (!latest) {
72
+ return [
73
+ {
74
+ category: 'cli_version',
75
+ severity: 'unmeasured',
76
+ code: 'cli_version_unmeasured',
77
+ message: `@celilo/cli ${deps.installedVersion}: could not reach the npm registry, so version drift is unknown`,
78
+ remediation:
79
+ 'Check outbound network to registry.npmjs.org, then re-audit. This finding records that the comparison did not happen, not that the CLI is current.',
80
+ actionable: false,
81
+ subject: 'system',
82
+ },
83
+ ];
84
+ }
71
85
 
72
86
  if (compareSemver(deps.installedVersion, latest) >= 0) {
73
87
  return []; // up to date or ahead (dev build)
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModuleManifest } from '../../manifest/schema';
3
+ import { auditDetectWithoutConverge } from './detect-without-converge';
4
+
5
+ function mod(
6
+ id: string,
7
+ manifest: Partial<ModuleManifest>,
8
+ state = 'INSTALLED',
9
+ ): { id: string; state: string; manifest: ModuleManifest } {
10
+ return { id, state, manifest: manifest as ModuleManifest };
11
+ }
12
+
13
+ const HOOK = { script: 'scripts/x.ts' };
14
+
15
+ describe('auditDetectWithoutConverge', () => {
16
+ test('a convergence hook no subscription fires is drift', () => {
17
+ // celilo#934's shape: the module has the correcting code and nothing runs
18
+ // it, so drift is computed every health check and corrected never.
19
+ const findings = auditDetectWithoutConverge({
20
+ modules: [mod('wireguard', { hooks: { reconcile_peers: HOOK } })],
21
+ });
22
+ expect(findings).toHaveLength(1);
23
+ expect(findings[0]?.code).toBe('converge_hook_never_fires');
24
+ expect(findings[0]?.message).toContain('reconcile_peers');
25
+ expect(findings[0]?.subject).toBe('wireguard');
26
+ });
27
+
28
+ test('the same module WIRED UP is not reported', () => {
29
+ // This is `wireguard` after celilo#968.
30
+ const findings = auditDetectWithoutConverge({
31
+ modules: [
32
+ mod('wireguard', {
33
+ hooks: { reconcile_peers: HOOK },
34
+ subscriptions: [
35
+ { name: 'wireguard-peer-converge', pattern: 'timer.tick.15m', hook: 'reconcile_peers' },
36
+ ],
37
+ } as Partial<ModuleManifest>),
38
+ ],
39
+ });
40
+ expect(findings).toEqual([]);
41
+ });
42
+
43
+ test('a subscription firing a DIFFERENT hook does not count', () => {
44
+ const findings = auditDetectWithoutConverge({
45
+ modules: [
46
+ mod('m', {
47
+ hooks: { reconcile_peers: HOOK, reconcile_routes: HOOK },
48
+ subscriptions: [{ name: 's', pattern: 'timer.tick.15m', hook: 'reconcile_routes' }],
49
+ } as Partial<ModuleManifest>),
50
+ ],
51
+ });
52
+ expect(findings).toHaveLength(1);
53
+ expect(findings[0]?.message).toContain('reconcile_peers');
54
+ expect(findings[0]?.message).not.toContain('reconcile_routes');
55
+ });
56
+
57
+ test('a module with no convergence hook at all is not reported', () => {
58
+ // A module that detects drift and declares no correcting hook is a missing
59
+ // feature, not dead wiring. Guessing at it from `health_check` alone would
60
+ // flag most of the fleet.
61
+ expect(
62
+ auditDetectWithoutConverge({
63
+ modules: [mod('caddy', { hooks: { health_check: { script: 'x.ts' } } })],
64
+ }),
65
+ ).toEqual([]);
66
+ });
67
+
68
+ test('`list_peers` is detection, not convergence, and is not required to fire', () => {
69
+ expect(
70
+ auditDetectWithoutConverge({ modules: [mod('wireguard', { hooks: { list_peers: HOOK } })] }),
71
+ ).toEqual([]);
72
+ });
73
+
74
+ test('an undeployed module is not reported', () => {
75
+ expect(
76
+ auditDetectWithoutConverge({
77
+ modules: [mod('m', { hooks: { reconcile_peers: HOOK } }, 'IMPORTED')],
78
+ }),
79
+ ).toEqual([]);
80
+ });
81
+
82
+ test('the remediation is prose, and is NOT marked actionable', () => {
83
+ // The fix is an edit to a module's manifest. Marking it actionable would
84
+ // put a Remediate button on a modal that cannot do anything.
85
+ const findings = auditDetectWithoutConverge({
86
+ modules: [mod('wireguard', { hooks: { reconcile_peers: HOOK } })],
87
+ });
88
+ expect(findings[0]?.actionable).toBe(false);
89
+ expect(findings[0]?.remediation).toContain('manifest.yml');
90
+ });
91
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A module that carries convergence code nothing ever runs
3
+ * (openspec/changes/module-integrity-rigor, D10; celilo#934).
4
+ *
5
+ * No convergence framework. celilo already has one — `subscriptions:` plus
6
+ * `timer.tick.<n>`, driven by the event bus, used by `technitium`,
7
+ * `wireguard-manager`, `namecheap` and `celilo-mgmt`. celilo#934 asked whether
8
+ * drift detection and correction deserve a shared shape; they have one. The
9
+ * gap was that nothing noticed when a module had the shape and not the wiring,
10
+ * so `wireguard` computed peer drift and corrected none of it.
11
+ *
12
+ * **What this rule actually checks, and how it narrows D10.** The design says
13
+ * "a module whose health check computes drift and whose manifest declares no
14
+ * reconcile subscription". The first half is not decidable from a manifest —
15
+ * celilo cannot read a hook script and tell whether it computes drift — so
16
+ * this checks the decidable, strictly stronger half: a module that DECLARES a
17
+ * convergence hook which no subscription fires. That names dead machinery with
18
+ * no interpretation and no false positives. A module that detects drift and
19
+ * declares no correcting hook at all is a missing feature, and a rule that
20
+ * guessed at it from `health_check` alone would flag most of the fleet.
21
+ */
22
+
23
+ import type { ModuleManifest } from '../../manifest/schema';
24
+ import type { DriftFinding } from './types';
25
+
26
+ /**
27
+ * Hooks whose whole purpose is to CORRECT something. If one of these exists
28
+ * and nothing calls it, the module has the fix and never applies it.
29
+ *
30
+ * `list_peers` is deliberately absent: it reports what a tunnel carries and
31
+ * corrects nothing, so it is a detection hook and firing it on a timer would
32
+ * achieve nothing.
33
+ */
34
+ const CONVERGENCE_HOOKS = [
35
+ 'reconcile_routes',
36
+ 'reconcile_clients',
37
+ 'reconcile_peers',
38
+ 'refresh_registrations',
39
+ 'reassert_dhcp_dns',
40
+ ] as const;
41
+
42
+ export interface DetectWithoutConvergeAuditDeps {
43
+ modules: { id: string; state: string; manifest: ModuleManifest }[];
44
+ }
45
+
46
+ const DEPLOYED_STATES = new Set(['INSTALLED', 'VERIFIED']);
47
+
48
+ export function auditDetectWithoutConverge(deps: DetectWithoutConvergeAuditDeps): DriftFinding[] {
49
+ const findings: DriftFinding[] = [];
50
+
51
+ for (const module of deps.modules) {
52
+ if (!DEPLOYED_STATES.has(module.state)) continue;
53
+
54
+ const hooks = module.manifest.hooks ?? {};
55
+ const declared = CONVERGENCE_HOOKS.filter((name) => hooks[name] !== undefined);
56
+ if (declared.length === 0) continue;
57
+
58
+ const fired = new Set(
59
+ (module.manifest.subscriptions ?? []).map((s) => s.hook).filter((h): h is string => !!h),
60
+ );
61
+ const unfired = declared.filter((name) => !fired.has(name));
62
+ if (unfired.length === 0) continue;
63
+
64
+ findings.push({
65
+ category: 'detect_without_converge',
66
+ severity: 'drift',
67
+ code: 'converge_hook_never_fires',
68
+ message: `${module.id}: declares ${unfired.join(', ')} but no subscription ever fires ${unfired.length === 1 ? 'it' : 'them'} — drift is detected and never corrected`,
69
+ details:
70
+ 'Add a `subscriptions:` entry on a `timer.tick.<n>` event naming the hook. `technitium`, `wireguard-manager`, `namecheap` and `celilo-mgmt` all do this; the machinery exists and this module is not wired into it.',
71
+ remediation: `Edit ${module.id}'s manifest.yml to add a subscriptions entry for ${unfired.join(', ')}, then 'celilo module update'.`,
72
+ // Prose, not a command: the fix is an edit to a module's manifest, and
73
+ // `actionable: true` on something no `celilo …` invocation performs would
74
+ // put a Remediate button on a modal that cannot do anything.
75
+ actionable: false,
76
+ subject: module.id,
77
+ });
78
+ }
79
+
80
+ return findings;
81
+ }
@@ -78,13 +78,16 @@ describe('auditDiskSpace', () => {
78
78
 
79
79
  // Unmeasurable must not read as healthy — but it must not page either, since
80
80
  // machines_reachable is already alerting for the same dead host.
81
- test('an unmeasurable host is recorded as todo, not as healthy', () => {
81
+ test('an unmeasurable host is recorded as unmeasured, not as healthy', () => {
82
82
  const findings = auditDiskSpace({
83
83
  results: [usage({ hostname: 'iot', usedPercent: null, message: 'ssh: connect timed out' })],
84
84
  });
85
85
 
86
86
  expect(findings).toHaveLength(1);
87
- expect(findings[0]?.severity).toBe('todo');
87
+ // Was `todo`, because that was the only non-paging severity available.
88
+ // `unmeasured` is what it always meant, and it stops the verdict returning
89
+ // READY.
90
+ expect(findings[0]?.severity).toBe('unmeasured');
88
91
  expect(findings[0]?.code).toBe('disk_unmeasured');
89
92
  expect(findings[0]?.details).toContain('timed out');
90
93
  });
@@ -66,12 +66,14 @@ export function auditDiskSpace(deps: DiskSpaceAuditDeps): DriftFinding[] {
66
66
  for (const result of deps.results) {
67
67
  // Unmeasurable is NOT healthy — but it does not page either. The host is
68
68
  // already unreachable, `machines_reachable` is already alerting on it, and
69
- // a second page for one dead host is noise. `todo` records without
70
- // notifying, which is the existing severity for exactly that.
69
+ // a second page for one dead host is noise. This used to be filed as `todo`
70
+ // because that was the only non-paging severity available; `unmeasured`
71
+ // (D7) is what it always meant, and unlike `todo` it stops the verdict
72
+ // returning READY.
71
73
  if (result.usedPercent === null) {
72
74
  findings.push({
73
75
  category: 'disk_space',
74
- severity: 'todo',
76
+ severity: 'unmeasured',
75
77
  code: 'disk_unmeasured',
76
78
  message: `${result.hostname}: disk usage could not be measured`,
77
79
  details: result.message,
@@ -81,4 +81,43 @@ describe('auditHealth', () => {
81
81
  expect(result).toHaveLength(2);
82
82
  expect(result.map((f) => f.subject).sort()).toEqual(['a', 'b']);
83
83
  });
84
+
85
+ test('firewall persistence drift reaches the audit as a drift finding', () => {
86
+ // The delivery path for celilo#670's fix: the iptables module reports drift
87
+ // as a `warn` health item, the runner turns any warn into `degraded`, and
88
+ // this turns `degraded` into an audit finding. No core code knows what an
89
+ // iptables rule is — which is the point (#941).
90
+ const unpersisted = '-A POSTROUTING -s 10.9.9.0/24 -o eth1 -j MASQUERADE';
91
+ const results: HealthCheckResult[] = [
92
+ {
93
+ moduleId: 'iptables',
94
+ status: 'degraded',
95
+ checks: [
96
+ { name: 'ssh_access', status: 'pass', message: 'SSH connected to fw01' },
97
+ {
98
+ name: 'persistence_drift',
99
+ status: 'warn',
100
+ message: '1 live rule(s) absent from /etc/iptables/rules.v4 (lost on reboot)',
101
+ details: `live only: nat ${unpersisted}`,
102
+ },
103
+ ],
104
+ },
105
+ ];
106
+
107
+ return auditHealth({ results }).then((findings) => {
108
+ expect(findings).toHaveLength(1);
109
+ expect(findings[0]).toMatchObject({
110
+ category: 'health',
111
+ severity: 'drift',
112
+ subject: 'iptables',
113
+ actionable: true,
114
+ });
115
+ expect(findings[0].details).toContain('persistence_drift');
116
+ expect(findings[0].details).toContain('lost on reboot');
117
+ // The audit is a summary: it carries the COUNT, and points at the command
118
+ // that names the rules. `module health` prints check.details verbatim
119
+ // (cli/commands/module-health.ts:49), so every drifted rule is reachable.
120
+ expect(findings[0].remediation).toBe('celilo module health iptables --debug');
121
+ });
122
+ });
84
123
  });
@@ -11,7 +11,11 @@ const emptyDeps = {
11
11
  fetcher: async () => '0.1.5',
12
12
  },
13
13
  schema: {
14
- journal: () => null,
14
+ // A READABLE journal with nothing pending. It used to be `() => null`,
15
+ // which now (correctly) reports `unmeasured` — an unreadable journal is not
16
+ // the same as a schema with no pending migrations, and this fixture means
17
+ // the latter.
18
+ journal: () => ({ version: '6', dialect: 'sqlite', entries: [] }),
15
19
  applied: () => [],
16
20
  db: fakeDb,
17
21
  },
@@ -26,6 +30,8 @@ const emptyDeps = {
26
30
  fetcher: async () => ({ latest: null }),
27
31
  },
28
32
  moduleConfigs: { modules: [] },
33
+ moduleIntegrity: { results: [] },
34
+ detectWithoutConverge: { modules: [] },
29
35
  health: { results: [] },
30
36
  backups: { modules: [] },
31
37
  abandonedOperations: { records: [] },