@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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Capability compatibility between a module's declared requirements and the
3
+ * providers actually deployed in the fleet.
4
+ *
5
+ * The pure half mirrors the runtime lookup's semantics (see
6
+ * `deploy-preflight.ts` section 2): a requirement is served when ANY deployed
7
+ * provider satisfies it under `compareConsumerToProvider`. The reason
8
+ * `deploy-preflight` and the upgrade path must agree is the failure
9
+ * celilo#1361 recorded: celilo-website was upgraded against a caddy whose
10
+ * bundled provider rejected its publish calls, and every attempt burned a
11
+ * release. The upgrade path now asks this question BEFORE updating, and
12
+ * defers instead of deploying into a wall.
13
+ */
14
+
15
+ import { compareConsumerToProvider } from '@celilo/capabilities';
16
+ import type { DbClient } from '../db/client';
17
+ import { capabilities } from '../db/schema';
18
+ import type { ModuleManifest } from '../manifest/schema';
19
+ import { isPrivilegedCapability } from '../manifest/validate';
20
+
21
+ export interface CapabilityRequirement {
22
+ name: string;
23
+ version: string;
24
+ }
25
+
26
+ export interface DeployedCapabilityProvider {
27
+ capabilityName: string;
28
+ moduleId: string;
29
+ version: string;
30
+ }
31
+
32
+ export interface UnservedCapability {
33
+ capability: string;
34
+ /** The version the module's manifest requires. */
35
+ required: string;
36
+ /** Example provider that does not serve it (the first mismatch found). */
37
+ providerModuleId: string | null;
38
+ providerVersion: string | null;
39
+ reason:
40
+ | 'no_provider'
41
+ | 'major_mismatch_higher'
42
+ | 'major_mismatch_lower'
43
+ | 'minor_mismatch_higher'
44
+ | 'caller_minor_too_old';
45
+ message: string;
46
+ }
47
+
48
+ /**
49
+ * Which of a module's capability requirements no deployed provider serves.
50
+ * Pure (Rule 10): the I/O — reading the capabilities table — happens in
51
+ * `capabilityBlockersForManifest`.
52
+ */
53
+ export function unservedCapabilityRequirements(
54
+ requirements: CapabilityRequirement[] | undefined,
55
+ providers: DeployedCapabilityProvider[],
56
+ ): UnservedCapability[] {
57
+ const unserved: UnservedCapability[] = [];
58
+ for (const cap of requirements ?? []) {
59
+ // Framework-granted privileges (e.g. cross_module_read) are not
60
+ // provider-backed — the same skip `deploy-preflight` makes.
61
+ if (isPrivilegedCapability(cap.name)) continue;
62
+
63
+ const installed = providers.filter((p) => p.capabilityName === cap.name);
64
+ if (installed.length === 0) {
65
+ unserved.push({
66
+ capability: cap.name,
67
+ required: cap.version,
68
+ providerModuleId: null,
69
+ providerVersion: null,
70
+ reason: 'no_provider',
71
+ message: `no deployed provider for '${cap.name}'`,
72
+ });
73
+ continue;
74
+ }
75
+ let anyCompatible = false;
76
+ let example: DeployedCapabilityProvider | undefined;
77
+ let reason: UnservedCapability['reason'] = 'major_mismatch_higher';
78
+ for (const p of installed) {
79
+ const result = compareConsumerToProvider(cap.version, p.version);
80
+ if (result.compatible) {
81
+ anyCompatible = true;
82
+ break;
83
+ }
84
+ if (!example) {
85
+ example = p;
86
+ reason = result.reason;
87
+ }
88
+ }
89
+ if (anyCompatible || !example) continue;
90
+ unserved.push({
91
+ capability: cap.name,
92
+ required: cap.version,
93
+ providerModuleId: example.moduleId,
94
+ providerVersion: example.version,
95
+ reason,
96
+ message: `requires ${cap.name}@${cap.version} but deployed provider '${example.moduleId}' provides ${cap.name}@${example.version} (${reason})`,
97
+ });
98
+ }
99
+ return unserved;
100
+ }
101
+
102
+ /** Every capability provider currently registered in the fleet. */
103
+ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[] {
104
+ return db
105
+ .select({
106
+ capabilityName: capabilities.capabilityName,
107
+ moduleId: capabilities.moduleId,
108
+ version: capabilities.version,
109
+ })
110
+ .from(capabilities)
111
+ .all();
112
+ }
113
+
114
+ /**
115
+ * The upgrade-path entry point: which of a target manifest's capability
116
+ * requirements the deployed fleet cannot serve. A non-empty result means the
117
+ * upgrade would deploy into a wall the preflight would then reject — defer
118
+ * instead of burning the attempt.
119
+ */
120
+ export function capabilityBlockersForManifest(
121
+ db: DbClient,
122
+ manifest: Pick<ModuleManifest, 'requires' | 'id'>,
123
+ ): UnservedCapability[] {
124
+ return unservedCapabilityRequirements(
125
+ manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
126
+ readDeployedProviders(db),
127
+ );
128
+ }
@@ -29,7 +29,7 @@ describe('celilo lifecycle events', () => {
29
29
  process.env.EVENT_BUS_DB = dbPath;
30
30
  });
31
31
  afterEach(() => {
32
- process.env.EVENT_BUS_DB = undefined;
32
+ delete process.env.EVENT_BUS_DB;
33
33
  try {
34
34
  rmSync(dir, { recursive: true, force: true });
35
35
  } catch {
@@ -11,6 +11,14 @@ import type { HookContext } from '@celilo/capabilities';
11
11
  // runnable from the apps/celilo working dir.
12
12
  const HOOK_DIR = join(import.meta.dirname, '../../../../modules/celilo-mgmt/scripts');
13
13
 
14
+ // on_backup reads its machine-pool snapshot from the context input the
15
+ // framework stages (backup-create.ts, from listMachines()). These tests
16
+ // inject a JSON string so the snapshot is deterministic on every host. The
17
+ // pre-staging version of this hook shelled `celilo machine list --json` and
18
+ // these tests had to inject a runner stub to avoid depending on the installed
19
+ // CLI's age (celilo#1333); the staged input made that seam unnecessary.
20
+ const STUB_MACHINE_POOL = [{ hostname: 'stub-machine', zone: 'dmz' }];
21
+
14
22
  interface BackupHookOutput {
15
23
  artifact_count: number;
16
24
  size_bytes: number;
@@ -116,6 +124,7 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
116
124
  backup_dir: backupDir,
117
125
  cross_module_root: crossModuleRoot,
118
126
  system_state_root: systemStateRoot,
127
+ machine_pool: JSON.stringify(STUB_MACHINE_POOL),
119
128
  }),
120
129
  )) as BackupHookOutput;
121
130
 
@@ -135,7 +144,6 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
135
144
  expect(result.artifact_count).toBeGreaterThan(0);
136
145
  expect(result.size_bytes).toBeGreaterThan(0);
137
146
  });
138
-
139
147
  it('refuses when the framework did not stage celilo state', async () => {
140
148
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
141
149
 
@@ -148,24 +156,34 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
148
156
  ).rejects.toThrow(/system_state_root was not provided/);
149
157
  });
150
158
 
151
- it('machine-pool.json is valid JSON (array)', async () => {
159
+ it('machine-pool.json is the staged snapshot (valid JSON array)', async () => {
152
160
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
153
161
  await hook(
154
162
  buildContext({
155
163
  backup_dir: backupDir,
156
164
  cross_module_root: crossModuleRoot,
157
165
  system_state_root: systemStateRoot,
166
+ machine_pool: JSON.stringify(STUB_MACHINE_POOL),
158
167
  }),
159
168
  );
160
169
 
161
- const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
162
- expect(Array.isArray(machinePool)).toBe(true);
170
+ // Content, not just shape. A bare Array.isArray also passes when the
171
+ // hook swallowed a snapshot failure and wrote an empty pool, which is
172
+ // exactly the failure mode this stub exists to rule out.
173
+ const machinePool: unknown = JSON.parse(
174
+ readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'),
175
+ );
176
+ expect(machinePool).toEqual(STUB_MACHINE_POOL);
163
177
  });
164
178
 
165
179
  it('proceeds (with a warning) when cross_module_root is missing', async () => {
166
180
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
167
181
  const result = (await hook(
168
- buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot }),
182
+ buildContext({
183
+ backup_dir: backupDir,
184
+ system_state_root: systemStateRoot,
185
+ machine_pool: JSON.stringify(STUB_MACHINE_POOL),
186
+ }),
169
187
  )) as BackupHookOutput;
170
188
 
171
189
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
@@ -55,7 +55,7 @@ describe('container-service', () => {
55
55
 
56
56
  // Clear environment variables
57
57
  resetTestDbPath();
58
- process.env.CELILO_MASTER_KEY_PATH = undefined;
58
+ delete process.env.CELILO_MASTER_KEY_PATH;
59
59
  });
60
60
 
61
61
  describe('addContainerService', () => {
@@ -72,7 +72,7 @@ describe('materializeCrossModuleRoot', () => {
72
72
  afterEach(() => {
73
73
  closeDb();
74
74
  resetTestDbPath();
75
- process.env.CELILO_DATA_DIR = undefined;
75
+ delete process.env.CELILO_DATA_DIR;
76
76
  try {
77
77
  rmSync(dir, { recursive: true, force: true });
78
78
  } catch {
@@ -180,7 +180,7 @@ describe('applyCrossModuleWriteRoot', () => {
180
180
  });
181
181
 
182
182
  afterEach(() => {
183
- process.env.CELILO_DATA_DIR = undefined;
183
+ delete process.env.CELILO_DATA_DIR;
184
184
  try {
185
185
  rmSync(dir, { recursive: true, force: true });
186
186
  } catch {
@@ -27,6 +27,7 @@ import type { DbClient } from '../db/client';
27
27
  import { getDb } from '../db/client';
28
28
  import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
29
29
  import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
30
+ import { isPrivilegedCapability } from '../manifest/validate';
30
31
  import { buildResolutionContext } from '../variables/context';
31
32
  import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
32
33
  import { describeCapabilityProblem, findBrokenCapabilityDerivations } from './fleet-checks';
@@ -113,6 +114,12 @@ export async function runPreflight(
113
114
  // template-generation against an ABI the provider can't fulfil.
114
115
  if (manifest.requires?.capabilities) {
115
116
  for (const cap of manifest.requires.capabilities) {
117
+ // Framework-granted privileges (e.g. cross_module_read) aren't
118
+ // provider-backed — they're gated by the allow-list at import time,
119
+ // not satisfied by deploying another module. Skip them here.
120
+ if (isPrivilegedCapability(cap.name)) {
121
+ continue;
122
+ }
116
123
  const providers = db
117
124
  .select()
118
125
  .from(capabilities)
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { log } from '../cli/prompts';
4
4
  import { executeBuildWithProgress } from './build-stream';
5
- import { validateTerraformPlanSafety } from './terraform-safety';
5
+ import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
6
6
 
7
7
  export interface TerraformResult {
8
8
  success: boolean;
@@ -173,6 +173,43 @@ export async function executeTerraform(
173
173
 
174
174
  if (importResult.success) {
175
175
  log.success('Resource imported into Terraform state');
176
+
177
+ // The import REPLACED the plan validated above. That plan described a
178
+ // create; the plan terraform computes now can describe a REPLACE,
179
+ // because `ostemplate` and `rootfs[0].storage` do not round-trip
180
+ // through an import and each forces one. Re-plan and re-check, or the
181
+ // retry applies a destroy the safety gate exists to forbid — which is
182
+ // how a redeploy destroyed signal's LXC on a converged fleet
183
+ // (celilo#1374).
184
+ const rePlan = await executeBuildWithProgress({
185
+ command: 'terraform',
186
+ args: ['plan', '-detailed-exitcode', '-no-color'],
187
+ cwd: terraformDir,
188
+ title: 'Re-planning after import',
189
+ env: terraformEnv,
190
+ noInteractive,
191
+ });
192
+
193
+ const verdict = planAfterImportVerdict(rePlan.exitCode, rePlan.output);
194
+
195
+ if (verdict.action === 'done') {
196
+ // The import alone reconciled state with reality: adopt and stop.
197
+ if (phases) phases.terraformApply = true;
198
+ log.success('Infrastructure adopted by import (no changes needed)');
199
+ return { success: true, output: rePlan.output, exitCode: 0 };
200
+ }
201
+
202
+ if (verdict.action === 'refuse') {
203
+ if (phases) phases.terraformApply = false;
204
+ log.error('Refusing to apply the plan computed after import');
205
+ return {
206
+ success: false,
207
+ output: rePlan.output,
208
+ error: verdict.error,
209
+ exitCode: 2,
210
+ };
211
+ }
212
+
176
213
  log.message(' Retrying deployment...');
177
214
 
178
215
  // Retry apply after successful import
@@ -242,7 +242,7 @@ export function upsertDeployedSystem(
242
242
  .run();
243
243
  }
244
244
 
245
- function asZone(value: string | null | undefined): NetworkZone | null {
245
+ export function asZone(value: string | null | undefined): NetworkZone | null {
246
246
  return value && (NETWORK_ZONES as readonly string[]).includes(value)
247
247
  ? (value as NetworkZone)
248
248
  : null;
@@ -10,6 +10,7 @@ import {
10
10
  readInstalledUnit,
11
11
  renderLaunchdPlist,
12
12
  renderSystemdUnit,
13
+ resolveDaemonPathEnv,
13
14
  resolveRestartScope,
14
15
  resolveRunAsUser,
15
16
  restartDaemon,
@@ -27,11 +28,15 @@ describe('renderSystemdUnit', () => {
27
28
  concurrency: 4,
28
29
  home: '/home/op',
29
30
  scope: 'user',
31
+ pathEnv: '/bun/bin:/usr/bin:/bin',
30
32
  });
31
33
  expect(out).toContain(
32
34
  'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
33
35
  );
34
36
  expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
37
+ // celilo#1373 — the unit's environment must resolve bun; the global celilo
38
+ // wrapper is a bash script whose first act is `command -v bun`.
39
+ expect(out).toContain('Environment=PATH=/bun/bin:/usr/bin:/bin');
35
40
  expect(out).toContain('Restart=on-failure');
36
41
  expect(out).toContain('WantedBy=default.target');
37
42
  expect(out).not.toContain('User=');
@@ -45,6 +50,7 @@ describe('renderSystemdUnit', () => {
45
50
  concurrency: 8,
46
51
  home: '/h',
47
52
  scope: 'user',
53
+ pathEnv: '/bun/bin:/usr/bin',
48
54
  });
49
55
  expect(out).toContain('--poll-ms 250 --concurrency 8');
50
56
  });
@@ -57,6 +63,7 @@ describe('renderSystemdUnit', () => {
57
63
  concurrency: 4,
58
64
  home: '/root',
59
65
  scope: 'system',
66
+ pathEnv: '/bun/bin:/usr/bin',
60
67
  runAsUser: 'celilo',
61
68
  });
62
69
  expect(out).toContain('User=celilo');
@@ -75,6 +82,7 @@ describe('renderLaunchdPlist', () => {
75
82
  concurrency: 4,
76
83
  home: '/Users/op',
77
84
  scope: 'user',
85
+ pathEnv: '/bun/bin:/usr/bin:/bin',
78
86
  });
79
87
  expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
80
88
  expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
@@ -85,6 +93,29 @@ describe('renderLaunchdPlist', () => {
85
93
  expect(out).not.toContain('<key>UserName</key>');
86
94
  });
87
95
 
96
+ // celilo#1373 — launchd starts jobs with its own default PATH (no bun),
97
+ // and the global celilo wrapper is a bash script whose first act is
98
+ // `command -v bun`. The rendered plist MUST carry a PATH that resolves
99
+ // bun, inside EnvironmentVariables, or the unit crash-loops forever.
100
+ it('carries a PATH in EnvironmentVariables that resolves bun', () => {
101
+ const out = renderLaunchdPlist({
102
+ celiloPath: '/Users/op/.bun/bin/celilo',
103
+ busDbPath: '/db',
104
+ pollMs: 1000,
105
+ concurrency: 4,
106
+ home: '/Users/op',
107
+ scope: 'user',
108
+ pathEnv: '/bun/bin:/usr/local/bin:/usr/bin:/bin',
109
+ });
110
+ expect(out).toContain('<key>PATH</key>');
111
+ expect(out).toContain('<string>/bun/bin:/usr/local/bin:/usr/bin:/bin</string>');
112
+ // PATH lives INSIDE the EnvironmentVariables dict, after EVENT_BUS_DB.
113
+ const envIdx = out.indexOf('<key>EnvironmentVariables</key>');
114
+ const pathIdx = out.indexOf('<key>PATH</key>');
115
+ expect(envIdx).toBeGreaterThan(-1);
116
+ expect(pathIdx).toBeGreaterThan(envIdx);
117
+ });
118
+
88
119
  it('system scope sets UserName and logs under /Library/Logs', () => {
89
120
  const out = renderLaunchdPlist({
90
121
  celiloPath: '/c',
@@ -93,6 +124,7 @@ describe('renderLaunchdPlist', () => {
93
124
  concurrency: 4,
94
125
  home: '/Users/op',
95
126
  scope: 'system',
127
+ pathEnv: '/bun/bin:/usr/bin',
96
128
  runAsUser: 'celilo',
97
129
  });
98
130
  expect(out).toContain('<key>UserName</key>');
@@ -103,6 +135,31 @@ describe('renderLaunchdPlist', () => {
103
135
  });
104
136
  });
105
137
 
138
+ describe('resolveDaemonPathEnv', () => {
139
+ // celilo#1373 — the whole point of the PATH key is that the bun the
140
+ // wrapper needs is actually in the directory we wrote. First colon
141
+ // segment, and a bun executable must exist in it.
142
+ it('starts with a directory that contains a bun executable', () => {
143
+ const firstDir = resolveDaemonPathEnv().split(':')[0] ?? '';
144
+ expect(firstDir).not.toBe('');
145
+ expect(existsSync(join(firstDir, 'bun'))).toBe(true);
146
+ });
147
+
148
+ it('planDaemonInstall pins the PATH into the rendered unit', () => {
149
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-pathenv-'));
150
+ const celiloPath = join(dir, 'fake-celilo');
151
+ writeFileSync(celiloPath, '#!/bin/sh\n', { mode: 0o755 });
152
+ const plan = planDaemonInstall({
153
+ platform: 'darwin',
154
+ scope: 'user',
155
+ home: join(dir, 'home'),
156
+ celiloPath,
157
+ pathEnv: '/bun/bin:/usr/bin:/bin',
158
+ });
159
+ expect(plan.unitContent).toContain('<string>/bun/bin:/usr/bin:/bin</string>');
160
+ });
161
+ });
162
+
106
163
  describe('getDaemonUnitPath', () => {
107
164
  it('returns the systemd user path on linux', () => {
108
165
  expect(getDaemonUnitPath('linux', '/home/op')).toBe(
@@ -48,6 +48,12 @@ export interface InstallDaemonOptions {
48
48
  platform?: SupervisorPlatform;
49
49
  /** Override the bus DB path. Defaults to celilo's getEventBusPath(). */
50
50
  busDbPath?: string;
51
+ /**
52
+ * Override the PATH written into the unit's environment. Defaults to
53
+ * resolveDaemonPathEnv() captured at render time. Test seam — pin it so
54
+ * assertions don't depend on the machine's bun install.
55
+ */
56
+ pathEnv?: string;
51
57
  /** Override the home directory used to compute install paths. */
52
58
  home?: string;
53
59
  /** Prefix for system-scope paths. Test seam — see getDaemonUnitPath. */
@@ -159,6 +165,47 @@ export function getDaemonUnitPath(
159
165
  : join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
160
166
  }
161
167
 
168
+ export interface LaunchdUnitStatus {
169
+ /** The pid launchd currently runs the unit under, or null when it isn't running. */
170
+ pid: number | null;
171
+ /**
172
+ * The unit's last exit status in wait4 form (256 = exited 1, 15 = killed by
173
+ * SIGTERM), or null when launchd has no record of one.
174
+ */
175
+ lastExitStatus: number | null;
176
+ }
177
+
178
+ /**
179
+ * Probe launchd for the unit's health. Uses `launchctl list <label>` —
180
+ * on current macOS the richer `launchctl print gui/<uid>/<label>` answers
181
+ * "Domain does not support specified action" for legacy-loaded jobs, while
182
+ * `list <label>` prints a legacy-style dict carrying `PID` (absent when not
183
+ * running) and `LastExitStatus`.
184
+ *
185
+ * Returns null when launchd doesn't answer at all (not loaded, linux, the
186
+ * binary missing) — null is "don't know", never "bad". Only the user scope
187
+ * is probed: the system domain needs root, and a mac running a system-scope
188
+ * celilo daemon is not a supported shape (see supervisorCommands).
189
+ */
190
+ export function launchdUnitStatus(scope: SupervisorScope = 'user'): LaunchdUnitStatus | null {
191
+ if (scope !== 'user' || detectPlatform() !== 'darwin') return null;
192
+ let out: string;
193
+ try {
194
+ out = execFileSync('launchctl', ['list', LAUNCHD_LABEL], {
195
+ encoding: 'utf-8',
196
+ stdio: ['ignore', 'pipe', 'ignore'],
197
+ });
198
+ } catch {
199
+ return null;
200
+ }
201
+ const pidMatch = out.match(/"PID" = (\d+)/);
202
+ const exitMatch = out.match(/"LastExitStatus" = (-?\d+)/);
203
+ return {
204
+ pid: pidMatch ? Number(pidMatch[1]) : null,
205
+ lastExitStatus: exitMatch ? Number(exitMatch[1]) : null,
206
+ };
207
+ }
208
+
162
209
  /**
163
210
  * Find the celilo executable. Priority:
164
211
  * 1. Explicit override
@@ -185,6 +232,25 @@ export function resolveCeliloPath(override?: string): string {
185
232
  return found;
186
233
  }
187
234
 
235
+ /**
236
+ * A PATH that resolves bun, for a supervisor unit's environment.
237
+ *
238
+ * launchd starts jobs with its own default PATH, and systemd runs
239
+ * ExecStart with a similarly minimal one. Neither includes bun, and the
240
+ * global `celilo` entry is a bash wrapper whose first act is
241
+ * `command -v bun` — so a unit without a PATH key crash-loops forever on
242
+ * "Error: celilo requires bun" (celilo#1373: 22680 respawns on one Mac).
243
+ * The PATH is captured at RENDER time — the moment the unit is written —
244
+ * from Bun.which('bun'), falling back to process.execPath, which IS the
245
+ * bun binary under the bun runtime. Standard dirs follow so the
246
+ * dispatcher's own subprocesses keep working.
247
+ */
248
+ export function resolveDaemonPathEnv(): string {
249
+ const bun =
250
+ (Bun as unknown as { which: (cmd: string) => string | null }).which('bun') ?? process.execPath;
251
+ return `${dirname(bun)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
252
+ }
253
+
188
254
  /**
189
255
  * Run-as user for a system-scope unit: the owner of the celilo state
190
256
  * dir (the bus DB's directory). Falls back to the current user when
@@ -211,6 +277,11 @@ interface UnitInputs {
211
277
  concurrency: number;
212
278
  home: string;
213
279
  scope: SupervisorScope;
280
+ /**
281
+ * PATH injected into the unit's environment (celilo#1373). Required:
282
+ * a unit without it cannot resolve bun and crash-loops.
283
+ */
284
+ pathEnv: string;
214
285
  /** Required for system scope; ignored for user scope. */
215
286
  runAsUser?: string;
216
287
  }
@@ -232,6 +303,7 @@ ${userLine}ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --
232
303
  Restart=on-failure
233
304
  RestartSec=10s
234
305
  Environment=EVENT_BUS_DB=${input.busDbPath}
306
+ Environment=PATH=${input.pathEnv}
235
307
  # stdout/stderr are captured by ${journalHint}.
236
308
  StandardOutput=journal
237
309
  StandardError=journal
@@ -271,6 +343,8 @@ ${userNameBlock} <key>ProgramArguments</key>
271
343
  <dict>
272
344
  <key>EVENT_BUS_DB</key>
273
345
  <string>${input.busDbPath}</string>
346
+ <key>PATH</key>
347
+ <string>${input.pathEnv}</string>
274
348
  </dict>
275
349
  <key>RunAtLoad</key>
276
350
  <true/>
@@ -334,6 +408,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
334
408
  const home = opts.home ?? homedir();
335
409
  const celiloPath = resolveCeliloPath(opts.celiloPath);
336
410
  const busDbPath = opts.busDbPath ?? getEventBusPath();
411
+ const pathEnv = opts.pathEnv ?? resolveDaemonPathEnv();
337
412
  const pollMs = opts.pollMs ?? 1000;
338
413
  const concurrency = opts.concurrency ?? 4;
339
414
  const runAsUser = scope === 'system' ? resolveRunAsUser(busDbPath, opts.runAsUser) : undefined;
@@ -346,6 +421,7 @@ export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemo
346
421
  concurrency,
347
422
  home,
348
423
  scope,
424
+ pathEnv,
349
425
  runAsUser,
350
426
  };
351
427
  const unitContent =
@@ -57,27 +57,40 @@ export function loadZoneSubnets(db: DbClient): string[] {
57
57
 
58
58
  /**
59
59
  * Read each firewall's live ruleset and pair it with celilo's composed trusted
60
- * set. A firewall that can't be reached contributes nothing rather than a false
61
- * "no unowned reach" — an unreadable box is unknown, not clean.
60
+ * set. Firewalls whose ruleset cannot be read are reported separately, as
61
+ * `unreachable` — the audit turns each into an `unmeasured` finding, because
62
+ * an unreadable box is unknown, not clean (D7).
62
63
  */
63
- export function collectFirewallReach(db: DbClient, run: Runner = execRunner): FirewallReachState[] {
64
+ export interface FirewallReachCollection {
65
+ firewalls: FirewallReachState[];
66
+ unreachableFirewalls: string[];
67
+ }
68
+
69
+ export function collectFirewallReach(
70
+ db: DbClient,
71
+ run: Runner = execRunner,
72
+ ): FirewallReachCollection {
64
73
  const zoneSubnets = loadZoneSubnets(db);
65
- if (zoneSubnets.length === 0) return [];
74
+ if (zoneSubnets.length === 0) return { firewalls: [], unreachableFirewalls: [] };
66
75
 
67
- const states: FirewallReachState[] = [];
76
+ const firewalls: FirewallReachState[] = [];
77
+ const unreachableFirewalls: string[] = [];
68
78
  for (const firewallIp of listFirewallIps(db)) {
69
79
  // escape-hatch: reads the running filter table (`iptables-save`), which is
70
80
  // the only place an out-of-band rule appears. Read-only, no capability.
71
81
  const result = runAppCommand({ ipv4_address: firewallIp }, 'iptables-save -t filter', run, {
72
82
  timeoutMs: IPTABLES_SAVE_TIMEOUT_MS,
73
83
  });
74
- if (!result.ok) continue;
84
+ if (!result.ok) {
85
+ unreachableFirewalls.push(firewallIp);
86
+ continue;
87
+ }
75
88
 
76
- states.push({
89
+ firewalls.push({
77
90
  firewallIp,
78
91
  live: parseLiveReachRules(result.stdout, zoneSubnets),
79
92
  known: loadTrustedSubnets(db, firewallIp).map((e) => e.subnet),
80
93
  });
81
94
  }
82
- return states;
95
+ return { firewalls, unreachableFirewalls };
83
96
  }