@celilo/cli 4.0.0 → 4.1.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 (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +1 -0
  2. package/package.json +5 -4
  3. package/src/cli/command-tree-parser.test.ts +18 -0
  4. package/src/cli/command-tree-parser.ts +12 -0
  5. package/src/cli/commands/api.ts +67 -14
  6. package/src/cli/commands/events.ts +5 -2
  7. package/src/cli/commands/machine-reclassify.ts +79 -0
  8. package/src/cli/commands/system-audit.ts +22 -0
  9. package/src/cli/commands/system-reboot.ts +126 -0
  10. package/src/cli/commands/system-update.ts +5 -0
  11. package/src/cli/completion.ts +3 -2
  12. package/src/cli/index.ts +19 -0
  13. package/src/cli/tui/audit-state.ts +4 -0
  14. package/src/hooks/broker.ts +31 -0
  15. package/src/hooks/capability-loader.ts +8 -2
  16. package/src/hooks/executor.ts +20 -2
  17. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  18. package/src/hooks/timeout-names-provider.test.ts +87 -0
  19. package/src/services/api-access.test.ts +44 -0
  20. package/src/services/audit/index.test.ts +7 -0
  21. package/src/services/audit/index.ts +15 -0
  22. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  23. package/src/services/audit/machine-interface-zones.ts +43 -0
  24. package/src/services/audit/reboot-pending.test.ts +63 -0
  25. package/src/services/audit/reboot-pending.ts +85 -0
  26. package/src/services/audit/recurrence-gate.test.ts +21 -0
  27. package/src/services/audit/server-bun-pin.test.ts +44 -0
  28. package/src/services/audit/server-bun-pin.ts +95 -0
  29. package/src/services/audit/types.ts +2 -0
  30. package/src/services/capability-compat.test.ts +22 -0
  31. package/src/services/capability-compat.ts +15 -2
  32. package/src/services/celilo-events.test.ts +39 -0
  33. package/src/services/celilo-events.ts +41 -4
  34. package/src/services/deploy-preflight.ts +12 -3
  35. package/src/services/deploy-validation.test.ts +81 -2
  36. package/src/services/deploy-validation.ts +19 -0
  37. package/src/services/fleet-checks.ts +40 -10
  38. package/src/services/machine-interface-zones.test.ts +73 -0
  39. package/src/services/machine-interface-zones.ts +101 -0
  40. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  41. package/src/services/module-deploy.ts +31 -10
  42. package/src/services/provider-arrival.test.ts +62 -46
  43. package/src/services/provider-arrival.ts +41 -10
  44. package/src/services/system-reboot.test.ts +231 -0
  45. package/src/services/system-reboot.ts +357 -0
  46. package/src/services/update/orchestrator.test.ts +164 -0
  47. package/src/services/update/orchestrator.ts +43 -12
  48. package/src/services/update/types.ts +10 -1
  49. package/src/templates/generator.test.ts +15 -4
  50. package/src/templates/generator.ts +12 -4
  51. package/src/variables/context.ts +14 -1
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { auditServerBunPin, readPinnedBunVersion } from './server-bun-pin';
6
+
7
+ const PIN = '/etc/celilo/mise.pin.toml';
8
+
9
+ describe('server bun pin audit', () => {
10
+ test('silent when the running bun is the pinned bun', () => {
11
+ expect(
12
+ auditServerBunPin({ pinnedVersion: '1.4.2', runningVersion: '1.4.2', pinPath: PIN }),
13
+ ).toEqual([]);
14
+ });
15
+
16
+ test('reports drift naming both versions — the celilo#1379 shape', () => {
17
+ const [finding] = auditServerBunPin({
18
+ pinnedVersion: '1.4.2',
19
+ runningVersion: '1.3.3',
20
+ pinPath: PIN,
21
+ });
22
+ expect(finding?.code).toBe('server_bun_pin_drift');
23
+ expect(finding?.severity).toBe('drift');
24
+ expect(finding?.message).toContain('1.3.3');
25
+ expect(finding?.message).toContain('1.4.2');
26
+ });
27
+
28
+ test('an unreadable pin is unmeasured, never green', () => {
29
+ const [finding] = auditServerBunPin({
30
+ pinnedVersion: null,
31
+ runningVersion: '1.4.2',
32
+ pinPath: PIN,
33
+ });
34
+ expect(finding?.severity).toBe('unmeasured');
35
+ });
36
+
37
+ test('reads the bun line out of the shipped pin file', () => {
38
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-pin-'));
39
+ const path = join(dir, 'mise.pin.toml');
40
+ writeFileSync(path, '# comment\n[tools]\nbun = "1.4.2"\nterraform = "1.10.0"\n');
41
+ expect(readPinnedBunVersion(path)).toBe('1.4.2');
42
+ expect(readPinnedBunVersion(join(dir, 'absent.toml'))).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Server bun-pin drift (celilo#1379).
3
+ *
4
+ * The deb pins the bun celilo was built and tested against, and the CLI runs
5
+ * on whatever bun mise resolves at /var/celilo. Those can disagree: for three
6
+ * months celilo-mgr ran on bun 1.3.3 while the shipped pin said 1.4.2,
7
+ * because the postinst seeded the pin file once and an upgrade never
8
+ * rewrote it. 1.3.3's `Bun.YAML.parse` reads a bare `on:` key as `true`, so
9
+ * production was running a parser no gate had ever exercised.
10
+ *
11
+ * The postinst now rewrites the pin on every configure, which closes the way
12
+ * that drift arose. This check is what makes the drift VISIBLE if it arises
13
+ * some other way — an operator's `mise use`, a hand-edited config, a
14
+ * dispatcher still holding an old runtime.
15
+ *
16
+ * It compares the pin the deb shipped against the bun THIS process is
17
+ * running on. Not `mise which bun`, which answers what mise would resolve
18
+ * now rather than what is actually executing (measured 2026-09-10: mise
19
+ * named 1.4.2 and ran 1.3.3 on the same box).
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import type { DriftFinding } from './types';
24
+
25
+ export interface ServerBunPinAuditDeps {
26
+ /** The bun version the installed deb pins, or `null` when unreadable. */
27
+ pinnedVersion: string | null;
28
+ /** The bun this process is running on, or `null` when not under bun. */
29
+ runningVersion: string | null;
30
+ /** Where `pinnedVersion` was read from — named in the finding. */
31
+ pinPath: string;
32
+ }
33
+
34
+ export function auditServerBunPin(deps: ServerBunPinAuditDeps): DriftFinding[] {
35
+ const { pinnedVersion, runningVersion, pinPath } = deps;
36
+
37
+ if (pinnedVersion === null || runningVersion === null) {
38
+ return [
39
+ {
40
+ category: 'server_bun_pin',
41
+ severity: 'unmeasured',
42
+ code: 'server_bun_pin_unmeasured',
43
+ subject: 'system',
44
+ message:
45
+ pinnedVersion === null
46
+ ? `No bun pin could be read from ${pinPath}, so the running bun was compared against nothing.`
47
+ : 'This process is not running under bun, so the running bun version is unknown.',
48
+ remediation:
49
+ pinnedVersion === null
50
+ ? `Confirm the celilo deb is installed and ${pinPath} exists (\`sudo apt install --reinstall celilo\`).`
51
+ : 'Run `celilo system audit` from the deb-installed celilo (/usr/local/bin/celilo).',
52
+ actionable: false,
53
+ },
54
+ ];
55
+ }
56
+
57
+ if (pinnedVersion === runningVersion) return [];
58
+
59
+ return [
60
+ {
61
+ category: 'server_bun_pin',
62
+ severity: 'drift',
63
+ code: 'server_bun_pin_drift',
64
+ subject: 'system',
65
+ message: `celilo is running on bun ${runningVersion}, but the installed deb pins bun ${pinnedVersion} (${pinPath}).`,
66
+ details:
67
+ 'celilo is tested against the pinned bun only. A different runtime is not a version-number mismatch — bun 1.3.3 and 1.4.2 parse the same YAML differently, which is how a deploy shipped on an untested parser (celilo#1379).',
68
+ remediation:
69
+ 'Reconcile the runtime: `sudo apt install --reinstall celilo` rewrites /var/celilo/mise.local.toml from the deb pin, then `celilo events restart-daemon` puts the dispatcher on it.',
70
+ actionable: false,
71
+ },
72
+ ];
73
+ }
74
+
75
+ /** The pin the deb ships. Read at audit time — never a copy in celilo's code. */
76
+ export const DEB_PIN_PATH = '/etc/celilo/mise.pin.toml';
77
+
78
+ export function readPinnedBunVersion(path: string = DEB_PIN_PATH): string | null {
79
+ try {
80
+ // One `bun = "x.y.z"` line under [tools]. A TOML parser for one key would
81
+ // be a dependency to read a file this file's own format gate keeps simple.
82
+ const match = readFileSync(path, 'utf-8').match(/^\s*bun\s*=\s*"([^"]+)"/m);
83
+ return match?.[1] ?? null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function collectServerBunPinDeps(pinPath: string = DEB_PIN_PATH): ServerBunPinAuditDeps {
90
+ return {
91
+ pinnedVersion: readPinnedBunVersion(pinPath),
92
+ runningVersion: process.versions.bun ?? null,
93
+ pinPath,
94
+ };
95
+ }
@@ -27,6 +27,8 @@ export type DriftCategory =
27
27
  | 'schema'
28
28
  | 'capability_abi'
29
29
  | 'browser_pin'
30
+ | 'server_bun_pin'
31
+ | 'reboot_pending'
30
32
  | 'terraform_plan'
31
33
  | 'module_versions'
32
34
  | 'module_configs'
@@ -24,6 +24,28 @@ function provider(
24
24
  }
25
25
 
26
26
  describe('unservedCapabilityRequirements', () => {
27
+ /**
28
+ * celilo#1384. Optional in PRESENCE, not in contract: with no provider the
29
+ * module is fine, but an installed provider on another major is called by
30
+ * the module's hooks and fails there — as a warning, silently.
31
+ */
32
+ test('an optional capability with no provider is not a blocker', () => {
33
+ expect(
34
+ unservedCapabilityRequirements([], [], [{ name: 'external_web', version: '1.0.0' }]),
35
+ ).toEqual([]);
36
+ });
37
+
38
+ test('an optional capability whose installed provider is another major IS a blocker', () => {
39
+ const result = unservedCapabilityRequirements(
40
+ [],
41
+ [provider('external_web', 'generic-cpanel', '2.0.0')],
42
+ [{ name: 'external_web', version: '1.0.0' }],
43
+ );
44
+ expect(result).toHaveLength(1);
45
+ expect(result[0]?.capability).toBe('external_web');
46
+ expect(result[0]?.providerModuleId).toBe('generic-cpanel');
47
+ });
48
+
27
49
  test('a requirement any deployed provider serves is not a blocker', () => {
28
50
  const providers = [provider('public_web', 'caddy', '4.0.0')];
29
51
  expect(
@@ -53,15 +53,27 @@ export interface UnservedCapability {
53
53
  export function unservedCapabilityRequirements(
54
54
  requirements: CapabilityRequirement[] | undefined,
55
55
  providers: DeployedCapabilityProvider[],
56
+ /**
57
+ * Capabilities the module uses only if present. Their provider may be
58
+ * absent, but an installed provider on another major breaks at runtime
59
+ * exactly as a required one would (celilo#1384), so they take the version
60
+ * check and skip the presence check.
61
+ */
62
+ optional: CapabilityRequirement[] | undefined = undefined,
56
63
  ): UnservedCapability[] {
57
64
  const unserved: UnservedCapability[] = [];
58
- for (const cap of requirements ?? []) {
65
+ const consumed = [
66
+ ...(requirements ?? []).map((cap) => ({ cap, required: true })),
67
+ ...(optional ?? []).map((cap) => ({ cap, required: false })),
68
+ ];
69
+ for (const { cap, required } of consumed) {
59
70
  // Framework-granted privileges (e.g. cross_module_read) are not
60
71
  // provider-backed — the same skip `deploy-preflight` makes.
61
72
  if (isPrivilegedCapability(cap.name)) continue;
62
73
 
63
74
  const installed = providers.filter((p) => p.capabilityName === cap.name);
64
75
  if (installed.length === 0) {
76
+ if (!required) continue;
65
77
  unserved.push({
66
78
  capability: cap.name,
67
79
  required: cap.version,
@@ -119,10 +131,11 @@ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[
119
131
  */
120
132
  export function capabilityBlockersForManifest(
121
133
  db: DbClient,
122
- manifest: Pick<ModuleManifest, 'requires' | 'id'>,
134
+ manifest: Pick<ModuleManifest, 'requires' | 'optional' | 'id'>,
123
135
  ): UnservedCapability[] {
124
136
  return unservedCapabilityRequirements(
125
137
  manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
126
138
  readDeployedProviders(db),
139
+ manifest.optional?.capabilities as CapabilityRequirement[] | undefined,
127
140
  );
128
141
  }
@@ -208,6 +208,20 @@ describe('celilo lifecycle events', () => {
208
208
  bus.close();
209
209
  }
210
210
 
211
+ /** One failed attempt that will be retried — the delivery stays pending. */
212
+ function recordRetryingFailure(message: string): void {
213
+ const bus = openBus({ dbPath, events: defineEvents({}) });
214
+ const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
215
+ const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0];
216
+ if (!sub || !event) {
217
+ throw new Error('expected a subscriber and an emitted event');
218
+ }
219
+ bus.markFailed({ eventId: event.id, subscriberId: sub.id }, message, {
220
+ retryAfter: Date.now() + 60_000,
221
+ });
222
+ bus.close();
223
+ }
224
+
211
225
  it('routesChangedHighWater is 0 with no events, then the latest id', () => {
212
226
  expect(routesChangedHighWater()).toBe(0);
213
227
  emitWebRoutesChanged('celilo-website');
@@ -222,6 +236,7 @@ describe('celilo lifecycle events', () => {
222
236
  succeeded: 0,
223
237
  failed: 0,
224
238
  timedOut: false,
239
+ lastError: null,
225
240
  noDispatcher: false,
226
241
  });
227
242
  });
@@ -237,6 +252,7 @@ describe('celilo lifecycle events', () => {
237
252
  succeeded: 0,
238
253
  failed: 0,
239
254
  timedOut: false,
255
+ lastError: null,
240
256
  noDispatcher: true,
241
257
  });
242
258
  });
@@ -251,6 +267,7 @@ describe('celilo lifecycle events', () => {
251
267
  succeeded: 0,
252
268
  failed: 0,
253
269
  timedOut: false,
270
+ lastError: null,
254
271
  noDispatcher: false,
255
272
  });
256
273
  });
@@ -265,6 +282,26 @@ describe('celilo lifecycle events', () => {
265
282
  expect(result.timedOut).toBe(true);
266
283
  });
267
284
 
285
+ /**
286
+ * celilo#1401. A handler that CRASHES is retried, so between attempts its
287
+ * delivery is `pending` with the error already recorded. The deadline hits
288
+ * in that window and the caller used to report a bare timeout — `0 ok, 0
289
+ * failed of 1` — which is what hid a caddy crash for days.
290
+ */
291
+ it('carries the crashing handler error out of a timeout', async () => {
292
+ markDispatcherAlive();
293
+ subscribeReconciler();
294
+ const since = routesChangedHighWater();
295
+ emitWebRoutesChanged('celilo-website');
296
+ // Failed but NOT abandoned: it goes back to pending for the next attempt.
297
+ recordRetryingFailure('{} is not iterable');
298
+
299
+ const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
300
+ expect(result.timedOut).toBe(true);
301
+ expect(result.failed).toBe(0);
302
+ expect(result.lastError).toContain('{} is not iterable');
303
+ });
304
+
268
305
  it('completes once the provider delivery succeeds', async () => {
269
306
  markDispatcherAlive();
270
307
  subscribeReconciler();
@@ -278,6 +315,7 @@ describe('celilo lifecycle events', () => {
278
315
  succeeded: 1,
279
316
  failed: 0,
280
317
  timedOut: false,
318
+ lastError: null,
281
319
  noDispatcher: false,
282
320
  });
283
321
  });
@@ -313,6 +351,7 @@ describe('celilo lifecycle events', () => {
313
351
  succeeded: 1,
314
352
  failed: 0,
315
353
  timedOut: false,
354
+ lastError: null,
316
355
  noDispatcher: false,
317
356
  });
318
357
  });
@@ -222,6 +222,17 @@ export interface RouteReconcileWaitResult {
222
222
  succeeded: number;
223
223
  failed: number;
224
224
  timedOut: boolean;
225
+ /**
226
+ * The handler's own error text from the most recent settled-or-retrying
227
+ * delivery, or null when no delivery has reported one.
228
+ *
229
+ * A handler that CRASHES is retried, so for `max_attempts` retries its
230
+ * delivery is back in `pending` with an error already recorded. The deadline
231
+ * can hit in that window, and the caller then reported a timeout for what was
232
+ * a crash — `0 ok, 0 failed of 1` with the real cause nowhere in sight
233
+ * (celilo#1401, which hid a caddy crash for days behind a deadline message).
234
+ */
235
+ lastError: string | null;
225
236
  /**
226
237
  * True when no event dispatcher was running, so the `routes_changed` event
227
238
  * was persisted but never DELIVERED to the provider (caddy). The route is
@@ -290,18 +301,36 @@ export async function waitForRouteReconcile(
290
301
  .filter((e) => e.id > sinceEventId);
291
302
 
292
303
  if (events.length === 0) {
293
- return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
304
+ return {
305
+ events: 0,
306
+ succeeded: 0,
307
+ failed: 0,
308
+ timedOut: false,
309
+ lastError: null,
310
+ noDispatcher: false,
311
+ };
294
312
  }
295
313
 
296
- if (b.health().status === 'no_dispatcher') {
314
+ // Confirmed, not instant: a dispatcher starved by the deploy's own load looks
315
+ // exactly like a dead one for a single sample, and abandoning the reconcile on
316
+ // that reading is celilo#1399 — it failed consumers' re-registration during the
317
+ // one operation that generates route changes. `lagging` waits like any healthy
318
+ // bus; the deadline below still bounds it.
319
+ const health = await b.healthConfirmed();
320
+ if (health.status === 'no_dispatcher' || health.status === 'wedged') {
321
+ const why =
322
+ health.status === 'wedged'
323
+ ? `dispatcher heartbeat stale with nothing running (wedged, ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s)`
324
+ : 'no dispatcher running';
297
325
  console.warn(
298
- `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
326
+ `[celilo] route-reconcile: ${why} — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
299
327
  );
300
328
  return {
301
329
  events: events.length,
302
330
  succeeded: 0,
303
331
  failed: 0,
304
332
  timedOut: false,
333
+ lastError: null,
305
334
  noDispatcher: true,
306
335
  };
307
336
  }
@@ -319,6 +348,7 @@ export async function waitForRouteReconcile(
319
348
  succeeded: settled('succeeded'),
320
349
  failed: settled('failed') + settled('abandoned'),
321
350
  timedOut: pending > 0,
351
+ lastError: deliveries.find((d) => d.lastError)?.lastError ?? null,
322
352
  noDispatcher: false,
323
353
  };
324
354
  }
@@ -328,7 +358,14 @@ export async function waitForRouteReconcile(
328
358
  } catch (err) {
329
359
  const msg = err instanceof Error ? err.message : String(err);
330
360
  console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
331
- return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
361
+ return {
362
+ events: 0,
363
+ succeeded: 0,
364
+ failed: 0,
365
+ timedOut: false,
366
+ lastError: null,
367
+ noDispatcher: false,
368
+ };
332
369
  } finally {
333
370
  bus?.close();
334
371
  }
@@ -112,8 +112,15 @@ export async function runPreflight(
112
112
  // drift after deploy; this surface refuses up front so the
113
113
  // operator gets an actionable error instead of a silent
114
114
  // template-generation against an ABI the provider can't fulfil.
115
- if (manifest.requires?.capabilities) {
116
- for (const cap of manifest.requires.capabilities) {
115
+ // An OPTIONAL capability joins the version check but not the presence check:
116
+ // its provider may legitimately be absent, but when one IS installed the
117
+ // module's hooks call it, so another major breaks at runtime (celilo#1384).
118
+ {
119
+ const consumed = [
120
+ ...(manifest.requires?.capabilities ?? []).map((cap) => ({ cap, required: true })),
121
+ ...(manifest.optional?.capabilities ?? []).map((cap) => ({ cap, required: false })),
122
+ ];
123
+ for (const { cap, required } of consumed) {
117
124
  // Framework-granted privileges (e.g. cross_module_read) aren't
118
125
  // provider-backed — they're gated by the allow-list at import time,
119
126
  // not satisfied by deploying another module. Skip them here.
@@ -126,6 +133,7 @@ export async function runPreflight(
126
133
  .where(eq(capabilities.capabilityName, cap.name))
127
134
  .all();
128
135
  if (providers.length === 0) {
136
+ if (!required) continue;
129
137
  errors.push({
130
138
  category: 'missing-capability',
131
139
  message: `Required capability '${cap.name}' has no provider installed`,
@@ -154,7 +162,8 @@ export async function runPreflight(
154
162
  errors.push({
155
163
  category: 'capability-version-mismatch',
156
164
  message:
157
- `Module '${moduleId}' requires ${cap.name}@${cap.version} but ` +
165
+ `Module '${moduleId}' ${required ? 'requires' : 'optionally uses'} ` +
166
+ `${cap.name}@${cap.version} but ` +
158
167
  `provider '${example.moduleId}' offers ${cap.name}@${example.version}`,
159
168
  suggestion:
160
169
  reason === 'caller_minor_too_old'
@@ -19,11 +19,15 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
  import { type DbClient, getDb } from '../db/client';
22
- import { modules, secrets } from '../db/schema';
22
+ import { capabilities, modules, secrets } from '../db/schema';
23
23
  import type { ModuleManifest } from '../manifest/schema';
24
24
  import { resetTestDbPath } from '../test-utils/db-path';
25
25
  import { findMissingSecrets } from './config-interview';
26
- import { findMissingBuildArtifacts, findMissingRequiredVariables } from './deploy-validation';
26
+ import {
27
+ findMissingBuildArtifacts,
28
+ findMissingRequiredVariables,
29
+ validateAndPrepareDeployment,
30
+ } from './deploy-validation';
27
31
 
28
32
  function manifestWithStringMapSecret(): ModuleManifest {
29
33
  return {
@@ -366,3 +370,78 @@ describe('findMissingBuildArtifacts (deploy build-gate)', () => {
366
370
  expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
367
371
  });
368
372
  });
373
+
374
+ /**
375
+ * celilo#1384. An OPTIONAL capability is optional in its PRESENCE, not in its
376
+ * contract: with a provider installed the module's hooks call it, so a
377
+ * provider on another major fails at runtime — and `on_install` failing is
378
+ * only a warning, so it fails silently. Measured: generic-cpanel 0.4.1+2
379
+ * (external_web 2.0.0) against tango-nexus 0.8.0 (optional external_web
380
+ * 1.0.0); nothing published, the site kept serving, nothing said so.
381
+ */
382
+ describe('validateAndPrepareDeployment (optional capability version gate)', () => {
383
+ let tempDir: string;
384
+ let db: DbClient;
385
+
386
+ function seed(optionalVersion: string, providerVersion: string | null) {
387
+ db.insert(modules)
388
+ .values({
389
+ id: 'consumer',
390
+ name: 'Consumer',
391
+ sourcePath: tempDir,
392
+ version: '1.0.0',
393
+ manifestData: {
394
+ celilo_contract: '1.0',
395
+ id: 'consumer',
396
+ name: 'Consumer',
397
+ version: '1.0.0',
398
+ description: 'fixture',
399
+ optional: { capabilities: [{ name: 'external_web', version: optionalVersion }] },
400
+ },
401
+ })
402
+ .run();
403
+ if (providerVersion === null) return;
404
+ db.insert(modules)
405
+ .values({
406
+ id: 'provider',
407
+ name: 'Provider',
408
+ sourcePath: tempDir,
409
+ version: '1.0.0',
410
+ manifestData: {},
411
+ })
412
+ .run();
413
+ db.insert(capabilities)
414
+ .values({
415
+ moduleId: 'provider',
416
+ capabilityName: 'external_web',
417
+ version: providerVersion,
418
+ data: {},
419
+ })
420
+ .run();
421
+ }
422
+
423
+ beforeEach(() => {
424
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-optional-cap-'));
425
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
426
+ db = getDb();
427
+ });
428
+
429
+ afterEach(() => {
430
+ rmSync(tempDir, { recursive: true, force: true });
431
+ resetTestDbPath();
432
+ });
433
+
434
+ test('refuses the deploy when the installed provider is another major', async () => {
435
+ seed('1.0.0', '2.0.0');
436
+ const result = await validateAndPrepareDeployment('consumer', db);
437
+ expect(result.success).toBe(false);
438
+ expect(result.error).toContain('external_web');
439
+ expect(result.error).toContain('provider');
440
+ });
441
+
442
+ test('does not refuse when nobody provides the optional capability', async () => {
443
+ seed('1.0.0', null);
444
+ const result = await validateAndPrepareDeployment('consumer', db);
445
+ expect(result.error).toBeUndefined();
446
+ });
447
+ });
@@ -113,6 +113,25 @@ export async function validateAndPrepareDeployment(
113
113
  }
114
114
  }
115
115
 
116
+ // An OPTIONAL capability is optional in its PRESENCE, not in its contract.
117
+ // When a provider is installed the module's hooks will call it, so a provider
118
+ // on another major breaks at runtime exactly as a required one would — except
119
+ // an `on_install` failure is only a warning, so it breaks silently
120
+ // (celilo#1384). No provider installed is still fine: the version check below
121
+ // skips a capability nobody provides.
122
+ if (manifest.optional?.capabilities) {
123
+ const optionalMismatches = await findCapabilityVersionMismatches(
124
+ manifest.optional.capabilities,
125
+ db,
126
+ );
127
+ if (optionalMismatches.length > 0) {
128
+ return {
129
+ success: false,
130
+ error: formatVersionMismatchError(moduleId, optionalMismatches),
131
+ };
132
+ }
133
+ }
134
+
116
135
  // Check for missing required variables BEFORE generating — if any are missing
117
136
  // we return them for the caller to interview, then the caller re-invokes deploy
118
137
  // after the user has answered. This prevents generation from failing mid-way
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { readFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
- import { type Bus, describeError } from '@celilo/event-bus';
22
+ import { type Bus, type BusHealth, describeError } from '@celilo/event-bus';
23
23
  import { desc, eq, inArray } from 'drizzle-orm';
24
24
  import { parse as parseYaml } from 'yaml';
25
25
  import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
@@ -203,6 +203,12 @@ export interface DispatcherCheckOptions {
203
203
  * Injected so the crash-loop aspect is testable without launchd.
204
204
  */
205
205
  launchdProbe?: (scope: SupervisorScope) => LaunchdUnitStatus | null;
206
+ /**
207
+ * A verdict already taken, normally `await bus.healthConfirmed()`. Callers
208
+ * that can await pass it so a load-starved dispatcher reads `lagging` rather
209
+ * than the `wedged` a single sample cannot distinguish it from (celilo#1399).
210
+ */
211
+ health?: BusHealth;
206
212
  }
207
213
 
208
214
  /**
@@ -214,7 +220,7 @@ export interface DispatcherCheckOptions {
214
220
  */
215
221
  export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): FleetFinding {
216
222
  const now = opts.now ?? Date.now();
217
- const health = bus.health();
223
+ const health = opts.health ?? bus.health();
218
224
  const hb = bus.db
219
225
  .query<HeartbeatRow, []>(
220
226
  'SELECT dispatcher_id, last_heartbeat, started_at, pid, version FROM dispatcher_heartbeat ORDER BY last_heartbeat DESC LIMIT 1',
@@ -227,14 +233,25 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
227
233
 
228
234
  // (1) running — a fresh heartbeat. health() already classifies a
229
235
  // stale/absent heartbeat as no_dispatcher.
230
- if (health.status === 'no_dispatcher' || !hb) {
236
+ if (health.status === 'no_dispatcher' || health.status === 'wedged' || !hb) {
231
237
  statuses.push('fail');
232
- detail.push(
233
- 'no live dispatcher — heartbeat absent or stale (events are queueing, not delivered)',
234
- );
235
- remediations.push(
236
- 'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
237
- );
238
+ if (health.status === 'wedged') {
239
+ // Registered, process alive, heartbeat stale, nothing running: it is present
240
+ // but dead, so "start the dispatcher" is the wrong instruction (celilo#1398).
241
+ detail.push(
242
+ `dispatcher WEDGED — pid ${hb?.pid ?? '?'} is alive and registered but its heartbeat is ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s old with nothing running (events are queueing, not delivered)`,
243
+ );
244
+ remediations.push(
245
+ health.remedy ?? 'restart the dispatcher: `celilo events restart-daemon --system`',
246
+ );
247
+ } else {
248
+ detail.push(
249
+ 'no live dispatcher — heartbeat absent or stale (events are queueing, not delivered)',
250
+ );
251
+ remediations.push(
252
+ 'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
253
+ );
254
+ }
238
255
  // A unit can be INSTALLED and still dead: the supervisor respawns a
239
256
  // program that cannot start in the unit's environment and it dies again,
240
257
  // forever. On macOS the classic cause is a PATH-less
@@ -274,6 +291,15 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
274
291
  detail.push(
275
292
  `running (pid ${hb.pid}, heartbeat ${Math.round(ageMs / 1000)}s ago, code v${hb.version})`,
276
293
  );
294
+ if (health.status === 'lagging') {
295
+ // Confirmed alive by a second beat: slow, not dead. Worth saying — an operator
296
+ // reading a 60s heartbeat wants to know it was checked — but not a failure,
297
+ // and explicitly not a restart (celilo#1399).
298
+ statuses.push('warn');
299
+ detail.push(
300
+ 'heartbeat is stale but still advancing — the dispatcher is alive and starved for CPU, not wedged; it recovers when load drops',
301
+ );
302
+ }
277
303
  if (health.status === 'stuck') {
278
304
  statuses.push('warn');
279
305
  detail.push(
@@ -1395,7 +1421,11 @@ export async function runFleetChecks(
1395
1421
  const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
1396
1422
  return [
1397
1423
  checkSchemaDrift(db),
1398
- checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
1424
+ checkDispatcher(bus, {
1425
+ now: opts.now,
1426
+ installedCodeMtimeMs: opts.installedCodeMtimeMs,
1427
+ health: await bus.healthConfirmed(),
1428
+ }),
1399
1429
  checkBuildBusPublishing(bus, db, { now: opts.now }),
1400
1430
  checkSubscribers(bus, db),
1401
1431
  checkCapabilityProviders(db),