@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
@@ -15,8 +15,9 @@
15
15
  import { beforeEach, describe, expect, test } from 'bun:test';
16
16
  import { eq } from 'drizzle-orm';
17
17
  import type { DbClient } from '../db/client';
18
- import { systemConfig } from '../db/schema';
18
+ import { modules, systemConfig } from '../db/schema';
19
19
  import { setupTestDatabase } from '../test-utils/database';
20
+ import { CONTROL_PLANE_MODULE_ID, upsertDeployedSystem } from './deployed-systems';
20
21
  import {
21
22
  discoverAndRecordNetwork,
22
23
  discoveredNetworkKeys,
@@ -102,6 +103,33 @@ describe('discoveredNetworkKeys (issue #300 redeploy idempotency)', () => {
102
103
  });
103
104
  });
104
105
 
106
+ test('first install with control plane in secure-mgmt records there (celilo#1356)', () => {
107
+ // The default topology (ce-8mxz) puts celilo-mgr on secure-mgmt, so a fresh
108
+ // segmented bootstrap must not file the control-plane subnet under
109
+ // `network.internal.*` — celilo would otherwise be blind to its own
110
+ // internal zone.
111
+ expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'secure-mgmt' })).toEqual({
112
+ 'network.secure-mgmt.subnet': '192.168.1.0/24',
113
+ 'network.secure-mgmt.gateway': '192.168.1.1',
114
+ });
115
+ });
116
+
117
+ test('first install with control plane in internal is unchanged', () => {
118
+ expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'internal' })).toEqual({
119
+ 'network.internal.subnet': '192.168.1.0/24',
120
+ 'network.internal.gateway': '192.168.1.1',
121
+ });
122
+ });
123
+
124
+ test('first install never clobbers an already-set control-plane zone value', () => {
125
+ expect(
126
+ discoveredNetworkKeys(internal, false, {
127
+ controlPlaneZone: 'secure-mgmt',
128
+ secureMgmtAlreadySet: true,
129
+ }),
130
+ ).toEqual({});
131
+ });
132
+
105
133
  test('preserves an already-set subnet on redeploy (no override)', () => {
106
134
  // The regression: a redeploy on a box off the internal network discovers the
107
135
  // WRONG subnet — it must not clobber the operator's value.
@@ -165,6 +193,41 @@ describe('discoverAndRecordNetwork', () => {
165
193
  expect(read('network.internal.gateway')).toBe('10.31.7.1');
166
194
  });
167
195
 
196
+ test('first install for a control plane deployed in secure-mgmt records secure-mgmt (celilo#1356)', () => {
197
+ // Seed the module_systems row the deploy pipeline writes before
198
+ // bootstrapControlPlane runs, placing celilo-mgmt in secure-mgmt.
199
+ db.insert(modules)
200
+ .values({
201
+ id: CONTROL_PLANE_MODULE_ID,
202
+ name: 'Celilo Management Server',
203
+ version: '0.9.0',
204
+ manifestData: {},
205
+ sourcePath: '/tmp/modules/celilo-mgmt',
206
+ })
207
+ .run();
208
+ upsertDeployedSystem(db, CONTROL_PLANE_MODULE_ID, {
209
+ name: 'main',
210
+ hostname: 'celilo-mgr',
211
+ ipv4Address: '10.226.120.10',
212
+ zone: 'secure-mgmt',
213
+ infraType: 'machine',
214
+ machineId: null,
215
+ serviceId: null,
216
+ vmid: null,
217
+ });
218
+
219
+ const result = discoverAndRecordNetwork(db, () =>
220
+ ipRouteFor('10.226.120.0', '10.226.120.1', '10.226.120.10'),
221
+ );
222
+
223
+ expect(result.applied).toEqual([
224
+ 'network.secure-mgmt.subnet = 10.226.120.0/24',
225
+ 'network.secure-mgmt.gateway = 10.226.120.1',
226
+ ]);
227
+ expect(read('network.internal.subnet')).toBeUndefined();
228
+ expect(read('network.secure-mgmt.subnet')).toBe('10.226.120.0/24');
229
+ });
230
+
168
231
  test('a redeploy on the same box changes nothing and says so', () => {
169
232
  const routes = () => ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20');
170
233
  discoverAndRecordNetwork(db, routes);
@@ -24,7 +24,8 @@
24
24
  import { execFileSync } from 'node:child_process';
25
25
  import { eq } from 'drizzle-orm';
26
26
  import type { DbClient } from '../db/client';
27
- import { systemConfig } from '../db/schema';
27
+ import { type NetworkZone, systemConfig } from '../db/schema';
28
+ import { CONTROL_PLANE_MODULE_ID, asZone, getModuleSystems } from './deployed-systems';
28
29
 
29
30
  export interface DiscoveredNetwork {
30
31
  subnet: string;
@@ -61,7 +62,14 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
61
62
  * LAN, so what it discovered IS `internal`. First install records it there.
62
63
  * - Segmented deployment: the box sits on a dedicated control-plane network.
63
64
  * `internal` is then someone else's subnet — the semi-trusted LAN — and the
64
- * discovered value belongs under `secure-mgmt`.
65
+ * discovered value belongs under the control plane's own zone (`secure-mgmt`
66
+ * in the default topology, celilo#1356).
67
+ *
68
+ * On FIRST INSTALL the zone is decided the same way the rest of celilo decides
69
+ * it: from where the control plane actually landed. `discoverAndRecordNetwork`
70
+ * reads `celilo-mgmt`'s deployed-system row and passes that zone in as
71
+ * `opts.controlPlaneZone`; with no row recorded yet the fallback stays
72
+ * `internal`, the pre-#1356 behaviour.
65
73
  *
66
74
  * Issue #300 spotted the second case as "discovery returns the WRONG subnet" and
67
75
  * mitigated it by DISCARDING the value whenever `internal` was already set. That
@@ -76,14 +84,21 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
76
84
  export function discoveredNetworkKeys(
77
85
  host: DiscoveredNetwork | null,
78
86
  internalAlreadySet: boolean,
79
- opts: { internalSubnet?: string; secureMgmtAlreadySet?: boolean } = {},
87
+ opts: {
88
+ internalSubnet?: string;
89
+ secureMgmtAlreadySet?: boolean;
90
+ /** Zone the control plane itself occupies; first install records there, not `internal`. */
91
+ controlPlaneZone?: NetworkZone;
92
+ } = {},
80
93
  ): Record<string, string> {
81
94
  if (!host) return {};
82
95
 
83
96
  if (!internalAlreadySet) {
97
+ const targetZone = opts.controlPlaneZone ?? 'internal';
98
+ if (targetZone !== 'internal' && opts.secureMgmtAlreadySet) return {};
84
99
  return {
85
- 'network.internal.subnet': host.subnet,
86
- 'network.internal.gateway': host.gateway,
100
+ [`network.${targetZone}.subnet`]: host.subnet,
101
+ [`network.${targetZone}.gateway`]: host.gateway,
87
102
  };
88
103
  }
89
104
 
@@ -118,6 +133,15 @@ function readValue(db: DbClient, key: string): string | undefined {
118
133
  return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
119
134
  }
120
135
 
136
+ /**
137
+ * The zone the control plane actually landed in, from its deployed-system row
138
+ * (same source `loadControlPlaneSubnet` reads). Undefined when celilo-mgmt has
139
+ * no system recorded yet, so the caller falls back to `internal`.
140
+ */
141
+ function controlPlaneZone(db: DbClient): NetworkZone | undefined {
142
+ return asZone(getModuleSystems(CONTROL_PLANE_MODULE_ID, db)[0]?.zone) ?? undefined;
143
+ }
144
+
121
145
  /**
122
146
  * Discover the network this box sits on and record it. Idempotent, and never
123
147
  * overwrites a value that is already set.
@@ -143,6 +167,7 @@ export function discoverAndRecordNetwork(
143
167
  const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, {
144
168
  internalSubnet,
145
169
  secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined,
170
+ controlPlaneZone: controlPlaneZone(db),
146
171
  });
147
172
 
148
173
  const applied: string[] = [];
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../db/client';
8
8
  import { portForwards } from '../db/schema';
9
9
  import { setupTestDatabaseAt } from '../test-utils/database';
10
+ import { resetTestDbPath } from '../test-utils/db-path';
10
11
  import { deleteClaimedRows } from './capability-table-rows';
11
12
  import { buildPortForwardStore } from './port-forwards';
12
13
 
@@ -27,7 +28,7 @@ describe('port-forward store', () => {
27
28
  });
28
29
  afterEach(() => {
29
30
  db.$client.close();
30
- process.env.CELILO_DB_PATH = undefined;
31
+ resetTestDbPath();
31
32
  try {
32
33
  rmSync(dir, { recursive: true, force: true });
33
34
  } catch {
@@ -17,6 +17,7 @@ import { join } from 'node:path';
17
17
  import { defineEvents, openBus } from '@celilo/event-bus';
18
18
  import { closeDb, getDb } from '../db/client';
19
19
  import { runMigrations } from '../db/migrate';
20
+ import { resetTestDbPath } from '../test-utils/db-path';
20
21
  import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview';
21
22
  import { startProgrammaticResponder } from './programmatic-responder';
22
23
 
@@ -72,7 +73,7 @@ describe('programmatic responder — aspect.required consent (#262)', () => {
72
73
  afterEach(() => {
73
74
  closeDb();
74
75
  rmSync(dir, { recursive: true, force: true });
75
- process.env.CELILO_DB_PATH = undefined;
76
+ resetTestDbPath();
76
77
  });
77
78
 
78
79
  it('approves (consented=true) when the policy approves the module', async () => {
@@ -27,6 +27,7 @@ import {
27
27
  modules,
28
28
  } from '../db/schema';
29
29
  import type { BaseModuleAspect } from '../manifest/schema';
30
+ import { resetTestDbPath } from '../test-utils/db-path';
30
31
  import { upsertModuleConfig } from './module-config';
31
32
  import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
32
33
 
@@ -153,7 +154,7 @@ describe('proxmox-reconcile', () => {
153
154
 
154
155
  afterEach(() => {
155
156
  closeDb();
156
- process.env.CELILO_DB_PATH = undefined;
157
+ resetTestDbPath();
157
158
  try {
158
159
  rmSync(dir, { recursive: true, force: true });
159
160
  } catch {
@@ -32,7 +32,7 @@ describe('ensureResponderForInterview (ISS-0025)', () => {
32
32
  origTTY = process.stdin.isTTY;
33
33
  });
34
34
  afterEach(() => {
35
- if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
35
+ if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
36
36
  else process.env.EVENT_BUS_DB = origEnv;
37
37
  setTTY(origTTY);
38
38
  try {
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
  import { closeDb } from '../db/client';
25
25
  import { runMigrations } from '../db/migrate';
26
+ import { resetTestDbPath } from '../test-utils/db-path';
26
27
  import { applyStagedSystemFiles, restoreFromArtifactFile } from './restore-from-file';
27
28
 
28
29
  describe('applyStagedSystemFiles', () => {
@@ -44,9 +45,9 @@ describe('applyStagedSystemFiles', () => {
44
45
 
45
46
  afterEach(() => {
46
47
  closeDb();
47
- process.env.CELILO_DB_PATH = undefined;
48
- process.env.CELILO_MASTER_KEY_PATH = undefined;
49
- process.env.CELILO_DATA_DIR = undefined;
48
+ resetTestDbPath();
49
+ delete process.env.CELILO_MASTER_KEY_PATH;
50
+ delete process.env.CELILO_DATA_DIR;
50
51
  try {
51
52
  rmSync(dir, { recursive: true, force: true });
52
53
  } catch {
@@ -205,9 +206,9 @@ describe('restoreFromArtifactFile error paths', () => {
205
206
 
206
207
  afterEach(() => {
207
208
  closeDb();
208
- process.env.CELILO_DB_PATH = undefined;
209
- process.env.CELILO_DATA_DIR = undefined;
210
- process.env.CELILO_MASTER_KEY_PATH = undefined;
209
+ resetTestDbPath();
210
+ delete process.env.CELILO_DATA_DIR;
211
+ delete process.env.CELILO_MASTER_KEY_PATH;
211
212
  try {
212
213
  rmSync(dir, { recursive: true, force: true });
213
214
  } catch {
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { closeDb, getDb } from '../db/client';
6
6
  import { runMigrations } from '../db/migrate';
7
7
  import { modules } from '../db/schema';
8
+ import { resetTestDbPath } from '../test-utils/db-path';
8
9
  import {
9
10
  NonEmptyRestoreTargetError,
10
11
  assertRestoreTargetEmpty,
@@ -23,8 +24,8 @@ describe('restore pre-flight', () => {
23
24
 
24
25
  afterEach(() => {
25
26
  closeDb();
26
- process.env.CELILO_DB_PATH = undefined;
27
- process.env.CELILO_DATA_DIR = undefined;
27
+ resetTestDbPath();
28
+ delete process.env.CELILO_DATA_DIR;
28
29
  try {
29
30
  rmSync(dir, { recursive: true, force: true });
30
31
  } catch {
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { type DbClient, getDb } from '../db/client';
6
6
  import { modules } from '../db/schema';
7
+ import { resetTestDbPath } from '../test-utils/db-path';
7
8
  import {
8
9
  type SecretsSchema,
9
10
  getAllSecretMetadata,
@@ -40,7 +41,7 @@ describe('secret-schema-loader', () => {
40
41
  afterEach(() => {
41
42
  // Clean up temp directory
42
43
  rmSync(tempDir, { recursive: true, force: true });
43
- process.env.CELILO_DB_PATH = undefined;
44
+ resetTestDbPath();
44
45
  });
45
46
 
46
47
  describe('loadSecretsSchema', () => {
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { closeDb } from '../db/client';
10
10
  import { runMigrations } from '../db/migrate';
11
+ import { resetTestDbPath } from '../test-utils/db-path';
11
12
  import { addMachine } from './machine-pool';
12
13
  import {
13
14
  LOCAL_MACHINE_IP,
@@ -97,9 +98,9 @@ describe('ssh-key-manager', () => {
97
98
  }
98
99
 
99
100
  // Clear environment variables
100
- process.env.CELILO_DB_PATH = undefined;
101
- process.env.CELILO_DATA_DIR = undefined;
102
- process.env.CELILO_MASTER_KEY_PATH = undefined;
101
+ resetTestDbPath();
102
+ delete process.env.CELILO_DATA_DIR;
103
+ delete process.env.CELILO_MASTER_KEY_PATH;
103
104
  });
104
105
 
105
106
  describe('writeTemporarySshKey', () => {
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
14
14
  import type { DbClient } from '../db/client';
15
15
  import { moduleConfigs, modules, webRoutes } from '../db/schema';
16
16
  import { setupTestDatabaseAt } from '../test-utils/database';
17
+ import { resetTestDbPath } from '../test-utils/db-path';
17
18
  import {
18
19
  ansibleTreeMentions,
19
20
  buildStaticContentPlan,
@@ -64,7 +65,7 @@ describe('planStaticContent — the declared release set (D10 task 4.1)', () =>
64
65
 
65
66
  afterEach(() => {
66
67
  db.$client.close();
67
- process.env.CELILO_DB_PATH = undefined;
68
+ resetTestDbPath();
68
69
  try {
69
70
  rmSync(dir, { recursive: true, force: true });
70
71
  } catch {
@@ -177,7 +178,7 @@ describe('resolveStaticContentRetention — operator config with a default', ()
177
178
 
178
179
  afterEach(() => {
179
180
  db.$client.close();
180
- process.env.CELILO_DB_PATH = undefined;
181
+ resetTestDbPath();
181
182
  try {
182
183
  rmSync(dir, { recursive: true, force: true });
183
184
  } catch {
@@ -279,7 +280,7 @@ describe('the state web overlay (celilo#1265)', () => {
279
280
 
280
281
  afterEach(() => {
281
282
  db.$client.close();
282
- process.env.CELILO_DB_PATH = undefined;
283
+ resetTestDbPath();
283
284
  try {
284
285
  rmSync(dir, { recursive: true, force: true });
285
286
  } catch {
@@ -359,7 +360,7 @@ describe('writeStaticContentVars — lands in the generated inventory', () => {
359
360
 
360
361
  afterEach(() => {
361
362
  db.$client.close();
362
- process.env.CELILO_DB_PATH = undefined;
363
+ resetTestDbPath();
363
364
  try {
364
365
  rmSync(dir, { recursive: true, force: true });
365
366
  } catch {
@@ -23,7 +23,7 @@
23
23
  * `module verify` (celilo#1018, deliberately left to that thread).
24
24
  */
25
25
 
26
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
26
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
27
27
  import { mkdir, writeFile } from 'node:fs/promises';
28
28
  import { join } from 'node:path';
29
29
  import { and, eq, isNotNull } from 'drizzle-orm';
@@ -237,9 +237,10 @@ export function staticContentVarsYaml(plan: StaticContentPlan): string {
237
237
  *
238
238
  * The shared half of both converge triggers: the capability's callback adds
239
239
  * `executeAnsible` after this, and the provider's own deploy already runs it.
240
- * Writing happens AFTER the deploy's staleness check (`refuseIfGeneratedIsStale`
241
- * only inspects verbatim role assets), so an injected vars file never reads as
242
- * a stale generation.
240
+ * Nothing inspects verbatim role assets any more — the staleness pre-flight
241
+ * retired with the ephemeral generated tree (D5 of
242
+ * control-plane-stops-building-modules), so an injected vars file cannot read
243
+ * as a stale generation because nothing reads staleness.
243
244
  */
244
245
  export async function writeStaticContentVars(
245
246
  db: DbClient,
@@ -308,13 +309,31 @@ export async function convergeStaticContent(
308
309
  return { success: false, error: `Provider module '${providerModuleId}' is not installed` };
309
310
  }
310
311
  const generatedPath = join(module.sourcePath, 'generated');
312
+ // D4 (control-plane-stops-building-modules): the generated project is
313
+ // ephemeral, so a successful provider deploy deletes it and nothing is
314
+ // persisted for this converge to read. Render it here, on demand, from the
315
+ // signed payload plus resolved config — generation is fast and local. A
316
+ // render failure fails loudly with the deploy-once remedy, same as the old
317
+ // missing-project refusal did; a tree that predates the converge support is
318
+ // still refused by the static_releases check below.
311
319
  if (!existsSync(generatedPath)) {
312
- return {
313
- success: false,
314
- error:
315
- `public_web provider '${providerModuleId}' has no generated deploy project at ${generatedPath}. ` +
316
- `Run \`celilo module deploy ${providerModuleId}\` once, then retry the publish.`,
317
- };
320
+ const { generateTemplates } = await import('../templates/generator');
321
+ const rendered = await generateTemplates({
322
+ moduleId: providerModuleId,
323
+ modulePath: module.sourcePath,
324
+ outputPath: generatedPath,
325
+ db,
326
+ skipVariableValidation: false,
327
+ });
328
+ if (!rendered.success) {
329
+ return {
330
+ success: false,
331
+ error:
332
+ `public_web provider '${providerModuleId}' has no generated deploy project at ${generatedPath} ` +
333
+ `and rendering one failed: ${rendered.error ?? 'unknown error'}. ` +
334
+ `Run \`celilo module deploy ${providerModuleId}\` once, then retry the publish.`,
335
+ };
336
+ }
318
337
  }
319
338
 
320
339
  const written = await writeStaticContentVars(db, providerModuleId, generatedPath);
@@ -342,5 +361,9 @@ export async function convergeStaticContent(
342
361
  varsPath: written.varsPath,
343
362
  };
344
363
  }
364
+ // D4: the converge rendered the tree, used it, and is done with it. The same
365
+ // no-secrets-at-rest rule the deploy follows applies here; on the failure
366
+ // paths above the tree stays for inspection.
367
+ rmSync(generatedPath, { recursive: true, force: true });
345
368
  return { success: true, varsPath: written.varsPath };
346
369
  }
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
13
13
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
+ import { resetTestDbPath } from '../test-utils/db-path';
16
17
  import { snapshotDatabase, stageSystemState } from './system-state-stage';
17
18
 
18
19
  describe('stageSystemState', () => {
@@ -37,9 +38,9 @@ describe('stageSystemState', () => {
37
38
  });
38
39
 
39
40
  afterEach(() => {
40
- process.env.CELILO_DATA_DIR = undefined;
41
- process.env.CELILO_DB_PATH = undefined;
42
- process.env.CELILO_MASTER_KEY_PATH = undefined;
41
+ delete process.env.CELILO_DATA_DIR;
42
+ resetTestDbPath();
43
+ delete process.env.CELILO_MASTER_KEY_PATH;
43
44
  rmSync(dataDir, { recursive: true, force: true });
44
45
  });
45
46
 
@@ -6,6 +6,7 @@ import type { DbClient } from '../db/client';
6
6
  import { systemConfig } from '../db/schema';
7
7
  import { loadTrustedSubnets } from '../hooks/capability-loader';
8
8
  import { setupTestDatabaseAt } from '../test-utils/database';
9
+ import { resetTestDbPath } from '../test-utils/db-path';
9
10
  import {
10
11
  buildTrustedSourceStore,
11
12
  composeTrustedSubnets,
@@ -30,7 +31,7 @@ describe('trusted-source store', () => {
30
31
  });
31
32
  afterEach(() => {
32
33
  db.$client.close();
33
- process.env.CELILO_DB_PATH = undefined;
34
+ resetTestDbPath();
34
35
  try {
35
36
  rmSync(dir, { recursive: true, force: true });
36
37
  } catch {
@@ -147,7 +148,7 @@ describe('the render input excludes registrations; the reporting view includes t
147
148
  });
148
149
  afterEach(() => {
149
150
  db.$client.close();
150
- process.env.CELILO_DB_PATH = undefined;
151
+ resetTestDbPath();
151
152
  try {
152
153
  rmSync(dir, { recursive: true, force: true });
153
154
  } catch {
@@ -78,6 +78,7 @@ const cleanAudit: AuditDeps = {
78
78
  moduleConfigs: { modules: [] },
79
79
  moduleIntegrity: { results: [] },
80
80
  detectWithoutConverge: { modules: [] },
81
+ jailExemptions: { exemptions: [] },
81
82
  health: { results: [] },
82
83
  backups: { modules: [] },
83
84
  abandonedOperations: { records: [] },
@@ -87,6 +88,7 @@ const cleanAudit: AuditDeps = {
87
88
  secretsDecryptable: { results: [] },
88
89
  servicesReachable: { results: [] },
89
90
  machinesReachable: { results: [] },
91
+ diskSpace: { results: [] },
90
92
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
91
93
  trustedSources: { firewalls: [] },
92
94
  publicDns: { records: [], probe: unusedPublicDnsProbe },
@@ -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 { systemConfig } from '../db/schema';
12
+ import { resetTestDbPath } from '../test-utils/db-path';
12
13
  import { detectZoneFromIp, isValidIp } from './zone-detector';
13
14
 
14
15
  describe('zone-detector', () => {
@@ -46,7 +47,7 @@ describe('zone-detector', () => {
46
47
  }
47
48
 
48
49
  // Clear environment variables
49
- process.env.CELILO_DB_PATH = undefined;
50
+ resetTestDbPath();
50
51
  });
51
52
 
52
53
  describe('detectZoneFromIp', () => {
@@ -50,6 +50,7 @@ import {
50
50
  type ResponderValues,
51
51
  startProgrammaticResponder,
52
52
  } from '../services/programmatic-responder';
53
+ import { resetTestDbPath } from './db-path';
53
54
 
54
55
  export interface BusResponderFixtureOptions extends ResponderValues {
55
56
  /** Path to the bus sqlite db (shared with the celilo subprocess). */
@@ -98,7 +99,6 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
98
99
  const prevDataDir = process.env.CELILO_DATA_DIR;
99
100
  process.env.CELILO_DATA_DIR = opts.dataDir;
100
101
 
101
- const prevDbPath = process.env.CELILO_DB_PATH;
102
102
  process.env.CELILO_DB_PATH = opts.celiloDbPath;
103
103
  const db: DbClient = createDbClient();
104
104
 
@@ -130,10 +130,12 @@ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusR
130
130
  close: () => {
131
131
  handle.close();
132
132
  db.$client.close();
133
- if (prevDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
133
+ if (prevDataDir === undefined) delete process.env.CELILO_DATA_DIR;
134
134
  else process.env.CELILO_DATA_DIR = prevDataDir;
135
- if (prevDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
136
- else process.env.CELILO_DB_PATH = prevDbPath;
135
+ // Reset to the scratch path, never restore the previous value: it may
136
+ // be another suite's temp database, or unset — which sends the next
137
+ // var-less reader to the operator's real celilo.db (celilo#1315).
138
+ resetTestDbPath();
137
139
  },
138
140
  };
139
141
  }
@@ -0,0 +1,25 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The neutral database path the test preload arms `CELILO_DB_PATH` with
6
+ * (celilo#1315). The file does not exist, so a reader falls into whatever
7
+ * "no database" branch its feature has — and no test ever opens the
8
+ * operator's real `~/Library/.../celilo.db`, which is what a suite that
9
+ * reads the variable without setting it would otherwise reach.
10
+ *
11
+ * Why teardowns RESET to this path instead of `delete`: bunfig's `[test]`
12
+ * preload runs ONCE PER PROCESS, not once per test file. A teardown that
13
+ * clears the variable leaves every later var-less reader on the real
14
+ * database for the rest of the run. Resetting to the scratch path keeps the
15
+ * preload's invariant standing from the first test to the last.
16
+ */
17
+ export const SCRATCH_DB_PATH = join(tmpdir(), 'celilo-test-absent.db');
18
+
19
+ /**
20
+ * Point `CELILO_DB_PATH` at the neutral scratch path. Call from a test's
21
+ * afterEach (or any teardown) that pointed the variable somewhere else.
22
+ */
23
+ export function resetTestDbPath(): void {
24
+ process.env.CELILO_DB_PATH = SCRATCH_DB_PATH;
25
+ }
@@ -54,6 +54,13 @@ export interface IntegrationTestContext {
54
54
  * ```
55
55
  */
56
56
  export async function setupIntegrationTest(): Promise<IntegrationTestContext> {
57
+ // celilo#1246: the shell on a dev host carries CELILO_HOME pointing at the
58
+ // live install (DB and master.key included). Every CLI child this harness
59
+ // spawns would inherit it, and isolation would then rest on the per-run
60
+ // CELILO_DB_PATH / CELILO_DATA_DIR overrides below never being missed.
61
+ // Unset it once, here, so no child ever sees the live home.
62
+ delete process.env.CELILO_HOME;
63
+
57
64
  // Setup isolated database
58
65
  const testDb = await setupTestDatabaseFile();
59
66
  const dbPath = testDb.path;
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CLIContext } from './cli-context';
11
+ import { resetTestDbPath } from './db-path';
11
12
  import { getModuleTestConfig } from './fixtures';
12
13
  import { type IntegrationTestContext, setupIntegrationTest } from './integration';
13
14
 
@@ -50,7 +51,6 @@ interface HomebridgeModuleOptions extends BaseFixtureOptions {
50
51
  async function insertMockContainerService(dbPath: string): Promise<void> {
51
52
  const { getDb } = await import('../db/client');
52
53
  const { containerServices } = await import('../db/schema');
53
- const originalDbPath = process.env.CELILO_DB_PATH;
54
54
  process.env.CELILO_DB_PATH = dbPath;
55
55
  try {
56
56
  const db = getDb();
@@ -77,11 +77,10 @@ async function insertMockContainerService(dbPath: string): Promise<void> {
77
77
  updatedAt: new Date(),
78
78
  });
79
79
  } finally {
80
- if (originalDbPath) {
81
- process.env.CELILO_DB_PATH = originalDbPath;
82
- } else {
83
- process.env.CELILO_DB_PATH = undefined;
84
- }
80
+ // Reset to the scratch path, never restore the previous value: it may
81
+ // be another suite's temp database, or unset — which sends the next
82
+ // var-less reader to the operator's real celilo.db (celilo#1315).
83
+ resetTestDbPath();
85
84
  }
86
85
  }
87
86