@celilo/cli 2.2.0 → 2.3.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 (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  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/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -1,5 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { nextModuleState } from './health-runner';
1
+ import { describe, expect, spyOn, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { eq } from 'drizzle-orm';
6
+ import { type DbClient, getDb } from '../db/client';
7
+ import { modules } from '../db/schema';
8
+ import { resetTestDbPath } from '../test-utils/db-path';
9
+ import * as controlPlaneHealth from './control-plane-health';
10
+ import type { HealthCheckItem } from './health-runner';
11
+ import { deriveHealthStatus, nextModuleState, runModuleHealthCheck } from './health-runner';
3
12
 
4
13
  /**
5
14
  * The D15 rule: a scheduled (monitor-driven) health check observes only and
@@ -9,6 +18,34 @@ import { nextModuleState } from './health-runner';
9
18
  * throw, it just makes `celilo module list` flicker between INSTALLED and
10
19
  * VERIFIED every monitor interval.
11
20
  */
21
+ describe('deriveHealthStatus', () => {
22
+ const item = (status: HealthCheckItem['status'], name = 'probe'): HealthCheckItem => ({
23
+ name,
24
+ status,
25
+ message: 'msg',
26
+ });
27
+
28
+ test('fail dominates everything', () => {
29
+ expect(deriveHealthStatus([item('pass'), item('fail'), item('warn')])).toBe('unhealthy');
30
+ });
31
+
32
+ test('warn without fail is degraded', () => {
33
+ expect(deriveHealthStatus([item('pass'), item('warn')])).toBe('degraded');
34
+ });
35
+
36
+ test('measured passes with a skip are healthy — the audit carries the skip', () => {
37
+ expect(deriveHealthStatus([item('pass'), item('skip')])).toBe('healthy');
38
+ });
39
+
40
+ test('only skips is no-checks — nothing was measured', () => {
41
+ expect(deriveHealthStatus([item('skip'), item('skip')])).toBe('no-checks');
42
+ });
43
+
44
+ test('empty checks is healthy (the hook ran and reported nothing)', () => {
45
+ expect(deriveHealthStatus([])).toBe('healthy');
46
+ });
47
+ });
48
+
12
49
  describe('nextModuleState', () => {
13
50
  describe('operator-invoked run (unattended = false)', () => {
14
51
  test('passing check verifies an INSTALLED module', () => {
@@ -48,3 +85,51 @@ describe('nextModuleState', () => {
48
85
  });
49
86
  });
50
87
  });
88
+
89
+ // celilo#1363: a failed upgrade leaves state ERROR with an errorMessage. The
90
+ // recovery path is an attended health check passing, which moves ERROR →
91
+ // VERIFIED. The message must move with the state, or `module list` keeps
92
+ // printing the old `Error:` line beside a healthy row.
93
+ describe('runModuleHealthCheck — clearing a recorded failure (celilo#1363)', () => {
94
+ test('an attended healthy check clears errorMessage along with the state', async () => {
95
+ const tempDir = mkdtempSync(join(tmpdir(), 'celilo-health-'));
96
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
97
+
98
+ try {
99
+ const db: DbClient = getDb();
100
+ db.insert(modules)
101
+ .values({
102
+ id: 'celilo-mgmt',
103
+ name: 'Control plane',
104
+ sourcePath: join(tempDir, 'celilo-mgmt'),
105
+ version: '1.0.0+5',
106
+ state: 'ERROR',
107
+ errorMessage: 'Upgrade to 1.0.0+6 failed to deploy: ssh: connection refused',
108
+ manifestData: {
109
+ celilo_contract: '1.0',
110
+ id: 'celilo-mgmt',
111
+ name: 'Control plane',
112
+ version: '1.0.0',
113
+ },
114
+ })
115
+ .run();
116
+
117
+ const checks: HealthCheckItem[] = [{ name: 'probe', status: 'pass', message: 'ok' }];
118
+ const spy = spyOn(controlPlaneHealth, 'controlPlaneHealthChecks').mockResolvedValue(checks);
119
+ try {
120
+ const result = await runModuleHealthCheck('celilo-mgmt', db, {});
121
+ expect(result.status).toBe('healthy');
122
+ } finally {
123
+ spy.mockRestore();
124
+ }
125
+
126
+ const row = db.select().from(modules).where(eq(modules.id, 'celilo-mgmt')).get();
127
+ if (!row) throw new Error('row missing after health check');
128
+ expect(row.state).toBe('VERIFIED');
129
+ expect(row.errorMessage).toBeNull();
130
+ } finally {
131
+ rmSync(tempDir, { recursive: true, force: true });
132
+ resetTestDbPath();
133
+ }
134
+ });
135
+ });
@@ -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
  }
@@ -241,6 +248,22 @@ export async function runModuleHealthCheck(
241
248
  return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
242
249
  }
243
250
 
251
+ /**
252
+ * Derive the module-level verdict from the named checks.
253
+ *
254
+ * Pure so the folding rule is testable without a database or a hook
255
+ * subprocess. `skip` checks are folded toward nothing measured: they are
256
+ * neither pass nor fail, and a module whose checks all skipped is
257
+ * `no-checks` — nothing ran that deserves the word healthy.
258
+ */
259
+ export function deriveHealthStatus(checks: HealthCheckItem[]): HealthCheckResult['status'] {
260
+ if (checks.some((c) => c.status === 'fail')) return 'unhealthy';
261
+ if (checks.some((c) => c.status === 'warn')) return 'degraded';
262
+ const measured = checks.filter((c) => c.status !== 'skip');
263
+ if (checks.length > 0 && measured.length === 0) return 'no-checks';
264
+ return 'healthy';
265
+ }
266
+
244
267
  /**
245
268
  * Derive the verdict from the named checks and move the module's state.
246
269
  *
@@ -255,18 +278,24 @@ function finishHealthCheck(
255
278
  options: HealthCheckOptions,
256
279
  artifactPaths?: string[],
257
280
  ): 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';
281
+ const status = deriveHealthStatus(checks);
266
282
 
267
- const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
283
+ // Only a run that measured something may move lifecycle state. A module
284
+ // whose checks all skipped (or that declared no hook) stays as it was —
285
+ // `VERIFIED` asserts that a check was deliberately run and passed, and
286
+ // nothing was measured here.
287
+ const nextState =
288
+ status === 'healthy' || status === 'degraded' || status === 'unhealthy'
289
+ ? nextModuleState(moduleState, status, options.unattended ?? false)
290
+ : null;
268
291
  if (nextState) {
269
- db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
292
+ // Moving to VERIFIED/INSTALLED means the module just measured healthy (or
293
+ // was freshly deployed). Clear a recorded failure with the state, so a
294
+ // healthy row doesn't keep printing its old `Error:` line (celilo#1363).
295
+ db.update(modules)
296
+ .set({ state: nextState, errorMessage: null })
297
+ .where(eq(modules.id, moduleId))
298
+ .run();
270
299
  }
271
300
 
272
301
  return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
@@ -10,6 +10,7 @@ import { closeDb, createDbClient } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { modules } from '../db/schema';
12
12
  import type { Module } from '../db/schema';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import { addContainerService } from './container-service';
14
15
  import { InfrastructureError, selectInfrastructure } from './infrastructure-selector';
15
16
  import { addMachine } from './machine-pool';
@@ -46,8 +47,8 @@ describe('infrastructure-selector', () => {
46
47
  }
47
48
 
48
49
  // Clear environment variables
49
- process.env.CELILO_DB_PATH = undefined;
50
- process.env.CELILO_MASTER_KEY_PATH = undefined;
50
+ resetTestDbPath();
51
+ delete process.env.CELILO_MASTER_KEY_PATH;
51
52
  });
52
53
 
53
54
  describe('selectInfrastructure', () => {
@@ -11,6 +11,7 @@ import {
11
11
  } from '@/db/schema';
12
12
  import type { ModuleManifest } from '@/manifest/schema';
13
13
  import { and, eq } from 'drizzle-orm';
14
+ import { resetTestDbPath } from '../test-utils/db-path';
14
15
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
15
16
  import { upsertModuleConfig } from './module-config';
16
17
 
@@ -38,7 +39,7 @@ beforeEach(() => {
38
39
 
39
40
  afterEach(async () => {
40
41
  db.$client.close();
41
- process.env.CELILO_DB_PATH = undefined;
42
+ resetTestDbPath();
42
43
 
43
44
  // Clean up database files
44
45
  if (existsSync(TEST_DB_PATH)) {
@@ -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
+ }
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
10
10
  import { closeDb, createDbClient, getDb } from '../db/client';
11
11
  import { runMigrations } from '../db/migrate';
12
12
  import { machines, moduleInfrastructure, modules } from '../db/schema';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import {
14
15
  addMachine,
15
16
  getMachine,
@@ -52,8 +53,8 @@ describe('machine-pool', () => {
52
53
  }
53
54
 
54
55
  // Clear environment variables
55
- process.env.CELILO_DB_PATH = undefined;
56
- process.env.CELILO_MASTER_KEY_PATH = undefined;
56
+ resetTestDbPath();
57
+ delete process.env.CELILO_MASTER_KEY_PATH;
57
58
  });
58
59
 
59
60
  describe('addMachine', () => {
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import { closeDb, createDbClient } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { modules } from '../db/schema';
12
+ import { resetTestDbPath } from '../test-utils/db-path';
12
13
  import {
13
14
  formatConfigValue,
14
15
  getAllModuleConfigValues,
@@ -59,7 +60,7 @@ describe('module-config service', () => {
59
60
  afterEach(() => {
60
61
  closeDb();
61
62
  // Clean up environment variable
62
- process.env.CELILO_DB_PATH = undefined;
63
+ resetTestDbPath();
63
64
  // Clean up test database
64
65
  rmSync(testDir, { recursive: true, force: true });
65
66
  });
@@ -9,6 +9,7 @@ import { eq } from 'drizzle-orm';
9
9
  import { type DbClient, closeDb, getDb } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
11
  import { capabilities, moduleSystems, modules, systemConfig } from '../db/schema';
12
+ import { resetTestDbPath } from '../test-utils/db-path';
12
13
  import { InterviewUnansweredError } from './interview-errors';
13
14
  import { planDnsRepoint, repointDnsPrimaryForProvider } from './module-deploy';
14
15
 
@@ -49,7 +50,7 @@ describe('repointDnsPrimaryForProvider (module-orchestrator-primitives slice 3)'
49
50
 
50
51
  afterEach(() => {
51
52
  closeDb();
52
- process.env.CELILO_DB_PATH = undefined;
53
+ resetTestDbPath();
53
54
  try {
54
55
  rmSync(dir, { recursive: true, force: true });
55
56
  } catch {
@@ -4,6 +4,7 @@
4
4
  * Coordinates full module deployment workflow across all phases
5
5
  */
6
6
 
7
+ import { rmSync } from 'node:fs';
7
8
  import { join } from 'node:path';
8
9
  import { ProgressDisplay } from '@celilo/cli-display';
9
10
  import { and, eq } from 'drizzle-orm';
@@ -104,6 +105,17 @@ export interface DeployOptions {
104
105
  * this mode — that requires a real hook run.
105
106
  */
106
107
  stopAfterInterview?: boolean;
108
+ /**
109
+ * D4 (control-plane-stops-building-modules): retain the generated project
110
+ * after a successful deploy instead of deleting it. The tree is ephemeral
111
+ * by design — rendered for this deploy from the signed payload plus live
112
+ * config, used, then removed — because a persisted generated tree cannot be
113
+ * validated (only its verbatim role assets have a meaningful digest, the
114
+ * rest is rendered and SUPPOSED to differ), it holds rendered secrets, and
115
+ * it is what the next deploy would ship. This flag keeps it for deliberate
116
+ * inspection. It is always kept when the deploy fails.
117
+ */
118
+ keepGeneratedProject?: boolean;
107
119
  }
108
120
 
109
121
  /**
@@ -368,6 +380,26 @@ export async function deployModule(
368
380
  closeDeployWindows(db, moduleId, new Date());
369
381
 
370
382
  if (result.success) {
383
+ // D4 (control-plane-stops-building-modules): the generated project is
384
+ // ephemeral. It was rendered for THIS deploy from the signed payload plus
385
+ // live config; a persisted copy is a tree nothing can validate, it holds
386
+ // rendered secrets, and it is what a later deploy would ship. Now that
387
+ // the deploy that needed it is done — ansible, hooks, DNS backfill and
388
+ // aspects all ran inside the impl — it goes. Kept on failure (the impl's
389
+ // early returns) and under --keep.
390
+ if (!options.keepGeneratedProject) {
391
+ const deployed = db.select().from(modules).where(eq(modules.id, moduleId)).get();
392
+ if (deployed) {
393
+ const generatedPath = `${deployed.sourcePath}/generated`;
394
+ try {
395
+ rmSync(generatedPath, { recursive: true, force: true });
396
+ log.info(`Removed generated/ for '${moduleId}' (ephemeral by design; --keep retains it)`);
397
+ } catch (cleanupError) {
398
+ const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
399
+ log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
400
+ }
401
+ }
402
+ }
371
403
  // A module that declares a suggested interval starts being watched on its
372
404
  // first successful deploy — watching is the default, not a thing to
373
405
  // remember.
@@ -792,26 +824,10 @@ async function deployModuleImpl(
792
824
 
793
825
  log.success('Templates generated');
794
826
 
795
- // Pre-flight: refuse before contacting any system if the generated project
796
- // does not match the module tree it was generated from (D6).
797
- //
798
- // This is the check that would have turned celilo#925 from a silent success
799
- // into a refusal at the point of the fault. A verbatim role asset that
800
- // failed to refresh is invisible downstream: `cp` reports no error, Ansible
801
- // copies the stale bytes and reports `ok`, unchanged, and the module's
802
- // version advances past code that never shipped. Local, milliseconds, no
803
- // SSH. It deliberately does NOT verify after the deploy — Ansible's
804
- // `changed` is truthful about what Ansible did, and the lie is upstream of
805
- // Ansible.
806
- const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
807
- const staleGenerated = await refuseIfGeneratedIsStale(
808
- moduleId,
809
- module.sourcePath,
810
- generatedPath,
811
- );
812
- if (staleGenerated) {
813
- return { success: false, error: staleGenerated, phases };
814
- }
827
+ // The generated-tree staleness pre-flight (D6 of module-integrity-rigor)
828
+ // retired here (D5 of control-plane-stops-building-modules): the tree is
829
+ // rendered for THIS deploy and deleted when it succeeds, so nothing
830
+ // persists that could be stale relative to what ships.
815
831
 
816
832
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
817
833
  if (manifest.hooks?.validate_config) {
@@ -1032,8 +1048,13 @@ async function deployModuleImpl(
1032
1048
  }
1033
1049
  }
1034
1050
 
1035
- // Transition to INSTALLED
1036
- db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
1051
+ // Transition to INSTALLED. A successful deploy ends any recorded
1052
+ // failure: leaving a stale errorMessage would print `Error:` beside a
1053
+ // healthy state in `module list` / `module status` (celilo#1363).
1054
+ db.update(modules)
1055
+ .set({ state: 'INSTALLED', errorMessage: null })
1056
+ .where(eq(modules.id, moduleId))
1057
+ .run();
1037
1058
 
1038
1059
  // Run health checks for config-only modules too
1039
1060
  if (manifest.hooks?.health_check) {
@@ -1700,8 +1721,11 @@ async function deployModuleImpl(
1700
1721
  }
1701
1722
  }
1702
1723
 
1703
- // Transition to INSTALLED
1704
- db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
1724
+ // Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
1725
+ db.update(modules)
1726
+ .set({ state: 'INSTALLED', errorMessage: null })
1727
+ .where(eq(modules.id, moduleId))
1728
+ .run();
1705
1729
 
1706
1730
  // Run health checks after successful deployment
1707
1731
  if (manifest.hooks?.health_check) {
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb } from '../db/client';
7
7
  import { runMigrations } from '../db/migrate';
8
+ import { resetTestDbPath } from '../test-utils/db-path';
8
9
  import {
9
10
  InFlightError,
10
11
  OPERATIONS_SWEEP_PATTERN,
@@ -30,7 +31,7 @@ describe('module-operations', () => {
30
31
 
31
32
  afterEach(() => {
32
33
  closeDb();
33
- process.env.CELILO_DB_PATH = undefined;
34
+ resetTestDbPath();
34
35
  try {
35
36
  rmSync(dir, { recursive: true, force: true });
36
37
  } catch {
@@ -10,6 +10,7 @@ import { ModuleManifestSchema } from '../manifest/schema';
10
10
  import { ModuleSubscriptionSchema } from '../manifest/schema';
11
11
  import type { ModuleManifest } from '../manifest/schema';
12
12
  import { setupTestDatabaseAt as migrateDbFile } from '../test-utils/database';
13
+ import { resetTestDbPath } from '../test-utils/db-path';
13
14
  import {
14
15
  registerModuleSubscriptions,
15
16
  resolveSubscription,
@@ -154,7 +155,7 @@ describe('register / unregister roundtrip', () => {
154
155
  process.env.EVENT_BUS_DB = dbPath;
155
156
  });
156
157
  afterEach(() => {
157
- process.env.EVENT_BUS_DB = undefined;
158
+ delete process.env.EVENT_BUS_DB;
158
159
  try {
159
160
  rmSync(dir, { recursive: true, force: true });
160
161
  } catch {
@@ -341,8 +342,8 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
341
342
  });
342
343
  afterEach(() => {
343
344
  closeDb();
344
- process.env.CELILO_DB_PATH = undefined;
345
- process.env.EVENT_BUS_DB = undefined;
345
+ resetTestDbPath();
346
+ delete process.env.EVENT_BUS_DB;
346
347
  try {
347
348
  rmSync(dir, { recursive: true, force: true });
348
349
  } catch {