@celilo/cli 2.2.1 → 3.0.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -15,7 +15,7 @@ import { createHookStores } from '../hooks/hook-store';
15
15
  import { loadHookConfigMap } from '../hooks/load-hook-config';
16
16
  import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
17
17
  import type { HookLogger, HookResult } from '../hooks/types';
18
- import type { ModuleManifest } from '../manifest/schema';
18
+ import type { HealthWaiver, ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
21
  import { controlPlaneHealthChecks } from './control-plane-health';
@@ -24,7 +24,14 @@ import { remoteAccessPolicy } from './remote-access';
24
24
 
25
25
  export interface HealthCheckItem {
26
26
  name: string;
27
- status: 'pass' | 'warn' | 'fail';
27
+ /**
28
+ * `skip` — the check could not perform its real probe (a credential is
29
+ * not provisioned, a peer is unreachable from the control plane) and
30
+ * refuses to guess. It is not a pass: a module whose checks all report
31
+ * skip is unmeasured, and `system audit` renders the fleet UNKNOWN, not
32
+ * READY. Never claim `pass` for a probe you did not run (celilo#1326).
33
+ */
34
+ status: 'pass' | 'warn' | 'fail' | 'skip';
28
35
  message: string;
29
36
  details?: string;
30
37
  }
@@ -44,12 +51,26 @@ export interface HealthCheckResult {
44
51
  * watching to re-run the check by hand.
45
52
  */
46
53
  artifactPaths?: string[];
54
+ /**
55
+ * The module's recorded health waiver, if its manifest declares one
56
+ * (openspec/changes/health-waiver-mechanism). It does not change this
57
+ * result's severity — an unmeasured module stays unmeasured — it
58
+ * annotates the finding with who decided and why.
59
+ */
60
+ waiver?: HealthWaiver;
47
61
  }
48
62
 
49
63
  export interface HealthCheckOptions {
50
64
  debug?: boolean;
51
65
  noInteractive?: boolean;
52
66
  onProgress?: (msg: string) => void;
67
+ /**
68
+ * Presentation-only silence: no gauge and no plain stdout lines, hook
69
+ * output captured instead. Unlike `unattended` this does NOT touch the
70
+ * state-transition guard — a `--json` command still runs real checks
71
+ * and still moves lifecycle state (celilo#1362).
72
+ */
73
+ quiet?: boolean;
53
74
  /**
54
75
  * Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
55
76
  * that matters beyond cosmetics — the module's lifecycle state is NOT
@@ -98,6 +119,15 @@ function createHealthCheckPresentation(
98
119
  return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
99
120
  }
100
121
 
122
+ // Non-interactive stdout (redirected, piped, or an explicit --json
123
+ // caller): stdout is a data channel here (celilo#699), so neither the
124
+ // gauge's frames nor the gauge logger's plain lines may reach it.
125
+ // Capture everything instead (celilo#1362). State transitions still
126
+ // happen — only the presentation goes quiet.
127
+ if (options.quiet || !process.stdout.isTTY) {
128
+ return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
129
+ }
130
+
101
131
  const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
102
132
  gauge.start();
103
133
  return {
@@ -171,7 +201,7 @@ export async function runModuleHealthCheck(
171
201
  const hookDef = manifest.hooks?.health_check;
172
202
 
173
203
  if (!hookDef) {
174
- return { moduleId, status: 'no-checks', checks: [] };
204
+ return { moduleId, status: 'no-checks', checks: [], waiver: manifest.health_waiver };
175
205
  }
176
206
 
177
207
  // Build config + secrets for hook context. The config map shape
@@ -238,7 +268,31 @@ export async function runModuleHealthCheck(
238
268
  };
239
269
 
240
270
  const checks = outputs.checks || [];
241
- return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
271
+ return finishHealthCheck(
272
+ moduleId,
273
+ module.state,
274
+ checks,
275
+ db,
276
+ options,
277
+ hookResult.artifactPaths,
278
+ manifest.health_waiver,
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Derive the module-level verdict from the named checks.
284
+ *
285
+ * Pure so the folding rule is testable without a database or a hook
286
+ * subprocess. `skip` checks are folded toward nothing measured: they are
287
+ * neither pass nor fail, and a module whose checks all skipped is
288
+ * `no-checks` — nothing ran that deserves the word healthy.
289
+ */
290
+ export function deriveHealthStatus(checks: HealthCheckItem[]): HealthCheckResult['status'] {
291
+ if (checks.some((c) => c.status === 'fail')) return 'unhealthy';
292
+ if (checks.some((c) => c.status === 'warn')) return 'degraded';
293
+ const measured = checks.filter((c) => c.status !== 'skip');
294
+ if (checks.length > 0 && measured.length === 0) return 'no-checks';
295
+ return 'healthy';
242
296
  }
243
297
 
244
298
  /**
@@ -254,22 +308,35 @@ function finishHealthCheck(
254
308
  db: DbClient,
255
309
  options: HealthCheckOptions,
256
310
  artifactPaths?: string[],
311
+ waiver?: HealthWaiver,
257
312
  ): HealthCheckResult {
258
- const hasFail = checks.some((c) => c.status === 'fail');
259
- const hasWarn = checks.some((c) => c.status === 'warn');
260
-
261
- const status: HealthCheckResult['status'] = hasFail
262
- ? 'unhealthy'
263
- : hasWarn
264
- ? 'degraded'
265
- : 'healthy';
313
+ const status = deriveHealthStatus(checks);
266
314
 
267
- const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
315
+ // Only a run that measured something may move lifecycle state. A module
316
+ // whose checks all skipped (or that declared no hook) stays as it was —
317
+ // `VERIFIED` asserts that a check was deliberately run and passed, and
318
+ // nothing was measured here.
319
+ const nextState =
320
+ status === 'healthy' || status === 'degraded' || status === 'unhealthy'
321
+ ? nextModuleState(moduleState, status, options.unattended ?? false)
322
+ : null;
268
323
  if (nextState) {
269
- db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
324
+ // Moving to VERIFIED/INSTALLED means the module just measured healthy (or
325
+ // was freshly deployed). Clear a recorded failure with the state, so a
326
+ // healthy row doesn't keep printing its old `Error:` line (celilo#1363).
327
+ db.update(modules)
328
+ .set({ state: nextState, errorMessage: null })
329
+ .where(eq(modules.id, moduleId))
330
+ .run();
270
331
  }
271
332
 
272
- return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
333
+ return {
334
+ moduleId,
335
+ status,
336
+ checks,
337
+ ...(artifactPaths ? { artifactPaths } : {}),
338
+ ...(waiver ? { waiver } : {}),
339
+ };
273
340
  }
274
341
 
275
342
  /**
@@ -314,7 +381,7 @@ async function mapConcurrent<T, R>(
314
381
  */
315
382
  export async function runAllHealthChecks(
316
383
  db: DbClient,
317
- options: { debug?: boolean; onProgress?: (msg: string) => void } = {},
384
+ options: { debug?: boolean; onProgress?: (msg: string) => void; quiet?: boolean } = {},
318
385
  ): Promise<HealthCheckResult[]> {
319
386
  const eligible = db
320
387
  .select()
@@ -48,7 +48,7 @@ describe('infrastructure-selector', () => {
48
48
 
49
49
  // Clear environment variables
50
50
  resetTestDbPath();
51
- process.env.CELILO_MASTER_KEY_PATH = undefined;
51
+ delete process.env.CELILO_MASTER_KEY_PATH;
52
52
  });
53
53
 
54
54
  describe('selectInfrastructure', () => {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tests for the shared jail-exemption collector (per-module-jail-policy
3
+ * task 3.1-3.3).
4
+ *
5
+ * Every reporting surface — `module list`, `system doctor`, `system audit` —
6
+ * builds its view from this one function, so these tests are what stops the
7
+ * three from disagreeing about what an exemption is. Isolation mirrors
8
+ * module-jail.test.ts: CELILO_DB_PATH / CELILO_DATA_DIR are set before the
9
+ * SUT is imported.
10
+ */
11
+
12
+ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { eq } from 'drizzle-orm';
17
+
18
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jail-exemptions-'));
19
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
20
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
21
+
22
+ const { getDb, closeDb } = await import('../db/client');
23
+ const { moduleJailPolicies, modules, systemConfig } = await import('../db/schema');
24
+ const { collectJailExemptions } = await import('./jail-exemptions');
25
+
26
+ function insertModule(id: string): void {
27
+ getDb()
28
+ .insert(modules)
29
+ .values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', manifestData: {} })
30
+ .run();
31
+ }
32
+
33
+ function setModulePolicy(moduleId: string, policy: string): void {
34
+ const typed = policy as 'auto' | 'off' | 'required';
35
+ getDb()
36
+ .insert(moduleJailPolicies)
37
+ .values({ moduleId, policy: typed, updatedAt: new Date() })
38
+ .onConflictDoUpdate({
39
+ target: moduleJailPolicies.moduleId,
40
+ set: { policy: typed, updatedAt: new Date() },
41
+ })
42
+ .run();
43
+ }
44
+
45
+ function setSystemPolicy(value: string | undefined): void {
46
+ const db = getDb();
47
+ if (value === undefined) {
48
+ db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
49
+ } else {
50
+ db.insert(systemConfig)
51
+ .values({ key: 'hooks.jail_policy', value })
52
+ .onConflictDoUpdate({ target: systemConfig.key, set: { value } })
53
+ .run();
54
+ }
55
+ }
56
+
57
+ afterAll(() => {
58
+ closeDb();
59
+ rmSync(testRoot, { recursive: true, force: true });
60
+ });
61
+
62
+ describe('collectJailExemptions (per-module-jail-policy task 3.4)', () => {
63
+ beforeEach(() => {
64
+ const db = getDb();
65
+ db.delete(moduleJailPolicies).run();
66
+ db.delete(systemConfig).where(eq(systemConfig.key, 'hooks.jail_policy')).run();
67
+ db.delete(modules).run();
68
+ insertModule('strictmod');
69
+ insertModule('exemptmod');
70
+ });
71
+
72
+ test('no per-module rows produce no exemptions', () => {
73
+ expect(collectJailExemptions(getDb())).toEqual([]);
74
+ });
75
+
76
+ test("a module policy stronger than the system's is not an exemption", () => {
77
+ // The fleet default is `off`, so `auto` STRENGTHENS the posture. Reporting
78
+ // it as an exemption would be a surface reporting an exemption nobody set.
79
+ setModulePolicy('strictmod', 'required');
80
+ expect(collectJailExemptions(getDb())).toEqual([]);
81
+ });
82
+
83
+ test("a module policy equal to the system's is not an exemption", () => {
84
+ setSystemPolicy('auto');
85
+ setModulePolicy('strictmod', 'auto');
86
+ expect(collectJailExemptions(getDb())).toEqual([]);
87
+ });
88
+
89
+ test("a module policy weaker than the system's is reported, with both values", () => {
90
+ setSystemPolicy('auto');
91
+ setModulePolicy('exemptmod', 'off');
92
+ expect(collectJailExemptions(getDb())).toEqual([
93
+ { moduleId: 'exemptmod', policy: 'off', systemPolicy: 'auto' },
94
+ ]);
95
+ });
96
+
97
+ test('a weaker exemption against the unset-system default (off) is impossible', () => {
98
+ // off is the floor; nothing can be weaker than it. This is the negative
99
+ // half of task 3.4: with nobody having set the system key, no module row
100
+ // — whatever it holds — may read as an exemption.
101
+ setModulePolicy('exemptmod', 'off');
102
+ expect(collectJailExemptions(getDb())).toEqual([]);
103
+ });
104
+
105
+ test('two exemptions are both reported', () => {
106
+ insertModule('exemptmod2');
107
+ setSystemPolicy('required');
108
+ setModulePolicy('exemptmod', 'auto');
109
+ setModulePolicy('exemptmod2', 'off');
110
+ const exemptions = collectJailExemptions(getDb());
111
+ expect(exemptions).toHaveLength(2);
112
+ expect(exemptions.map((e) => e.moduleId).sort()).toEqual(['exemptmod', 'exemptmod2']);
113
+ });
114
+
115
+ test('a stored module policy outside the accepted set throws, not coerces', () => {
116
+ setModulePolicy('exemptmod', 'sometimes');
117
+ expect(() => collectJailExemptions(getDb())).toThrow(/not a hook jail policy/);
118
+ });
119
+
120
+ test('a stored system policy outside the accepted set throws, naming the fix', () => {
121
+ setSystemPolicy('jail-harder');
122
+ setModulePolicy('exemptmod', 'off');
123
+ expect(() => collectJailExemptions(getDb())).toThrow(/hooks\.jail_policy='jail-harder'/);
124
+ });
125
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The per-module jail exemptions, as one query every reporting surface shares
3
+ * (per-module-jail-policy task 3.1-3.3).
4
+ *
5
+ * An exemption is a recorded module policy weaker than the system's
6
+ * `hooks.jail_policy`. The three surfaces — `module list`'s marker,
7
+ * `system doctor`'s count, `system audit`'s category — MUST agree on what
8
+ * that is, so the comparison lives in `isWeakerJailPolicy` and this file only
9
+ * fetches rows and applies it. A surface that re-derived the definition here
10
+ * would eventually report a different set from the other two, and a countable
11
+ * exemption that two surfaces miss is the failure this slice exists to close.
12
+ *
13
+ * Read-time refusal follows the rule the resolvers already follow: a stored
14
+ * value outside the accepted set THROWS (peba's ruling on ce-8832). The set
15
+ * path validates, so a bad row can only arrive via a restore or a hand edit —
16
+ * the exact foreign state the rule refuses to coerce into a silent default.
17
+ */
18
+
19
+ import { eq } from 'drizzle-orm';
20
+ import type { getDb as getDbType } from '../db/client';
21
+ import { moduleJailPolicies, systemConfig } from '../db/schema';
22
+ import { type JailPolicy, isWeakerJailPolicy, resolveJailPolicy } from '../hooks/jail';
23
+
24
+ const JAIL_SYSTEM_KEY = 'hooks.jail_policy';
25
+
26
+ export interface JailExemption {
27
+ moduleId: string;
28
+ /** The module's own recorded policy — the weaker of the two. */
29
+ policy: JailPolicy;
30
+ /** The system policy it is weaker than: the stored key or the default. */
31
+ systemPolicy: JailPolicy;
32
+ }
33
+
34
+ type Db = ReturnType<typeof getDbType>;
35
+
36
+ /**
37
+ * The system-wide policy every module without its own row follows.
38
+ *
39
+ * Throws on a stored value outside the accepted set, matching the read-time
40
+ * refusal rule; the error names the fix, and the same throw will be failing
41
+ * every hook invocation too (the executor resolves through the same key).
42
+ */
43
+ function systemJailPolicy(db: Db): JailPolicy {
44
+ const stored = db
45
+ .select()
46
+ .from(systemConfig)
47
+ .where(eq(systemConfig.key, JAIL_SYSTEM_KEY))
48
+ .get()?.value;
49
+ if (stored === undefined) return 'off';
50
+ try {
51
+ return resolveJailPolicy(undefined, undefined, stored).policy;
52
+ } catch {
53
+ throw new Error(
54
+ `Stored ${JAIL_SYSTEM_KEY}='${stored}' is not a hook jail policy. Fix it with 'celilo system config set ${JAIL_SYSTEM_KEY} <auto|off|required>'.`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Every module running under a recorded policy weaker than the system's.
61
+ *
62
+ * A module row stronger than or equal to the system's is NOT an exemption —
63
+ * the operator strengthened that module's posture, and reporting it as an
64
+ * exemption would be a phantom finding (per-module-jail-policy task 3.4).
65
+ * A bad module row throws rather than being skipped or coerced.
66
+ */
67
+ export function collectJailExemptions(db: Db): JailExemption[] {
68
+ const system = systemJailPolicy(db);
69
+ const rows = db.select().from(moduleJailPolicies).all();
70
+
71
+ const exemptions: JailExemption[] = [];
72
+ for (const row of rows) {
73
+ // Read-time validation through the same resolver the executor uses, so a
74
+ // hand-edited row fails with the same message here that a hook would get.
75
+ const policy = resolveJailPolicy(undefined, row.policy, undefined).policy;
76
+ if (isWeakerJailPolicy(policy, system)) {
77
+ exemptions.push({ moduleId: row.moduleId, policy, systemPolicy: system });
78
+ }
79
+ }
80
+ return exemptions;
81
+ }
@@ -54,7 +54,7 @@ describe('machine-pool', () => {
54
54
 
55
55
  // Clear environment variables
56
56
  resetTestDbPath();
57
- process.env.CELILO_MASTER_KEY_PATH = undefined;
57
+ delete process.env.CELILO_MASTER_KEY_PATH;
58
58
  });
59
59
 
60
60
  describe('addMachine', () => {
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isPreservedGeneratedPath, pruneGeneratedProject } from './module-deploy';
5
+
6
+ const ROOT = '/tmp/celilo-prune-test';
7
+ const GENERATED = join(ROOT, 'generated');
8
+
9
+ /**
10
+ * Mirrors the layout observed on celilo-mgr at 2026-09-16T00:51Z:
11
+ * `generated/{ansible,deploy.log,terraform}` with state inside terraform/.
12
+ * Derived from the real tree rather than invented — a hand-written mirror gives
13
+ * confident results about a world that does not exist.
14
+ */
15
+ function buildGeneratedTree(): void {
16
+ mkdirSync(join(GENERATED, 'ansible', 'roles'), { recursive: true });
17
+ mkdirSync(join(GENERATED, 'terraform', '.terraform', 'providers'), { recursive: true });
18
+
19
+ writeFileSync(join(GENERATED, 'deploy.log'), 'deploy output');
20
+ writeFileSync(join(GENERATED, 'ansible', 'secrets.yml'), 'vault-encrypted secrets');
21
+ writeFileSync(join(GENERATED, 'ansible', 'roles', 'task.yml'), 'rendered task');
22
+ writeFileSync(join(GENERATED, 'terraform', 'main.tf'), 'rendered terraform');
23
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfvars'), 'rendered vars');
24
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), '{"lxc":207}');
25
+ writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'), '{"lxc":207,"old":1}');
26
+ writeFileSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'), 'provider lock');
27
+ writeFileSync(join(GENERATED, 'terraform', '.terraform', 'providers', 'bin'), 'huge binary');
28
+ }
29
+
30
+ beforeEach(() => {
31
+ rmSync(ROOT, { recursive: true, force: true });
32
+ buildGeneratedTree();
33
+ });
34
+
35
+ afterEach(() => {
36
+ rmSync(ROOT, { recursive: true, force: true });
37
+ });
38
+
39
+ describe('pruneGeneratedProject', () => {
40
+ // The recurrence gate for celilo#1374's root cause. Deleting this file is
41
+ // what armed every container module for destruction on its next deploy.
42
+ test('keeps terraform state and its lock', () => {
43
+ pruneGeneratedProject(GENERATED);
44
+
45
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate'))).toBe(true);
46
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'))).toBe(true);
47
+ expect(existsSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'))).toBe(true);
48
+ // Intact, not merely present.
49
+ expect(readFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), 'utf-8')).toBe(
50
+ '{"lxc":207}',
51
+ );
52
+ });
53
+
54
+ // D4's actual concern. If this ever passes, the fix has quietly become
55
+ // "persist the rendered project", which D4 refused for good reasons.
56
+ test('still deletes every rendered template and secret', () => {
57
+ pruneGeneratedProject(GENERATED);
58
+
59
+ expect(existsSync(join(GENERATED, 'ansible'))).toBe(false);
60
+ expect(existsSync(join(GENERATED, 'deploy.log'))).toBe(false);
61
+ expect(existsSync(join(GENERATED, 'terraform', 'main.tf'))).toBe(false);
62
+ expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfvars'))).toBe(false);
63
+ });
64
+
65
+ // ~19 MB per provider, rebuilt by `terraform init`; excluded from the backup
66
+ // envelope for the same reason (cross-module-read.ts).
67
+ test('drops the provider cache', () => {
68
+ pruneGeneratedProject(GENERATED);
69
+
70
+ expect(existsSync(join(GENERATED, 'terraform', '.terraform'))).toBe(false);
71
+ });
72
+
73
+ // A deploy that failed before rendering never made one.
74
+ test('is a no-op when generated/ is absent', () => {
75
+ rmSync(GENERATED, { recursive: true, force: true });
76
+
77
+ expect(() => pruneGeneratedProject(GENERATED)).not.toThrow();
78
+ });
79
+ });
80
+
81
+ describe('isPreservedGeneratedPath', () => {
82
+ test('preserves only state files directly under terraform/', () => {
83
+ expect(isPreservedGeneratedPath('terraform/terraform.tfstate')).toBe(true);
84
+ expect(isPreservedGeneratedPath('terraform/main.tf')).toBe(false);
85
+ expect(isPreservedGeneratedPath('ansible/terraform.tfstate')).toBe(false);
86
+ // A nested lookalike must not smuggle a whole tree through.
87
+ expect(isPreservedGeneratedPath('terraform/nested/terraform.tfstate')).toBe(false);
88
+ });
89
+ });