@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
@@ -3,8 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
6
+ import { eq } from 'drizzle-orm';
6
7
  import type { DbClient } from '../db/client';
7
8
  import {
9
+ buildBusHookRuns,
8
10
  capabilities as capabilitiesTable,
9
11
  dnsInternalRecords,
10
12
  moduleConfigs,
@@ -20,6 +22,7 @@ import { ensureBackupSweepSubscriber } from './backup-sweep';
20
22
  import { getDaemonUnitPath } from './events-daemon';
21
23
  import {
22
24
  type HostLivenessInputs,
25
+ checkBuildBusPublishing,
23
26
  checkCapabilityProviders,
24
27
  checkControlPlaneNetwork,
25
28
  checkDispatcher,
@@ -96,7 +99,7 @@ describe('checkDispatcher', () => {
96
99
  });
97
100
  afterEach(() => {
98
101
  bus.close();
99
- process.env.EVENT_BUS_DB = undefined;
102
+ delete process.env.EVENT_BUS_DB;
100
103
  try {
101
104
  rmSync(dir, { recursive: true, force: true });
102
105
  } catch {
@@ -111,6 +114,41 @@ describe('checkDispatcher', () => {
111
114
  expect(f.remediation).toContain('enable --now celilo-events.service');
112
115
  });
113
116
 
117
+ // celilo#1373 — the unit is installed and launchd keeps respawning it, but
118
+ // it dies before it can heartbeat (no PATH → the wrapper can't find
119
+ // bun). The finding must NAME the crash loop, not tell the operator to
120
+ // "start the dispatcher".
121
+ it('names a crash-looping launchd unit when no dispatcher is live (darwin)', () => {
122
+ installFakeUnit(home);
123
+ const f = checkDispatcher(bus, {
124
+ now,
125
+ home,
126
+ platform: 'darwin',
127
+ launchdProbe: () => ({ pid: null, lastExitStatus: 256 }),
128
+ });
129
+ expect(f.status).toBe('fail');
130
+ expect(f.detail.join(' ')).toContain('crash-looping');
131
+ expect(f.detail.join(' ')).toContain('exit code 1');
132
+ expect(f.remediation).toContain('celilo events install-daemon');
133
+ });
134
+
135
+ it('does not claim a crash loop when launchd runs the unit or cannot answer', () => {
136
+ installFakeUnit(home);
137
+ for (const probe of [
138
+ () => ({ pid: 4242, lastExitStatus: 256 }), // running despite an old failure
139
+ () => null, // launchd didn't answer — ignorance, not evidence
140
+ ]) {
141
+ const f = checkDispatcher(bus, {
142
+ now,
143
+ home,
144
+ platform: 'darwin',
145
+ launchdProbe: probe,
146
+ });
147
+ expect(f.status).toBe('fail');
148
+ expect(f.detail.join(' ')).not.toContain('crash-looping');
149
+ }
150
+ });
151
+
114
152
  it('passes when running, supervised, current, and not behind on timer ticks', () => {
115
153
  seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
116
154
  installFakeUnit(home);
@@ -271,7 +309,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
271
309
  bus.close();
272
310
  db.$client.close();
273
311
  resetTestDbPath();
274
- process.env.EVENT_BUS_DB = undefined;
312
+ delete process.env.EVENT_BUS_DB;
275
313
  try {
276
314
  rmSync(dir, { recursive: true, force: true });
277
315
  } catch {
@@ -292,6 +330,119 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
292
330
  .run();
293
331
  }
294
332
 
333
+ describe('checkBuildBusPublishing', () => {
334
+ const now = Date.now();
335
+
336
+ /** A module whose installed manifest really declares on_upstream_publish. */
337
+ function insertHookModule(id: string): void {
338
+ const src = join(dir, `${id}-src`);
339
+ mkdirSync(src, { recursive: true });
340
+ writeFileSync(
341
+ join(src, 'manifest.yml'),
342
+ [
343
+ 'celilo_contract: "1.0"',
344
+ `id: ${id}`,
345
+ `name: ${id}`,
346
+ 'version: 1.0.0',
347
+ 'requires: { capabilities: [] }',
348
+ 'provides: { capabilities: [] }',
349
+ 'variables: { owns: [], imports: [] }',
350
+ 'hooks:',
351
+ ' on_upstream_publish:',
352
+ ' - name: self-update',
353
+ ' match: { registry: npm, tag: latest, package_pattern: "@celilo/cli" }',
354
+ ' script: ./scripts/update.sh',
355
+ ].join('\n'),
356
+ );
357
+ insertModule(id, baseManifest({ id, name: id }), 'VERIFIED');
358
+ // insertModule pins sourcePath to /src/<id>; point it at the real manifest.
359
+ db.update(modules).set({ sourcePath: src }).where(eq(modules.id, id)).run();
360
+ }
361
+
362
+ function seedRun(overrides: Partial<typeof buildBusHookRuns.$inferInsert> = {}): void {
363
+ db.insert(buildBusHookRuns)
364
+ .values({
365
+ eventId: 'evt-1',
366
+ packageName: '@celilo/cli',
367
+ packageVersion: '2.2.1',
368
+ tag: 'latest',
369
+ moduleId: 'celilo-mgmt',
370
+ hookName: 'self-update',
371
+ scriptPath: '/src/celilo-mgmt/scripts/update.sh',
372
+ exitCode: 0,
373
+ timedOut: false,
374
+ durationMs: 1200,
375
+ // Default to the freshest time, not the describe-time `now`:
376
+ // emitRaw stamps the bus event with its own Date.now(), and a
377
+ // run stamped even 1ms earlier would read as "predates the
378
+ // newest publish".
379
+ ranAt: new Date(Date.now()),
380
+ ...overrides,
381
+ })
382
+ .run();
383
+ }
384
+
385
+ it('is ok when no installed module declares on_upstream_publish', () => {
386
+ const f = checkBuildBusPublishing(bus, db, { now });
387
+ expect(f.status).toBe('ok');
388
+ expect(f.summary).toContain('not in use');
389
+ });
390
+
391
+ it('warns when a self-update module is installed but no publish has ever arrived', () => {
392
+ insertHookModule('celilo-mgmt');
393
+ const f = checkBuildBusPublishing(bus, db, { now });
394
+ expect(f.status).toBe('warn');
395
+ expect(f.summary).toContain('no build-bus publish has ever arrived');
396
+ expect(f.remediation).toContain('subscribers install-daemon');
397
+ expect(f.remediation).toContain('CELILO_BUS_SECRET');
398
+ });
399
+
400
+ it('warns when publishes arrive but no hook has ever run', () => {
401
+ insertHookModule('celilo-mgmt');
402
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
403
+ const f = checkBuildBusPublishing(bus, db, { now });
404
+ expect(f.status).toBe('warn');
405
+ expect(f.summary).toContain('no self-update hook has ever run');
406
+ });
407
+
408
+ it('warns with the stderr tail when the newest run failed', () => {
409
+ insertHookModule('celilo-mgmt');
410
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
411
+ seedRun({ exitCode: 1, stderrTail: 'bun add -g failed; not promoting' });
412
+ const f = checkBuildBusPublishing(bus, db, { now });
413
+ expect(f.status).toBe('warn');
414
+ expect(f.summary).toContain('exited 1');
415
+ expect(f.detail.join('\n')).toContain('bun add -g failed');
416
+ });
417
+
418
+ it('warns when the newest run timed out', () => {
419
+ insertHookModule('celilo-mgmt');
420
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
421
+ seedRun({ exitCode: null, timedOut: true, durationMs: 300000 });
422
+ const f = checkBuildBusPublishing(bus, db, { now });
423
+ expect(f.status).toBe('warn');
424
+ expect(f.summary).toContain('timed out');
425
+ });
426
+
427
+ it('warns when the newest run predates the newest publish — that publish dispatched nothing', () => {
428
+ insertHookModule('celilo-mgmt');
429
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-2' });
430
+ seedRun({ ranAt: new Date(now - 60 * MINUTE) });
431
+ const f = checkBuildBusPublishing(bus, db, { now });
432
+ expect(f.status).toBe('warn');
433
+ expect(f.summary).toContain('dispatched nothing');
434
+ });
435
+
436
+ it('is ok when the newest run succeeded and is fresh', () => {
437
+ insertHookModule('celilo-mgmt');
438
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
439
+ seedRun();
440
+ const f = checkBuildBusPublishing(bus, db, { now });
441
+ expect(f.status).toBe('ok');
442
+ expect(f.summary).toContain('@celilo/cli@2.2.1');
443
+ });
444
+ });
445
+
295
446
  describe('checkSubscribers', () => {
296
447
  it('fails when a deployed module declares a subscription the bus is missing', () => {
297
448
  insertModule(
@@ -449,7 +600,11 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
449
600
  .run();
450
601
  const f = checkCapabilityProviders(db);
451
602
  expect(f.status).toBe('ok');
452
- expect(f.detail.join(' ')).toContain('celilo capability chain');
603
+ // celilo#1308: `celilo capability chain` no longer exists, so the detail
604
+ // says the verification is by hand rather than naming a command that
605
+ // cannot run.
606
+ expect(f.detail.join(' ')).toContain('verify these by hand');
607
+ expect(f.detail.join(' ')).not.toContain('celilo capability chain');
453
608
  });
454
609
  });
455
610
 
@@ -980,7 +1135,7 @@ describe('runFleetChecks includes host liveness (#728)', () => {
980
1135
  bus.close();
981
1136
  db.$client.close();
982
1137
  resetTestDbPath();
983
- process.env.EVENT_BUS_DB = undefined;
1138
+ delete process.env.EVENT_BUS_DB;
984
1139
  try {
985
1140
  rmSync(dir, { recursive: true, force: true });
986
1141
  } catch {
@@ -17,13 +17,16 @@
17
17
  * rendering + `--fix` orchestration lives in the doctor command.
18
18
  */
19
19
 
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
20
22
  import { type Bus, describeError } from '@celilo/event-bus';
21
- import { inArray } from 'drizzle-orm';
23
+ import { desc, eq, inArray } from 'drizzle-orm';
24
+ import { parse as parseYaml } from 'yaml';
22
25
  import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
23
26
  import { getModuleStoragePath } from '../config/paths';
24
27
  import { type DbClient, findMigrationsFolder } from '../db/client';
25
28
  import { getMigrationStatus } from '../db/migration-status';
26
- import { capabilities as capabilitiesTable, modules } from '../db/schema';
29
+ import { buildBusHookRuns, capabilities as capabilitiesTable, modules } from '../db/schema';
27
30
  import { findSchemaDrift } from '../db/schema-introspection';
28
31
  import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
29
32
  import type { ModuleManifest } from '../manifest/schema';
@@ -36,9 +39,11 @@ import {
36
39
  } from './deployed-systems';
37
40
  import { listDnsInternalRecords } from './dns-internal-records';
38
41
  import {
42
+ type LaunchdUnitStatus,
39
43
  SUPERVISOR_SCOPES,
40
44
  type SupervisorPlatform,
41
45
  type SupervisorScope,
46
+ launchdUnitStatus,
42
47
  readInstalledUnit,
43
48
  unitMainPid,
44
49
  } from './events-daemon';
@@ -193,6 +198,11 @@ export interface DispatcherCheckOptions {
193
198
  * no supervision claim rather than guessing.
194
199
  */
195
200
  unitMainPid?: (scope: SupervisorScope) => number | null;
201
+ /**
202
+ * Probe launchd for the unit's health (darwin only — see launchdUnitStatus).
203
+ * Injected so the crash-loop aspect is testable without launchd.
204
+ */
205
+ launchdProbe?: (scope: SupervisorScope) => LaunchdUnitStatus | null;
196
206
  }
197
207
 
198
208
  /**
@@ -225,6 +235,29 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
225
235
  remediations.push(
226
236
  'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
227
237
  );
238
+ // A unit can be INSTALLED and still dead: the supervisor respawns a
239
+ // program that cannot start in the unit's environment and it dies again,
240
+ // forever. On macOS the classic cause is a PATH-less
241
+ // EnvironmentVariables dict — the global celilo wrapper needs
242
+ // `command -v bun`, and launchd's default PATH has none (celilo#1373:
243
+ // 22680 respawns on one Mac). Name the crash loop instead of telling
244
+ // the operator to "start the dispatcher".
245
+ if (opts.platform === 'darwin') {
246
+ const probe = opts.launchdProbe ?? launchdUnitStatus;
247
+ const st = probe('user');
248
+ if (st && st.pid === null && st.lastExitStatus != null && st.lastExitStatus !== 0) {
249
+ const fate =
250
+ st.lastExitStatus & 0xff
251
+ ? `signal ${st.lastExitStatus & 0xff}`
252
+ : `exit code ${st.lastExitStatus >> 8}`;
253
+ detail.push(
254
+ `the installed launchd unit is crash-looping (last ${fate}) — launchd respawns it and it dies again, so no dispatcher ever serves the bus`,
255
+ );
256
+ remediations.push(
257
+ "read the unit's stderr (`tail ~/Library/Logs/celilo-events.err.log`) and reinstall with `celilo events install-daemon` so the unit carries an environment that resolves bun (celilo#1373)",
258
+ );
259
+ }
260
+ }
228
261
  // Without a heartbeat there's nothing more to assert about it.
229
262
  return {
230
263
  id: 'dispatcher',
@@ -384,6 +417,172 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
384
417
  };
385
418
  }
386
419
 
420
+ /**
421
+ * The build-bus self-update chain, asserted outcome-first like every check
422
+ * here. Four facts separate the ways `on_upstream_publish` silently does
423
+ * nothing (celilo#1304: the CLI on a management box aged in place while the
424
+ * doctor's dispatcher check read green — that check covers the event-bus
425
+ * dispatcher, which no build-bus hook ever travels through):
426
+ *
427
+ * 1. no module declares the hook → nothing can self-update; not in use
428
+ * 2. `build-bus.publish` events arrive → the webhook path works end to end
429
+ * 3. hook-run rows exist for an event → the dispatcher ran matching hooks
430
+ * 4. the newest run exited 0 → the self-update itself succeeded
431
+ *
432
+ * Facts 3 and 4 read the `build_bus_hook_runs` ledger the dispatcher writes;
433
+ * before that ledger existed, a failed self-update existed only as daemon
434
+ * console output, which no surface reads.
435
+ */
436
+ export function checkBuildBusPublishing(
437
+ bus: Bus,
438
+ db: DbClient,
439
+ opts: { now?: number } = {},
440
+ ): FleetFinding {
441
+ const now = opts.now ?? Date.now();
442
+
443
+ const hookModules = db
444
+ .select({ id: modules.id })
445
+ .from(modules)
446
+ .where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
447
+ .all()
448
+ .filter((m) => hasUpstreamPublishHooks(db, m.id));
449
+
450
+ const finding = (
451
+ status: FleetFindingStatus,
452
+ summary: string,
453
+ detail: string[],
454
+ remediation: string | null,
455
+ ): FleetFinding => ({
456
+ id: 'build-bus-publishing',
457
+ title: 'build-bus webhooks arriving and self-update hooks running',
458
+ status,
459
+ summary,
460
+ detail,
461
+ remediation,
462
+ autoFixable: false,
463
+ });
464
+
465
+ if (hookModules.length === 0) {
466
+ return finding(
467
+ 'ok',
468
+ 'no installed module declares on_upstream_publish — build-bus self-update not in use here',
469
+ [],
470
+ null,
471
+ );
472
+ }
473
+
474
+ const newestPublish = bus.recentEvents({ type: 'build-bus.publish', limit: 1 })[0];
475
+ if (!newestPublish) {
476
+ return finding(
477
+ 'warn',
478
+ `${hookModules.map((m) => m.id).join(', ')} self-update can never fire: no build-bus publish has ever arrived`,
479
+ [
480
+ 'The webhook receiver (`celilo subscribers serve`) either is not running here or was never sent a verified publish — and nothing in the deploy installs it.',
481
+ ],
482
+ "install the receiver under a supervisor unit: `celilo subscribers install-daemon --secret $CELILO_BUS_SECRET` (systemd/launchd; `--print` renders for Ansible), enable it per its next-steps output, and register this host in the publisher's build-bus-subscribers.json with the same secret",
483
+ );
484
+ }
485
+
486
+ const runs = db
487
+ .select()
488
+ .from(buildBusHookRuns)
489
+ .orderBy(desc(buildBusHookRuns.id))
490
+ .limit(10)
491
+ .all();
492
+
493
+ const detail: string[] = [
494
+ `newest publish: ${newestPublish.emittedAt ? `${Math.round((now - newestPublish.emittedAt) / 60000)}min ago` : 'unknown age'}`,
495
+ ];
496
+
497
+ const newest = runs[0];
498
+ if (!newest) {
499
+ return finding(
500
+ 'warn',
501
+ 'publish events arrive but no self-update hook has ever run',
502
+ [
503
+ ...detail,
504
+ 'The receiver emitted to the bus, yet the in-process hook dispatcher dispatched nothing — it was started with --no-dispatch, no module matched, or the running CLI predates the run ledger.',
505
+ ],
506
+ "check how the receiver daemon is started (drop --no-dispatch) and compare the publish events against the installed modules' on_upstream_publish match rules",
507
+ );
508
+ }
509
+
510
+ const runAgeMin = newest.ranAt ? Math.round((now - newest.ranAt.getTime()) / 60000) : null;
511
+ const runLine = `newest hook run: ${newest.packageName}@${newest.packageVersion} on ${newest.moduleId}${runAgeMin != null ? `, ${runAgeMin}min ago` : ''}`;
512
+
513
+ if (newest.timedOut) {
514
+ return finding(
515
+ 'warn',
516
+ `self-update hook timed out after ${newest.durationMs}ms`,
517
+ [
518
+ ...detail,
519
+ runLine,
520
+ ` stderr tail: ${(newest.stderrTail ?? '(none)').trim().slice(0, 500)}`,
521
+ ],
522
+ 'inspect the ledger: `select * from build_bus_hook_runs order by id desc limit 10` in the celilo DB',
523
+ );
524
+ }
525
+ if (newest.exitCode !== 0) {
526
+ return finding(
527
+ 'warn',
528
+ `self-update hook exited ${newest.exitCode ?? 'without an exit code (spawn failure)'}`,
529
+ [
530
+ ...detail,
531
+ runLine,
532
+ ` stderr tail: ${(newest.stderrTail ?? '(none)').trim().slice(0, 500)}`,
533
+ ],
534
+ 'fix the failing hook script, then re-publish or re-emit the build-bus.publish event',
535
+ );
536
+ }
537
+
538
+ // A success that predates the newest publish means the newest publish was
539
+ // dropped on the dispatch side even though earlier ones ran. The ledger's
540
+ // ran_at has second resolution (unixepoch), so the event's millisecond
541
+ // stamp is truncated to the same granularity before comparing — otherwise
542
+ // a run in the same second as its event reads as older than it.
543
+ const newestPublishSec = Math.floor((newestPublish.emittedAt ?? 0) / 1000) * 1000;
544
+ if (newest.ranAt && newest.ranAt.getTime() < newestPublishSec) {
545
+ return finding(
546
+ 'warn',
547
+ 'newest publish event predates the newest hook run — that publish dispatched nothing',
548
+ [...detail, runLine],
549
+ 'check the receiver daemon logs for that event and the match rules against what the publisher sent',
550
+ );
551
+ }
552
+
553
+ return finding(
554
+ 'ok',
555
+ `last self-update hook succeeded (${newest.packageName}@${newest.packageVersion})`,
556
+ [...detail, runLine],
557
+ null,
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Does module `id`'s installed manifest declare an `on_upstream_publish`
563
+ * hook? Reads the manifest from disk exactly as the hook dispatcher's own
564
+ * loader does, so the check and the dispatcher cannot disagree about what
565
+ * "in use" means. A manifest that has vanished or fails to parse reads as
566
+ * "no hooks" — the dispatcher skips it silently too (Rule 6.2: the skip is
567
+ * visible here as fact 1 rather than as a crash).
568
+ */
569
+ function hasUpstreamPublishHooks(db: DbClient, moduleId: string): boolean {
570
+ const row = db
571
+ .select({ sourcePath: modules.sourcePath })
572
+ .from(modules)
573
+ .where(eq(modules.id, moduleId))
574
+ .get();
575
+ if (!row?.sourcePath) return false;
576
+ try {
577
+ const parsed = parseYaml(readFileSync(join(row.sourcePath, 'manifest.yml'), 'utf-8')) as {
578
+ hooks?: { on_upstream_publish?: unknown[] };
579
+ };
580
+ return (parsed.hooks?.on_upstream_publish ?? []).length > 0;
581
+ } catch {
582
+ return false;
583
+ }
584
+ }
585
+
387
586
  /** A deployed module + its parsed manifest (INSTALLED/VERIFIED only). */
388
587
  interface DeployedModule {
389
588
  id: string;
@@ -668,7 +867,10 @@ export function checkCapabilityProviders(db: DbClient): FleetFinding {
668
867
  }
669
868
  if (traceNeeded.length > 0) {
670
869
  detail.push(
671
- `${traceNeeded.length} derived value(s) resolve through another capability — verify with \`celilo capability chain <module> <var>\` (ISS-0114): ${traceNeeded.join('; ')}`,
870
+ // celilo#1308: `celilo capability chain` no longer exists (the ISS-0115
871
+ // rework removed it), so prescribing it sent the operator to an
872
+ // "unknown command" error. No CLI command traces a chain; say so.
873
+ `${traceNeeded.length} derived value(s) resolve through another capability. No CLI command traces a capability chain, so verify these by hand (ISS-0114): ${traceNeeded.join('; ')}`,
672
874
  );
673
875
  }
674
876
 
@@ -1194,6 +1396,7 @@ export async function runFleetChecks(
1194
1396
  return [
1195
1397
  checkSchemaDrift(db),
1196
1398
  checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
1399
+ checkBuildBusPublishing(bus, db, { now: opts.now }),
1197
1400
  checkSubscribers(bus, db),
1198
1401
  checkCapabilityProviders(db),
1199
1402
  checkControlPlaneNetwork(db),
@@ -1,5 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { nextModuleState } from './health-runner';
1
+ import { describe, expect, spyOn, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { eq } from 'drizzle-orm';
6
+ import { type DbClient, getDb } from '../db/client';
7
+ import { modules } from '../db/schema';
8
+ import { resetTestDbPath } from '../test-utils/db-path';
9
+ import * as controlPlaneHealth from './control-plane-health';
10
+ import type { HealthCheckItem } from './health-runner';
11
+ import { deriveHealthStatus, nextModuleState, runModuleHealthCheck } from './health-runner';
3
12
 
4
13
  /**
5
14
  * The D15 rule: a scheduled (monitor-driven) health check observes only and
@@ -9,6 +18,34 @@ import { nextModuleState } from './health-runner';
9
18
  * throw, it just makes `celilo module list` flicker between INSTALLED and
10
19
  * VERIFIED every monitor interval.
11
20
  */
21
+ describe('deriveHealthStatus', () => {
22
+ const item = (status: HealthCheckItem['status'], name = 'probe'): HealthCheckItem => ({
23
+ name,
24
+ status,
25
+ message: 'msg',
26
+ });
27
+
28
+ test('fail dominates everything', () => {
29
+ expect(deriveHealthStatus([item('pass'), item('fail'), item('warn')])).toBe('unhealthy');
30
+ });
31
+
32
+ test('warn without fail is degraded', () => {
33
+ expect(deriveHealthStatus([item('pass'), item('warn')])).toBe('degraded');
34
+ });
35
+
36
+ test('measured passes with a skip are healthy — the audit carries the skip', () => {
37
+ expect(deriveHealthStatus([item('pass'), item('skip')])).toBe('healthy');
38
+ });
39
+
40
+ test('only skips is no-checks — nothing was measured', () => {
41
+ expect(deriveHealthStatus([item('skip'), item('skip')])).toBe('no-checks');
42
+ });
43
+
44
+ test('empty checks is healthy (the hook ran and reported nothing)', () => {
45
+ expect(deriveHealthStatus([])).toBe('healthy');
46
+ });
47
+ });
48
+
12
49
  describe('nextModuleState', () => {
13
50
  describe('operator-invoked run (unattended = false)', () => {
14
51
  test('passing check verifies an INSTALLED module', () => {
@@ -48,3 +85,51 @@ describe('nextModuleState', () => {
48
85
  });
49
86
  });
50
87
  });
88
+
89
+ // celilo#1363: a failed upgrade leaves state ERROR with an errorMessage. The
90
+ // recovery path is an attended health check passing, which moves ERROR →
91
+ // VERIFIED. The message must move with the state, or `module list` keeps
92
+ // printing the old `Error:` line beside a healthy row.
93
+ describe('runModuleHealthCheck — clearing a recorded failure (celilo#1363)', () => {
94
+ test('an attended healthy check clears errorMessage along with the state', async () => {
95
+ const tempDir = mkdtempSync(join(tmpdir(), 'celilo-health-'));
96
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
97
+
98
+ try {
99
+ const db: DbClient = getDb();
100
+ db.insert(modules)
101
+ .values({
102
+ id: 'celilo-mgmt',
103
+ name: 'Control plane',
104
+ sourcePath: join(tempDir, 'celilo-mgmt'),
105
+ version: '1.0.0+5',
106
+ state: 'ERROR',
107
+ errorMessage: 'Upgrade to 1.0.0+6 failed to deploy: ssh: connection refused',
108
+ manifestData: {
109
+ celilo_contract: '1.0',
110
+ id: 'celilo-mgmt',
111
+ name: 'Control plane',
112
+ version: '1.0.0',
113
+ },
114
+ })
115
+ .run();
116
+
117
+ const checks: HealthCheckItem[] = [{ name: 'probe', status: 'pass', message: 'ok' }];
118
+ const spy = spyOn(controlPlaneHealth, 'controlPlaneHealthChecks').mockResolvedValue(checks);
119
+ try {
120
+ const result = await runModuleHealthCheck('celilo-mgmt', db, {});
121
+ expect(result.status).toBe('healthy');
122
+ } finally {
123
+ spy.mockRestore();
124
+ }
125
+
126
+ const row = db.select().from(modules).where(eq(modules.id, 'celilo-mgmt')).get();
127
+ if (!row) throw new Error('row missing after health check');
128
+ expect(row.state).toBe('VERIFIED');
129
+ expect(row.errorMessage).toBeNull();
130
+ } finally {
131
+ rmSync(tempDir, { recursive: true, force: true });
132
+ resetTestDbPath();
133
+ }
134
+ });
135
+ });