@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.
Files changed (55) hide show
  1. package/CELILO_SUBSYSTEMS.md +18 -4
  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 +41 -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/policy/module-script-scan.test.ts +164 -0
  32. package/src/policy/module-script-scan.ts +143 -0
  33. package/src/policy/no-hand-built-ssh.test.ts +22 -62
  34. package/src/services/audit/cli-version.test.ts +6 -2
  35. package/src/services/audit/cli-version.ts +20 -6
  36. package/src/services/audit/detect-without-converge.test.ts +91 -0
  37. package/src/services/audit/detect-without-converge.ts +81 -0
  38. package/src/services/audit/disk-space.test.ts +5 -2
  39. package/src/services/audit/disk-space.ts +5 -3
  40. package/src/services/audit/health.test.ts +39 -0
  41. package/src/services/audit/index.test.ts +7 -1
  42. package/src/services/audit/index.ts +12 -0
  43. package/src/services/audit/module-integrity.test.ts +146 -0
  44. package/src/services/audit/module-integrity.ts +113 -0
  45. package/src/services/audit/module-versions.ts +4 -1
  46. package/src/services/audit/schema.test.ts +7 -2
  47. package/src/services/audit/schema.ts +19 -1
  48. package/src/services/audit/terraform-plan.ts +17 -2
  49. package/src/services/audit/types.test.ts +29 -0
  50. package/src/services/audit/types.ts +30 -4
  51. package/src/services/module-deploy.ts +21 -0
  52. package/src/services/restore-from-file.ts +4 -0
  53. package/src/services/update/orchestrator.test.ts +2 -0
  54. package/src/templates/copy-role-files.test.ts +69 -0
  55. package/src/templates/generator.ts +23 -1
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md:
3
+ * **modules never hand-build SSH, and the one sanctioned raw-exec path is
4
+ * always justified in writing.**
5
+ *
6
+ * ONE definition of the rules, used by both enforcement points:
7
+ *
8
+ * - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
9
+ * on every `bun test`.
10
+ * - `apps/celilo/src/module/packaging/build.ts` — every `.netapp` at the
11
+ * moment it is packaged, including modules that never pass through this
12
+ * repo's CI (`bun run publish` is a documented escape hatch and runs no
13
+ * tests).
14
+ *
15
+ * Deliberately not two copies. The types this repo keeps re-learning that
16
+ * lesson on — `HookName` (celilo#821), `HookContext` — were duplicated
17
+ * declarations that drifted silently because nothing fails when two copies
18
+ * disagree. A scan rule is worse: the duplicate that drifts is the one that
19
+ * stops catching things, and a gate that stops catching things looks exactly
20
+ * like a gate with nothing to catch.
21
+ */
22
+
23
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
+ import { join, relative } from 'node:path';
25
+
26
+ export interface ScanViolation {
27
+ /** Path as the operator should see it — relative to the scanned root. */
28
+ file: string;
29
+ /** 1-based line number of the offending line. */
30
+ line: number;
31
+ /** Short rule name, e.g. `raw ssh invocation`. */
32
+ rule: string;
33
+ /** What to do instead. */
34
+ hint: string;
35
+ }
36
+
37
+ /**
38
+ * How far above a `runAppCommand*` call the justification may sit.
39
+ *
40
+ * Calibrated against the real call sites rather than guessed: the convention is
41
+ * a comment block directly above the call, but a call wrapped in `waitFor(() =>
42
+ * …)` puts the justification above the ENCLOSING statement, a few lines up. A
43
+ * window covers both without needing to parse TypeScript. Eight lines is the
44
+ * widest real gap plus headroom; wide enough to miss nothing legitimate, narrow
45
+ * enough that an unrelated hatch elsewhere in the function cannot launder a
46
+ * fresh call.
47
+ */
48
+ const ESCAPE_HATCH_LOOKBACK_LINES = 8;
49
+
50
+ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
51
+ {
52
+ rule: 'raw ssh invocation (StrictHostKeyChecking)',
53
+ re: /StrictHostKeyChecking/,
54
+ hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.',
55
+ },
56
+ {
57
+ rule: 'raw ssh invocation (ssh … root@)',
58
+ re: /\bssh\s+(?:-\S+\s+|\S*root@)/,
59
+ hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.',
60
+ },
61
+ {
62
+ rule: "'ssh2' import",
63
+ re: /(?:from|require\()\s*['"]ssh2['"]/,
64
+ hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
65
+ },
66
+ ];
67
+
68
+ /** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
69
+ const RAW_EXEC_CALL = /\brunAppCommand(?:WithSecret)?\s*\(/;
70
+
71
+ const ESCAPE_HATCH_MARKER = /escape-hatch:/;
72
+
73
+ /**
74
+ * Scan one file's source. Pure — takes the text, returns the violations, so it
75
+ * is testable without a filesystem and reusable over a staged package.
76
+ */
77
+ export function scanModuleScriptSource(file: string, source: string): ScanViolation[] {
78
+ const violations: ScanViolation[] = [];
79
+ const lines = source.split('\n');
80
+
81
+ lines.forEach((text, i) => {
82
+ for (const { rule, re, hint } of PATTERN_RULES) {
83
+ if (re.test(text)) violations.push({ file, line: i + 1, rule, hint });
84
+ }
85
+
86
+ if (!RAW_EXEC_CALL.test(text)) return;
87
+ const from = Math.max(0, i - ESCAPE_HATCH_LOOKBACK_LINES);
88
+ const justified = lines.slice(from, i).some((l) => ESCAPE_HATCH_MARKER.test(l));
89
+ if (!justified) {
90
+ violations.push({
91
+ file,
92
+ line: i + 1,
93
+ rule: 'unjustified raw-exec escape hatch',
94
+ hint:
95
+ 'runAppCommand* is the ONLY sanctioned raw-exec path and every call site must say why ' +
96
+ 'no capability, HTTP or converge path exists. Add an `// escape-hatch: …` comment ' +
97
+ 'immediately above the call. See MODULE_PRIMITIVES.md.',
98
+ });
99
+ }
100
+ });
101
+
102
+ return violations;
103
+ }
104
+
105
+ /**
106
+ * Every production `.ts` under a module's `scripts/` — excluding tests and
107
+ * `node_modules`.
108
+ *
109
+ * The exclusion is load-bearing, not tidiness: the shipped closure bundles
110
+ * `@celilo/capabilities`, whose `remote.ts` builds the `ssh … root@` string
111
+ * that every one of these rules exists to keep OUT of module code. Scanning it
112
+ * would fail every module in the fleet on the implementation of the primitives
113
+ * they were told to use.
114
+ */
115
+ export function moduleScriptFiles(scriptsDir: string): string[] {
116
+ if (!existsSync(scriptsDir) || !statSync(scriptsDir).isDirectory()) return [];
117
+ const out: string[] = [];
118
+ const walk = (dir: string) => {
119
+ for (const entry of readdirSync(dir)) {
120
+ if (entry === 'node_modules') continue;
121
+ const p = join(dir, entry);
122
+ if (statSync(p).isDirectory()) walk(p);
123
+ else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p);
124
+ }
125
+ };
126
+ walk(scriptsDir);
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Scan a module directory (the one holding `manifest.yml`). Returns every
132
+ * violation in its `scripts/`, with paths relative to `moduleDir`.
133
+ */
134
+ export function scanModuleDirectory(moduleDir: string): ScanViolation[] {
135
+ return moduleScriptFiles(join(moduleDir, 'scripts')).flatMap((f) =>
136
+ scanModuleScriptSource(relative(moduleDir, f), readFileSync(f, 'utf-8')),
137
+ );
138
+ }
139
+
140
+ /** Render violations for a test failure message or a refused publish. */
141
+ export function formatViolations(violations: ScanViolation[]): string {
142
+ return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n');
143
+ }
@@ -1,24 +1,20 @@
1
1
  /**
2
- * Recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: **modules never hand-build SSH.**
2
+ * Recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: **modules never hand-build SSH,
3
+ * and every raw-exec escape hatch is justified in writing.**
3
4
  *
4
- * Module hooks reach a remote box through the typed primitives in
5
- * `@celilo/capabilities` (probe / serviceCtl / applyRenderedConfig / see
6
- * apps/celilo/MODULE_PRIMITIVES.md), never a raw `ssh root@…` string, `ssh2`, or
7
- * an ad-hoc `child_process` shell-out. This test scans every production module
8
- * script and fails if a banned pattern reappears, so the SSH-elimination work
9
- * can't silently erode.
5
+ * The rules themselves live in `./module-script-scan`, because this is not the
6
+ * only place they run `.netapp` packaging applies the same scan at publish
7
+ * time, which is the enforcement point that also covers a module built outside
8
+ * this repo's CI. One definition, two callers.
10
9
  *
11
- * Precise on purpose: `noRestrictedImports` can't see raw ssh *strings* (the real
12
- * risk). We ban the SSH shapes themselves — an `ssh root@…` string catches
13
- * hand-built SSH however it's invoked (child_process, execSync, or the Runner),
14
- * and `ssh2` catches the library route. We do NOT ban `child_process` outright:
15
- * many modules' on_install legitimately shell LOCAL `celilo`/system commands
16
- * (`celilo system apply-config`), which isn't remote SSH.
10
+ * This file is the in-repo half: every production module script, on every
11
+ * `bun test`.
17
12
  */
18
13
 
19
14
  import { describe, expect, test } from 'bun:test';
20
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { existsSync, readdirSync, statSync } from 'node:fs';
21
16
  import { join, resolve } from 'node:path';
17
+ import { formatViolations, moduleScriptFiles, scanModuleDirectory } from './module-script-scan';
22
18
 
23
19
  /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
24
20
  function repoRoot(): string {
@@ -30,61 +26,25 @@ function repoRoot(): string {
30
26
  throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
31
27
  }
32
28
 
33
- /** Every production `.ts` under modules/<m>/scripts/ (excludes node_modules + tests). */
34
- function moduleScripts(): string[] {
29
+ function moduleDirs(): string[] {
35
30
  const modulesRoot = join(repoRoot(), 'modules');
36
- const out: string[] = [];
37
- const walk = (dir: string) => {
38
- for (const entry of readdirSync(dir)) {
39
- if (entry === 'node_modules') continue;
40
- const p = join(dir, entry);
41
- if (statSync(p).isDirectory()) walk(p);
42
- else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p);
43
- }
44
- };
45
- for (const mod of readdirSync(modulesRoot)) {
46
- const scripts = join(modulesRoot, mod, 'scripts');
47
- if (existsSync(scripts) && statSync(scripts).isDirectory()) walk(scripts);
48
- }
49
- return out;
31
+ return readdirSync(modulesRoot)
32
+ .map((m) => join(modulesRoot, m))
33
+ .filter((d) => statSync(d).isDirectory() && existsSync(join(d, 'scripts')));
50
34
  }
51
35
 
52
- const BANNED = [
53
- {
54
- name: 'raw ssh invocation (StrictHostKeyChecking)',
55
- re: /StrictHostKeyChecking/,
56
- hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.',
57
- },
58
- {
59
- name: 'raw ssh invocation (ssh … root@)',
60
- re: /\bssh\s+(?:-\S+\s+|\S*root@)/,
61
- hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.',
62
- },
63
- {
64
- name: "'ssh2' import",
65
- re: /(?:from|require\()\s*['"]ssh2['"]/,
66
- hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
67
- },
68
- ];
69
-
70
36
  describe('recurrence gate: modules never hand-build SSH', () => {
71
- const scripts = moduleScripts();
37
+ const dirs = moduleDirs();
72
38
 
73
39
  test('scans a non-trivial set of module scripts (sanity — the scan actually ran)', () => {
74
- expect(scripts.length).toBeGreaterThan(10);
40
+ const scanned = dirs.flatMap((d) => moduleScriptFiles(join(d, 'scripts')));
41
+ expect(scanned.length).toBeGreaterThan(10);
75
42
  });
76
43
 
77
- test('no production module script hand-builds SSH (ssh string / ssh2)', () => {
78
- const violations: string[] = [];
79
- for (const file of scripts) {
80
- const src = readFileSync(file, 'utf-8');
81
- for (const b of BANNED) {
82
- if (b.re.test(src)) violations.push(`${file}\n → ${b.name}. ${b.hint}`);
83
- }
84
- }
85
- expect(
86
- violations,
87
- `Hand-built SSH found in module scripts:\n ${violations.join('\n ')}`,
88
- ).toEqual([]);
44
+ test('no production module script hand-builds SSH, and every runAppCommand* is justified', () => {
45
+ const violations = dirs.flatMap((d) => scanModuleDirectory(d));
46
+ expect(violations, `Module script policy violations:\n${formatViolations(violations)}`).toEqual(
47
+ [],
48
+ );
89
49
  });
90
50
  });
@@ -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: [] },
@@ -18,9 +18,14 @@ import { type BackupsAuditDeps, auditBackups } from './backups';
18
18
  import { type BrowserPinAuditDeps, auditBrowserPin } from './browser-pin';
19
19
  import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
20
20
  import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
21
+ import {
22
+ type DetectWithoutConvergeAuditDeps,
23
+ auditDetectWithoutConverge,
24
+ } from './detect-without-converge';
21
25
  import { type HealthAuditDeps, auditHealth } from './health';
22
26
  import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
23
27
  import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
28
+ import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
24
29
  import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
25
30
  import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
26
31
  import { type SchemaAuditDeps, auditSchema } from './schema';
@@ -53,6 +58,8 @@ export interface AuditDeps {
53
58
  terraformPlan: TerraformPlanAuditDeps;
54
59
  moduleVersions: ModuleVersionsAuditDeps;
55
60
  moduleConfigs: ModuleConfigsAuditDeps;
61
+ moduleIntegrity: ModuleIntegrityAuditDeps;
62
+ detectWithoutConverge: DetectWithoutConvergeAuditDeps;
56
63
  health: HealthAuditDeps;
57
64
  backups: BackupsAuditDeps;
58
65
  abandonedOperations: AbandonedOperationsAuditDeps;
@@ -107,6 +114,11 @@ export async function runAudit(
107
114
  wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
108
115
  wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
109
116
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
117
+ wrap('module_integrity', Promise.resolve(auditModuleIntegrity(deps.moduleIntegrity))),
118
+ wrap(
119
+ 'detect_without_converge',
120
+ Promise.resolve(auditDetectWithoutConverge(deps.detectWithoutConverge)),
121
+ ),
110
122
  wrap('health', auditHealth(deps.health)),
111
123
  wrap('backups', auditBackups(deps.backups)),
112
124
  wrap(