@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
@@ -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
+ }
@@ -248,12 +248,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
248
248
  count: 2,
249
249
  why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
250
250
  },
251
- {
252
- file: 'apps/celilo/src/services/public-web-republish.ts',
253
- capability: 'public_web',
254
- count: 1,
255
- why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
256
- },
257
251
  {
258
252
  file: 'apps/celilo/src/services/zone-policy.ts',
259
253
  capability: 'public_web',
@@ -391,11 +385,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
391
385
  capability: 'firewall',
392
386
  why: 'S8 — core reaching into one provider implementation (#941)',
393
387
  },
394
- {
395
- file: 'apps/celilo/src/services/public-web-republish.ts',
396
- capability: 'public_web',
397
- why: "S2 — named for one provider's redeploy behaviour (#945)",
398
- },
399
388
  ];
400
389
 
401
390
  export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
@@ -214,7 +214,59 @@ export function ensureInboundSubscriber(bus: SubscriberRegistrar): void {
214
214
  });
215
215
  }
216
216
 
217
- export function deleteMonitor(db: DbClient, monitorId: string, now: Date): void {
218
- resolveMonitorAlerts(db, monitorId, now);
217
+ /** A live alert about to be deleted along with the monitor that owns it. */
218
+ export interface DroppedAlert {
219
+ key: string;
220
+ message: string;
221
+ }
222
+
223
+ /**
224
+ * Delete a monitor, and report the live alerts that go with it.
225
+ *
226
+ * The alerts are DELETED, not resolved. `alerts.monitorId` is
227
+ * `on delete cascade`, so the row goes the moment the monitor does. This used
228
+ * to call `resolveMonitorAlerts` first; that write was unreachable — the very
229
+ * next statement dropped the same rows, and nothing read them in between, so
230
+ * the resolved state existed for the duration of one statement. Deleted rather
231
+ * than restored, because resolving properly would need the alert rows to
232
+ * outlive their monitor, which the FK forbids and which no reader wants:
233
+ * `resolvedAt` is only ever read as a liveness predicate (`ack.ts`), never
234
+ * reported. Do not put the call back without changing the FK first.
235
+ *
236
+ * What IS worth keeping is what was lost, which is why the live alerts are
237
+ * returned. A monitor dropped while holding a firing alert takes a real
238
+ * failure and the coverage of it away together, and a caller that only names
239
+ * the monitor cannot tell an operator what stopped being watched.
240
+ */
241
+ export function deleteMonitor(db: DbClient, monitorId: string): DroppedAlert[] {
242
+ const dropped = db
243
+ .select({ key: alerts.key, message: alerts.message })
244
+ .from(alerts)
245
+ .where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
246
+ .all();
247
+
219
248
  db.delete(monitors).where(eq(monitors.id, monitorId)).run();
249
+ return dropped;
250
+ }
251
+
252
+ /**
253
+ * Drop the `module_hook` monitor belonging to a module that is going away.
254
+ *
255
+ * Called from the removal path rather than expressed as a foreign key, because
256
+ * `monitors.target` cannot carry one: it holds a module id for `module_hook`
257
+ * and an audit check name for `builtin_check`, and SQLite has no conditional
258
+ * reference. Splitting the table to get the constraint would need a synthetic
259
+ * monitor identity for `alerts.monitorId` — the trade the file header already
260
+ * weighs and declines.
261
+ *
262
+ * Deleting cascades the monitor's alerts away (`alerts.monitorId` is
263
+ * `on delete cascade`), which is what releases anything they were suppressing.
264
+ * They are deleted, not resolved — see `deleteMonitor`. Returns what was live
265
+ * so the caller can say what stopped being watched, or null if the module had
266
+ * no monitor.
267
+ */
268
+ export function deleteMonitorForModule(db: DbClient, moduleId: string): DroppedAlert[] | null {
269
+ const monitor = findMonitor(db, 'module_hook', moduleId);
270
+ if (!monitor) return null;
271
+ return deleteMonitor(db, monitor.id);
220
272
  }
@@ -23,6 +23,7 @@ import type { DbClient } from '../../db/client';
23
23
  import type { Alert, Monitor } from '../../db/schema';
24
24
  import { MONITOR_INTERVAL_FLOOR_MINUTES } from '../cadence';
25
25
  import { isScheduled, loadModuleHealthCadences } from './health-cadence';
26
+ import { type DroppedAlert, deleteMonitor } from './monitors';
26
27
  import type { NotifyDeps, NotifyOutcome } from './notifier';
27
28
  import { deliverDeferred, notifyAlert } from './notifier';
28
29
  import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
@@ -102,6 +103,19 @@ export interface SweepReport {
102
103
  * former, because nothing ever reached the daemon.
103
104
  */
104
105
  failures: string[];
106
+ /**
107
+ * Modules whose `module_hook` monitor was dropped because the module itself
108
+ * is gone, and the live alerts each one took with it.
109
+ *
110
+ * The alerts are DELETED by the monitor's cascade, not resolved, so a firing
111
+ * check and the coverage of it disappear in the same instant with nothing
112
+ * else recording either. A bare module name is not enough to act on: the row
113
+ * that mattered on the fleet was holding `Cannot reach router: Router login
114
+ * failed`, and `1 stranded-dropped (greenwave)` would not have told anyone
115
+ * that a router had stopped being checked. Same reason `noPolicy`, `skipped`
116
+ * and `failures` all name their subject rather than counting it.
117
+ */
118
+ strandedDropped: { moduleId: string; alerts: DroppedAlert[] }[];
105
119
  }
106
120
 
107
121
  /**
@@ -129,6 +143,7 @@ export async function runSweep(
129
143
  noPolicy: [],
130
144
  skipped: [],
131
145
  failures: [],
146
+ strandedDropped: [],
132
147
  };
133
148
 
134
149
  // 1. Run due monitors.
@@ -139,8 +154,30 @@ export async function runSweep(
139
154
  // manifest could never reach an existing install (design.md D2/D8). A
140
155
  // `builtin_check` has no module and no manifest, so its row is the config.
141
156
  const cadences = loadModuleHealthCadences(db);
157
+
158
+ // Reconcile away monitors whose module is gone, before anything tries to run
159
+ // them. `loadModuleHealthCadences` holds every module, so a miss here means
160
+ // no module row — and a `module_hook` monitor without one is unschedulable
161
+ // forever: its cadence resolves to null, `isScheduled` says false, and the
162
+ // sweep never selects it again. Anything it left firing would sit there with
163
+ // no path back to `resolved`, suppressing every alert it is an ancestor of
164
+ // (celilo#1029).
165
+ //
166
+ // The removal path deletes the monitor itself, so this only fires for rows a
167
+ // celilo without that fix left behind, or a removal that died between the two
168
+ // deletes. Same stance `setMonitorEnabled` already takes: a monitor that will
169
+ // never report again must not hold live alerts.
170
+ const stranded = monitors.filter((m) => m.kind === 'module_hook' && !cadences.has(m.target));
171
+ for (const monitor of stranded) {
172
+ report.strandedDropped.push({
173
+ moduleId: monitor.target,
174
+ alerts: deleteMonitor(db, monitor.id),
175
+ });
176
+ }
177
+ const active = stranded.length > 0 ? monitors.filter((m) => !stranded.includes(m)) : monitors;
178
+
142
179
  const due = selectDueMonitors(
143
- monitors.map((m) => {
180
+ active.map((m) => {
144
181
  if (m.kind !== 'module_hook') {
145
182
  return {
146
183
  id: m.id,
@@ -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(