@celilo/cli 2.2.1 → 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 (112) 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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/sessions.test.ts +3 -3
  10. package/src/api-clients/proxmox.ts +10 -7
  11. package/src/cli/backup-rename.test.ts +3 -3
  12. package/src/cli/cli.test.ts +1 -1
  13. package/src/cli/commands/events.test.ts +2 -2
  14. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  15. package/src/cli/commands/machine-list.test.ts +57 -0
  16. package/src/cli/commands/machine-list.ts +35 -4
  17. package/src/cli/commands/module-health.ts +1 -0
  18. package/src/cli/commands/module-import-registry.test.ts +1 -1
  19. package/src/cli/commands/module-jail.test.ts +242 -0
  20. package/src/cli/commands/module-jail.ts +227 -0
  21. package/src/cli/commands/module-list-jail.test.ts +136 -0
  22. package/src/cli/commands/module-list.ts +30 -3
  23. package/src/cli/commands/module-publish.test.ts +6 -6
  24. package/src/cli/commands/module-update.test.ts +22 -7
  25. package/src/cli/commands/module-update.ts +4 -1
  26. package/src/cli/commands/module-upgrade.test.ts +115 -1
  27. package/src/cli/commands/module-upgrade.ts +23 -1
  28. package/src/cli/commands/module-verify.test.ts +1 -1
  29. package/src/cli/commands/publish/execute.ts +4 -1
  30. package/src/cli/commands/publish/index.ts +11 -1
  31. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  32. package/src/cli/commands/publish/module-registry.ts +23 -4
  33. package/src/cli/commands/publish/plan.ts +1 -1
  34. package/src/cli/commands/publish/types.ts +7 -0
  35. package/src/cli/commands/registry-owner.test.ts +1 -1
  36. package/src/cli/commands/registry-token.test.ts +1 -1
  37. package/src/cli/commands/system-audit.ts +20 -6
  38. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  39. package/src/cli/commands/system-doctor.test.ts +76 -10
  40. package/src/cli/commands/system-doctor.ts +34 -3
  41. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  42. package/src/cli/commands/system-update.ts +5 -0
  43. package/src/cli/completion.ts +8 -2
  44. package/src/cli/flag-surface-gate.test.ts +279 -0
  45. package/src/cli/index.ts +68 -2
  46. package/src/cli/parser.test.ts +37 -1
  47. package/src/cli/restore-command.test.ts +3 -3
  48. package/src/cli/restore-migration-failure.test.ts +2 -2
  49. package/src/cli/tui/audit-state.ts +2 -0
  50. package/src/config/paths.test.ts +19 -19
  51. package/src/db/client.test.ts +46 -1
  52. package/src/db/client.ts +26 -0
  53. package/src/db/schema.ts +63 -0
  54. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  55. package/src/hooks/capability-loader.ts +8 -0
  56. package/src/hooks/executor.test.ts +86 -12
  57. package/src/hooks/executor.ts +31 -1
  58. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  59. package/src/hooks/hook-trespass.test.ts +9 -2
  60. package/src/hooks/jail.test.ts +105 -17
  61. package/src/hooks/jail.ts +47 -10
  62. package/src/manifest/contracts/v1.ts +13 -0
  63. package/src/policy/module-script-scan.ts +52 -68
  64. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  65. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  66. package/src/registry/client.test.ts +2 -2
  67. package/src/secrets/storage.test.ts +1 -1
  68. package/src/services/alerting/keys.test.ts +4 -0
  69. package/src/services/alerting/keys.ts +12 -2
  70. package/src/services/audit/health.test.ts +97 -2
  71. package/src/services/audit/health.ts +64 -2
  72. package/src/services/audit/index.test.ts +22 -0
  73. package/src/services/audit/index.ts +8 -2
  74. package/src/services/audit/jail-exemptions.test.ts +42 -0
  75. package/src/services/audit/jail-exemptions.ts +44 -0
  76. package/src/services/audit/module-integrity.test.ts +23 -1
  77. package/src/services/audit/module-integrity.ts +7 -2
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/backup-create.ts +9 -0
  80. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  81. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  82. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  83. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  84. package/src/services/bus-ensure-flow.test.ts +1 -1
  85. package/src/services/bus-interview-park.test.ts +2 -2
  86. package/src/services/bus-interview.test.ts +2 -2
  87. package/src/services/bus-secret-flow.test.ts +1 -1
  88. package/src/services/celilo-events.test.ts +1 -1
  89. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  90. package/src/services/container-service.test.ts +1 -1
  91. package/src/services/cross-module-read.test.ts +2 -2
  92. package/src/services/deploy-preflight.ts +7 -0
  93. package/src/services/deployed-systems.ts +1 -1
  94. package/src/services/fleet-checks.test.ts +123 -4
  95. package/src/services/fleet-checks.ts +176 -3
  96. package/src/services/health-runner.test.ts +87 -2
  97. package/src/services/health-runner.ts +40 -11
  98. package/src/services/infrastructure-selector.test.ts +1 -1
  99. package/src/services/jail-exemptions.test.ts +125 -0
  100. package/src/services/jail-exemptions.ts +81 -0
  101. package/src/services/machine-pool.test.ts +1 -1
  102. package/src/services/module-deploy.ts +12 -4
  103. package/src/services/module-subscriptions.test.ts +2 -2
  104. package/src/services/network-discovery.test.ts +64 -1
  105. package/src/services/network-discovery.ts +30 -5
  106. package/src/services/responder-probe.test.ts +1 -1
  107. package/src/services/restore-from-file.test.ts +4 -4
  108. package/src/services/restore-preflight.test.ts +1 -1
  109. package/src/services/ssh-key-manager.test.ts +2 -2
  110. package/src/services/system-state-stage.test.ts +2 -2
  111. package/src/services/update/orchestrator.test.ts +2 -0
  112. package/src/test-utils/bus-responder.ts +1 -1
@@ -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 } : {}) };
@@ -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', () => {
@@ -1048,8 +1048,13 @@ async function deployModuleImpl(
1048
1048
  }
1049
1049
  }
1050
1050
 
1051
- // Transition to INSTALLED
1052
- 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();
1053
1058
 
1054
1059
  // Run health checks for config-only modules too
1055
1060
  if (manifest.hooks?.health_check) {
@@ -1716,8 +1721,11 @@ async function deployModuleImpl(
1716
1721
  }
1717
1722
  }
1718
1723
 
1719
- // Transition to INSTALLED
1720
- 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();
1721
1729
 
1722
1730
  // Run health checks after successful deployment
1723
1731
  if (manifest.hooks?.health_check) {
@@ -155,7 +155,7 @@ describe('register / unregister roundtrip', () => {
155
155
  process.env.EVENT_BUS_DB = dbPath;
156
156
  });
157
157
  afterEach(() => {
158
- process.env.EVENT_BUS_DB = undefined;
158
+ delete process.env.EVENT_BUS_DB;
159
159
  try {
160
160
  rmSync(dir, { recursive: true, force: true });
161
161
  } catch {
@@ -343,7 +343,7 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
343
343
  afterEach(() => {
344
344
  closeDb();
345
345
  resetTestDbPath();
346
- process.env.EVENT_BUS_DB = undefined;
346
+ delete process.env.EVENT_BUS_DB;
347
347
  try {
348
348
  rmSync(dir, { recursive: true, force: true });
349
349
  } catch {
@@ -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[] = [];
@@ -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 {
@@ -46,8 +46,8 @@ describe('applyStagedSystemFiles', () => {
46
46
  afterEach(() => {
47
47
  closeDb();
48
48
  resetTestDbPath();
49
- process.env.CELILO_MASTER_KEY_PATH = undefined;
50
- process.env.CELILO_DATA_DIR = undefined;
49
+ delete process.env.CELILO_MASTER_KEY_PATH;
50
+ delete process.env.CELILO_DATA_DIR;
51
51
  try {
52
52
  rmSync(dir, { recursive: true, force: true });
53
53
  } catch {
@@ -207,8 +207,8 @@ describe('restoreFromArtifactFile error paths', () => {
207
207
  afterEach(() => {
208
208
  closeDb();
209
209
  resetTestDbPath();
210
- process.env.CELILO_DATA_DIR = undefined;
211
- process.env.CELILO_MASTER_KEY_PATH = undefined;
210
+ delete process.env.CELILO_DATA_DIR;
211
+ delete process.env.CELILO_MASTER_KEY_PATH;
212
212
  try {
213
213
  rmSync(dir, { recursive: true, force: true });
214
214
  } catch {
@@ -25,7 +25,7 @@ describe('restore pre-flight', () => {
25
25
  afterEach(() => {
26
26
  closeDb();
27
27
  resetTestDbPath();
28
- process.env.CELILO_DATA_DIR = undefined;
28
+ delete process.env.CELILO_DATA_DIR;
29
29
  try {
30
30
  rmSync(dir, { recursive: true, force: true });
31
31
  } catch {
@@ -99,8 +99,8 @@ describe('ssh-key-manager', () => {
99
99
 
100
100
  // Clear environment variables
101
101
  resetTestDbPath();
102
- process.env.CELILO_DATA_DIR = undefined;
103
- process.env.CELILO_MASTER_KEY_PATH = undefined;
102
+ delete process.env.CELILO_DATA_DIR;
103
+ delete process.env.CELILO_MASTER_KEY_PATH;
104
104
  });
105
105
 
106
106
  describe('writeTemporarySshKey', () => {
@@ -38,9 +38,9 @@ describe('stageSystemState', () => {
38
38
  });
39
39
 
40
40
  afterEach(() => {
41
- process.env.CELILO_DATA_DIR = undefined;
41
+ delete process.env.CELILO_DATA_DIR;
42
42
  resetTestDbPath();
43
- process.env.CELILO_MASTER_KEY_PATH = undefined;
43
+ delete process.env.CELILO_MASTER_KEY_PATH;
44
44
  rmSync(dataDir, { recursive: true, force: true });
45
45
  });
46
46
 
@@ -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 },
@@ -130,7 +130,7 @@ 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
135
  // Reset to the scratch path, never restore the previous value: it may
136
136
  // be another suite's temp database, or unset — which sends the next