@celilo/cli 2.2.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -4,7 +4,7 @@
4
4
  * Coordinates full module deployment workflow across all phases
5
5
  */
6
6
 
7
- import { rmSync } from 'node:fs';
7
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { ProgressDisplay } from '@celilo/cli-display';
10
10
  import { and, eq } from 'drizzle-orm';
@@ -337,6 +337,61 @@ async function backfillArrivedProvider(moduleId: string, db: DbClient): Promise<
337
337
  * alerting, etc. — can react. Bus emit failures are best-effort and
338
338
  * never affect the deploy outcome.
339
339
  */
340
+ /**
341
+ * Files under `generated/terraform/` that outlive the deploy.
342
+ *
343
+ * D4 (control-plane-stops-building-modules) deletes `generated/` because it
344
+ * holds RENDERED templates and secrets — a tree nothing can validate and what a
345
+ * later deploy would wrongly ship. Terraform state is neither. It is the record
346
+ * of what celilo actually BUILT, and `templates/generator.ts` calls it
347
+ * "celilo's authoritative record of placement (ISS-0090)".
348
+ *
349
+ * Deleting it meant the next deploy started from empty state, failed to create
350
+ * a container that already existed, auto-imported it, and planned a REPLACE —
351
+ * destroying the container it was asked to converge (celilo#1374, which
352
+ * destroyed signal's LXC and its signal-cli device identity).
353
+ *
354
+ * `.terraform/` is NOT preserved: it is the provider binary cache, ~19 MB per
355
+ * provider, rebuilt by `terraform init`. `cross-module-read.ts` excludes it from
356
+ * the backup envelope for the same reason.
357
+ */
358
+ export const PRESERVED_GENERATED_FILES = [
359
+ 'terraform.tfstate',
360
+ 'terraform.tfstate.backup',
361
+ '.terraform.lock.hcl',
362
+ ];
363
+
364
+ /** True for the paths, relative to `generated/`, that survive the cleanup. */
365
+ export function isPreservedGeneratedPath(relativePath: string): boolean {
366
+ const [dir, file, ...rest] = relativePath.split('/');
367
+ if (dir !== 'terraform' || rest.length > 0) return false;
368
+ return file !== undefined && PRESERVED_GENERATED_FILES.includes(file);
369
+ }
370
+
371
+ /**
372
+ * Delete the rendered project but keep terraform's record of what exists.
373
+ * Absent `generated/` is not an error — a deploy that failed early never made
374
+ * one.
375
+ */
376
+ export function pruneGeneratedProject(generatedPath: string): void {
377
+ if (!existsSync(generatedPath)) return;
378
+
379
+ for (const entry of readdirSync(generatedPath, { withFileTypes: true })) {
380
+ if (entry.name !== 'terraform') {
381
+ rmSync(join(generatedPath, entry.name), { recursive: true, force: true });
382
+ }
383
+ }
384
+
385
+ const terraformDir = join(generatedPath, 'terraform');
386
+ if (!existsSync(terraformDir)) return;
387
+
388
+ for (const entry of readdirSync(terraformDir, { withFileTypes: true })) {
389
+ if (!isPreservedGeneratedPath(`terraform/${entry.name}`)) {
390
+ rmSync(join(terraformDir, entry.name), { recursive: true, force: true });
391
+ }
392
+ }
393
+ }
394
+
340
395
  export async function deployModule(
341
396
  moduleId: string,
342
397
  db: DbClient,
@@ -392,8 +447,10 @@ export async function deployModule(
392
447
  if (deployed) {
393
448
  const generatedPath = `${deployed.sourcePath}/generated`;
394
449
  try {
395
- rmSync(generatedPath, { recursive: true, force: true });
396
- log.info(`Removed generated/ for '${moduleId}' (ephemeral by design; --keep retains it)`);
450
+ pruneGeneratedProject(generatedPath);
451
+ log.info(
452
+ `Removed generated/ for '${moduleId}' (terraform state preserved; --keep retains all)`,
453
+ );
397
454
  } catch (cleanupError) {
398
455
  const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
399
456
  log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
@@ -907,48 +964,10 @@ async function deployModuleImpl(
907
964
  };
908
965
  }
909
966
 
910
- // Store hook outputs as secrets (e.g., refresh_token from OAuth flow)
911
- if (Object.keys(hookResult.outputs).length > 0) {
912
- const { secrets: secretsTable } = await import('../db/schema');
913
- const { encryptSecret } = await import('../secrets/encryption');
914
- const { and: andOp, eq: eqOp } = await import('drizzle-orm');
915
- for (const [key, value] of Object.entries(hookResult.outputs)) {
916
- if (typeof value === 'string') {
917
- const masterKeyForOutputs = await getOrCreateMasterKey();
918
- const encrypted = encryptSecret(value, masterKeyForOutputs);
919
-
920
- // Check if secret already exists (no unique constraint on module_id+name)
921
- const existing = db
922
- .select()
923
- .from(secretsTable)
924
- .where(andOp(eqOp(secretsTable.moduleId, moduleId), eqOp(secretsTable.name, key)))
925
- .get();
926
-
927
- if (existing) {
928
- db.update(secretsTable)
929
- .set({
930
- encryptedValue: encrypted.encryptedValue,
931
- iv: encrypted.iv,
932
- authTag: encrypted.authTag,
933
- updatedAt: new Date(),
934
- })
935
- .where(eqOp(secretsTable.id, existing.id))
936
- .run();
937
- } else {
938
- db.insert(secretsTable)
939
- .values({
940
- moduleId,
941
- name: key,
942
- encryptedValue: encrypted.encryptedValue,
943
- iv: encrypted.iv,
944
- authTag: encrypted.authTag,
945
- })
946
- .run();
947
- }
948
- log.success(`Stored hook output: ${key}`);
949
- }
950
- }
951
- }
967
+ // Hook return values are no longer persisted (hook-owned-state D5):
968
+ // a hook that needs to persist state does so explicitly through the
969
+ // `context.config` / `context.secrets` accessors inside its own
970
+ // process. Nothing here sweeps `hookResult.outputs` into secrets.
952
971
 
953
972
  gauge.stop(true);
954
973
  } catch (error) {
@@ -1048,8 +1067,13 @@ async function deployModuleImpl(
1048
1067
  }
1049
1068
  }
1050
1069
 
1051
- // Transition to INSTALLED
1052
- db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
1070
+ // Transition to INSTALLED. A successful deploy ends any recorded
1071
+ // failure: leaving a stale errorMessage would print `Error:` beside a
1072
+ // healthy state in `module list` / `module status` (celilo#1363).
1073
+ db.update(modules)
1074
+ .set({ state: 'INSTALLED', errorMessage: null })
1075
+ .where(eq(modules.id, moduleId))
1076
+ .run();
1053
1077
 
1054
1078
  // Run health checks for config-only modules too
1055
1079
  if (manifest.hooks?.health_check) {
@@ -1347,45 +1371,11 @@ async function deployModuleImpl(
1347
1371
  };
1348
1372
  }
1349
1373
 
1350
- // Store hook outputs as secrets on the PROVIDER module
1351
- if (Object.keys(hookResult.outputs).length > 0) {
1352
- const { secrets: st } = await import('../db/schema');
1353
- const { encryptSecret } = await import('../secrets/encryption');
1354
- for (const [key, value] of Object.entries(hookResult.outputs)) {
1355
- if (typeof value === 'string') {
1356
- const mk = await getOrCreateMasterKey();
1357
- const encrypted = encryptSecret(value, mk);
1358
- const existing = db
1359
- .select()
1360
- .from(st)
1361
- .where(and(eq(st.moduleId, capRecord.moduleId), eq(st.name, key)))
1362
- .get();
1363
-
1364
- if (existing) {
1365
- db.update(st)
1366
- .set({
1367
- encryptedValue: encrypted.encryptedValue,
1368
- iv: encrypted.iv,
1369
- authTag: encrypted.authTag,
1370
- updatedAt: new Date(),
1371
- })
1372
- .where(eq(st.id, existing.id))
1373
- .run();
1374
- } else {
1375
- db.insert(st)
1376
- .values({
1377
- moduleId: capRecord.moduleId,
1378
- name: key,
1379
- encryptedValue: encrypted.encryptedValue,
1380
- iv: encrypted.iv,
1381
- authTag: encrypted.authTag,
1382
- })
1383
- .run();
1384
- }
1385
- log.success(`Stored ${capRecord.moduleId} hook output: ${key}`);
1386
- }
1387
- }
1388
- }
1374
+ // The return-value channel is gone (hook-owned-state D5/D3): hook
1375
+ // outputs are no longer persisted anywhere. A provider secret the
1376
+ // framework itself must record goes through the named
1377
+ // `recordProviderSecret` path (secrets/storage.ts) — never through a
1378
+ // hook return value.
1389
1379
 
1390
1380
  gauge.stop(true);
1391
1381
  }
@@ -1673,51 +1663,16 @@ async function deployModuleImpl(
1673
1663
  };
1674
1664
  }
1675
1665
 
1676
- // Persist on_install string outputs as secrets on this module — same
1677
- // as the validate_config / container_created paths above. caddy relies
1678
- // on this to publish its firewall external IP (`public_ip`) so
1679
- // public_web.register_route can point a new hostname's public A record
1680
- // at it (M1 / #328).
1681
- if (Object.keys(installResult.outputs).length > 0) {
1682
- const { secrets: st } = await import('../db/schema');
1683
- const { encryptSecret } = await import('../secrets/encryption');
1684
- const mk = await getOrCreateMasterKey();
1685
- for (const [key, value] of Object.entries(installResult.outputs)) {
1686
- if (typeof value !== 'string') continue;
1687
- const encrypted = encryptSecret(value, mk);
1688
- const existing = db
1689
- .select()
1690
- .from(st)
1691
- .where(and(eq(st.moduleId, moduleId), eq(st.name, key)))
1692
- .get();
1693
- if (existing) {
1694
- db.update(st)
1695
- .set({
1696
- encryptedValue: encrypted.encryptedValue,
1697
- iv: encrypted.iv,
1698
- authTag: encrypted.authTag,
1699
- updatedAt: new Date(),
1700
- })
1701
- .where(eq(st.id, existing.id))
1702
- .run();
1703
- } else {
1704
- db.insert(st)
1705
- .values({
1706
- moduleId,
1707
- name: key,
1708
- encryptedValue: encrypted.encryptedValue,
1709
- iv: encrypted.iv,
1710
- authTag: encrypted.authTag,
1711
- })
1712
- .run();
1713
- }
1714
- log.success(`Stored hook output: ${key}`);
1715
- }
1716
- }
1666
+ // Hook return values are no longer persisted (hook-owned-state D5).
1667
+ // caddy's `public_ip` moved to `context.config.set` + a config read
1668
+ // (task 5.1); no other hook may rely on its return reaching the store.
1717
1669
  }
1718
1670
 
1719
- // Transition to INSTALLED
1720
- db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
1671
+ // Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
1672
+ db.update(modules)
1673
+ .set({ state: 'INSTALLED', errorMessage: null })
1674
+ .where(eq(modules.id, moduleId))
1675
+ .run();
1721
1676
 
1722
1677
  // Run health checks after successful deployment
1723
1678
  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 {
@@ -8,7 +8,7 @@
8
8
  * committed types file is not regenerated, this test fails.
9
9
  *
10
10
  * Scope: `modules/<id>/manifest.yml` at the repo root. Archived
11
- * modules (`modules/archive/**`) and scratch extract dirs
11
+ * modules (`modules/__archive__/**`) and scratch extract dirs
12
12
  * (`modules/.tmp-*`) are excluded — they are not active modules.
13
13
  */
14
14
 
@@ -14,11 +14,15 @@ describe('validateCapabilityVersions', () => {
14
14
  });
15
15
 
16
16
  test('no errors when provides matches the runtime registry exactly', () => {
17
+ // Read from the registry, not pinned to a literal (see the comment on the
18
+ // "differs from runtime" test below — the registry moves, the test is
19
+ // about matching it).
20
+ const runtime = CAPABILITY_CONTRACT_VERSIONS.public_web;
17
21
  expect(
18
22
  validateCapabilityVersions({
19
23
  id: 'caddy',
20
24
  version: '3.0.0',
21
- provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
25
+ provides: { capabilities: [{ name: 'public_web', version: runtime }] },
22
26
  }),
23
27
  ).toEqual([]);
24
28
  });
@@ -40,10 +44,17 @@ describe('validateCapabilityVersions', () => {
40
44
  });
41
45
 
42
46
  test('error when provides[X].version is newer than runtime', () => {
47
+ // One minor above the registry is newer without being a different major,
48
+ // which is what this case tests. Computed from the registry so the test
49
+ // stays a behaviour test as the registry moves.
50
+ const [maj, min, pat] = CAPABILITY_CONTRACT_VERSIONS.public_web
51
+ .split('.')
52
+ .map((n) => Number.parseInt(n, 10));
53
+ const tooNew = `${maj}.${(min ?? 0) + 1}.${pat ?? 0}`;
43
54
  const errors = validateCapabilityVersions({
44
55
  id: 'caddy',
45
56
  version: '4.0.0',
46
- provides: { capabilities: [{ name: 'public_web', version: '4.0.0' }] },
57
+ provides: { capabilities: [{ name: 'public_web', version: tooNew }] },
47
58
  });
48
59
  expect(errors).toHaveLength(1);
49
60
  expect(errors[0]).toContain('provides[public_web]');
@@ -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
 
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
3
+
4
+ /**
5
+ * The plan terraform actually produced on celilo-mgr at 2026-09-16T00:04Z,
6
+ * after `module deploy signal` imported LXC 207 and re-planned. Abridged to the
7
+ * lines the parser reads, and kept verbatim otherwise: a fixture written from
8
+ * memory of the format would pass while the product still destroyed the box.
9
+ */
10
+ const POST_IMPORT_REPLACEMENT_PLAN = `
11
+ proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
12
+
13
+ Terraform used the selected providers to generate the following execution
14
+ plan. Resource actions are indicated with the following symbols:
15
+ -/+ destroy and then create replacement
16
+
17
+ Terraform will perform the following actions:
18
+
19
+ # proxmox_lxc.signal must be replaced
20
+ -/+ resource "proxmox_lxc" "signal" {
21
+ + ostemplate = "datacenter:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst" # forces replacement
22
+ ~ rootfs {
23
+ + storage = "datacenter" # forces replacement
24
+ }
25
+ }
26
+
27
+ Plan: 1 to add, 0 to change, 1 to destroy.
28
+ `;
29
+
30
+ const CLEAN_PLAN = `
31
+ proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
32
+
33
+ No changes. Your infrastructure matches the configuration.
34
+ `;
35
+
36
+ const IN_PLACE_UPDATE_PLAN = `
37
+ # proxmox_lxc.signal will be updated in-place
38
+ ~ resource "proxmox_lxc" "signal" {
39
+ ~ memory = 1024 -> 2048
40
+ }
41
+
42
+ Plan: 0 to add, 1 to change, 0 to destroy.
43
+ `;
44
+
45
+ describe('planAfterImportVerdict', () => {
46
+ // THE recurrence gate for celilo#1374. This exact plan was applied without
47
+ // validation and destroyed signal's container on a converged fleet. If this
48
+ // test ever passes the plan through, the defect is back.
49
+ test('refuses the replacement plan an import produces', () => {
50
+ const verdict = planAfterImportVerdict(2, POST_IMPORT_REPLACEMENT_PLAN);
51
+
52
+ expect(verdict.action).toBe('refuse');
53
+ if (verdict.action !== 'refuse') throw new Error('unreachable');
54
+ expect(verdict.error).toContain('after importing an existing resource');
55
+ });
56
+
57
+ // The outcome the recovery path is FOR: the import reconciled state with
58
+ // reality and there is nothing left to apply. Adopt, don't apply.
59
+ test('applies nothing when the post-import plan is clean', () => {
60
+ expect(planAfterImportVerdict(0, CLEAN_PLAN)).toEqual({ action: 'done' });
61
+ });
62
+
63
+ // A resize must still go through — refusing everything would be a different
64
+ // bug wearing the same fix (terraform-safety.ts rule 2, ISS-0055).
65
+ test('lets an in-place update through', () => {
66
+ expect(planAfterImportVerdict(2, IN_PLACE_UPDATE_PLAN)).toEqual({ action: 'apply' });
67
+ });
68
+
69
+ // An unreadable plan is not evidence of safety (D7).
70
+ test('refuses when the plan itself failed', () => {
71
+ const verdict = planAfterImportVerdict(1, 'Error: failed to refresh state');
72
+
73
+ expect(verdict.action).toBe('refuse');
74
+ });
75
+ });
76
+
77
+ describe('validateTerraformPlanSafety', () => {
78
+ // Characterises what was already true: the validator would have caught this.
79
+ // The defect was never a missing rule, it was a plan that never reached one.
80
+ test('already refuses a replacement — it was simply never asked', () => {
81
+ expect(validateTerraformPlanSafety(POST_IMPORT_REPLACEMENT_PLAN).safe).toBe(false);
82
+ });
83
+ });