@celilo/cli 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/sessions.test.ts +3 -3
  10. package/src/api-clients/proxmox.ts +10 -7
  11. package/src/cli/backup-rename.test.ts +3 -3
  12. package/src/cli/cli.test.ts +1 -1
  13. package/src/cli/commands/events.test.ts +2 -2
  14. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  15. package/src/cli/commands/machine-list.test.ts +57 -0
  16. package/src/cli/commands/machine-list.ts +35 -4
  17. package/src/cli/commands/module-health.ts +1 -0
  18. package/src/cli/commands/module-import-registry.test.ts +1 -1
  19. package/src/cli/commands/module-jail.test.ts +242 -0
  20. package/src/cli/commands/module-jail.ts +227 -0
  21. package/src/cli/commands/module-list-jail.test.ts +136 -0
  22. package/src/cli/commands/module-list.ts +30 -3
  23. package/src/cli/commands/module-publish.test.ts +6 -6
  24. package/src/cli/commands/module-update.test.ts +22 -7
  25. package/src/cli/commands/module-update.ts +4 -1
  26. package/src/cli/commands/module-upgrade.test.ts +115 -1
  27. package/src/cli/commands/module-upgrade.ts +23 -1
  28. package/src/cli/commands/module-verify.test.ts +1 -1
  29. package/src/cli/commands/publish/execute.ts +4 -1
  30. package/src/cli/commands/publish/index.ts +11 -1
  31. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  32. package/src/cli/commands/publish/module-registry.ts +23 -4
  33. package/src/cli/commands/publish/plan.ts +1 -1
  34. package/src/cli/commands/publish/types.ts +7 -0
  35. package/src/cli/commands/registry-owner.test.ts +1 -1
  36. package/src/cli/commands/registry-token.test.ts +1 -1
  37. package/src/cli/commands/system-audit.ts +20 -6
  38. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  39. package/src/cli/commands/system-doctor.test.ts +76 -10
  40. package/src/cli/commands/system-doctor.ts +34 -3
  41. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  42. package/src/cli/commands/system-update.ts +5 -0
  43. package/src/cli/completion.ts +8 -2
  44. package/src/cli/flag-surface-gate.test.ts +279 -0
  45. package/src/cli/index.ts +68 -2
  46. package/src/cli/parser.test.ts +37 -1
  47. package/src/cli/restore-command.test.ts +3 -3
  48. package/src/cli/restore-migration-failure.test.ts +2 -2
  49. package/src/cli/tui/audit-state.ts +2 -0
  50. package/src/config/paths.test.ts +19 -19
  51. package/src/db/client.test.ts +46 -1
  52. package/src/db/client.ts +26 -0
  53. package/src/db/schema.ts +63 -0
  54. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  55. package/src/hooks/capability-loader.ts +8 -0
  56. package/src/hooks/executor.test.ts +86 -12
  57. package/src/hooks/executor.ts +31 -1
  58. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  59. package/src/hooks/hook-trespass.test.ts +9 -2
  60. package/src/hooks/jail.test.ts +105 -17
  61. package/src/hooks/jail.ts +47 -10
  62. package/src/manifest/contracts/v1.ts +13 -0
  63. package/src/policy/module-script-scan.ts +52 -68
  64. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  65. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  66. package/src/registry/client.test.ts +2 -2
  67. package/src/secrets/storage.test.ts +1 -1
  68. package/src/services/alerting/keys.test.ts +4 -0
  69. package/src/services/alerting/keys.ts +12 -2
  70. package/src/services/audit/health.test.ts +97 -2
  71. package/src/services/audit/health.ts +64 -2
  72. package/src/services/audit/index.test.ts +22 -0
  73. package/src/services/audit/index.ts +8 -2
  74. package/src/services/audit/jail-exemptions.test.ts +42 -0
  75. package/src/services/audit/jail-exemptions.ts +44 -0
  76. package/src/services/audit/module-integrity.test.ts +23 -1
  77. package/src/services/audit/module-integrity.ts +7 -2
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/backup-create.ts +9 -0
  80. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  81. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  82. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  83. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  84. package/src/services/bus-ensure-flow.test.ts +1 -1
  85. package/src/services/bus-interview-park.test.ts +2 -2
  86. package/src/services/bus-interview.test.ts +2 -2
  87. package/src/services/bus-secret-flow.test.ts +1 -1
  88. package/src/services/celilo-events.test.ts +1 -1
  89. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  90. package/src/services/container-service.test.ts +1 -1
  91. package/src/services/cross-module-read.test.ts +2 -2
  92. package/src/services/deploy-preflight.ts +7 -0
  93. package/src/services/deployed-systems.ts +1 -1
  94. package/src/services/fleet-checks.test.ts +123 -4
  95. package/src/services/fleet-checks.ts +176 -3
  96. package/src/services/health-runner.test.ts +87 -2
  97. package/src/services/health-runner.ts +40 -11
  98. package/src/services/infrastructure-selector.test.ts +1 -1
  99. package/src/services/jail-exemptions.test.ts +125 -0
  100. package/src/services/jail-exemptions.ts +81 -0
  101. package/src/services/machine-pool.test.ts +1 -1
  102. package/src/services/module-deploy.ts +12 -4
  103. package/src/services/module-subscriptions.test.ts +2 -2
  104. package/src/services/network-discovery.test.ts +64 -1
  105. package/src/services/network-discovery.ts +30 -5
  106. package/src/services/responder-probe.test.ts +1 -1
  107. package/src/services/restore-from-file.test.ts +4 -4
  108. package/src/services/restore-preflight.test.ts +1 -1
  109. package/src/services/ssh-key-manager.test.ts +2 -2
  110. package/src/services/system-state-stage.test.ts +2 -2
  111. package/src/services/update/orchestrator.test.ts +2 -0
  112. package/src/test-utils/bus-responder.ts +1 -1
@@ -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)
@@ -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;
@@ -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 {
@@ -271,7 +274,7 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
271
274
  bus.close();
272
275
  db.$client.close();
273
276
  resetTestDbPath();
274
- process.env.EVENT_BUS_DB = undefined;
277
+ delete process.env.EVENT_BUS_DB;
275
278
  try {
276
279
  rmSync(dir, { recursive: true, force: true });
277
280
  } catch {
@@ -292,6 +295,118 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
292
295
  .run();
293
296
  }
294
297
 
298
+ describe('checkBuildBusPublishing', () => {
299
+ const now = Date.now();
300
+
301
+ /** A module whose installed manifest really declares on_upstream_publish. */
302
+ function insertHookModule(id: string): void {
303
+ const src = join(dir, `${id}-src`);
304
+ mkdirSync(src, { recursive: true });
305
+ writeFileSync(
306
+ join(src, 'manifest.yml'),
307
+ [
308
+ 'celilo_contract: "1.0"',
309
+ `id: ${id}`,
310
+ `name: ${id}`,
311
+ 'version: 1.0.0',
312
+ 'requires: { capabilities: [] }',
313
+ 'provides: { capabilities: [] }',
314
+ 'variables: { owns: [], imports: [] }',
315
+ 'hooks:',
316
+ ' on_upstream_publish:',
317
+ ' - name: self-update',
318
+ ' match: { registry: npm, tag: latest, package_pattern: "@celilo/cli" }',
319
+ ' script: ./scripts/update.sh',
320
+ ].join('\n'),
321
+ );
322
+ insertModule(id, baseManifest({ id, name: id }), 'VERIFIED');
323
+ // insertModule pins sourcePath to /src/<id>; point it at the real manifest.
324
+ db.update(modules).set({ sourcePath: src }).where(eq(modules.id, id)).run();
325
+ }
326
+
327
+ function seedRun(overrides: Partial<typeof buildBusHookRuns.$inferInsert> = {}): void {
328
+ db.insert(buildBusHookRuns)
329
+ .values({
330
+ eventId: 'evt-1',
331
+ packageName: '@celilo/cli',
332
+ packageVersion: '2.2.1',
333
+ tag: 'latest',
334
+ moduleId: 'celilo-mgmt',
335
+ hookName: 'self-update',
336
+ scriptPath: '/src/celilo-mgmt/scripts/update.sh',
337
+ exitCode: 0,
338
+ timedOut: false,
339
+ durationMs: 1200,
340
+ // Default to the freshest time, not the describe-time `now`:
341
+ // emitRaw stamps the bus event with its own Date.now(), and a
342
+ // run stamped even 1ms earlier would read as "predates the
343
+ // newest publish".
344
+ ranAt: new Date(Date.now()),
345
+ ...overrides,
346
+ })
347
+ .run();
348
+ }
349
+
350
+ it('is ok when no installed module declares on_upstream_publish', () => {
351
+ const f = checkBuildBusPublishing(bus, db, { now });
352
+ expect(f.status).toBe('ok');
353
+ expect(f.summary).toContain('not in use');
354
+ });
355
+
356
+ it('warns when a self-update module is installed but no publish has ever arrived', () => {
357
+ insertHookModule('celilo-mgmt');
358
+ const f = checkBuildBusPublishing(bus, db, { now });
359
+ expect(f.status).toBe('warn');
360
+ expect(f.summary).toContain('no build-bus publish has ever arrived');
361
+ expect(f.remediation).toContain('subscribers serve');
362
+ });
363
+
364
+ it('warns when publishes arrive but no hook has ever run', () => {
365
+ insertHookModule('celilo-mgmt');
366
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
367
+ const f = checkBuildBusPublishing(bus, db, { now });
368
+ expect(f.status).toBe('warn');
369
+ expect(f.summary).toContain('no self-update hook has ever run');
370
+ });
371
+
372
+ it('warns with the stderr tail when the newest run failed', () => {
373
+ insertHookModule('celilo-mgmt');
374
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
375
+ seedRun({ exitCode: 1, stderrTail: 'bun add -g failed; not promoting' });
376
+ const f = checkBuildBusPublishing(bus, db, { now });
377
+ expect(f.status).toBe('warn');
378
+ expect(f.summary).toContain('exited 1');
379
+ expect(f.detail.join('\n')).toContain('bun add -g failed');
380
+ });
381
+
382
+ it('warns when the newest run timed out', () => {
383
+ insertHookModule('celilo-mgmt');
384
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
385
+ seedRun({ exitCode: null, timedOut: true, durationMs: 300000 });
386
+ const f = checkBuildBusPublishing(bus, db, { now });
387
+ expect(f.status).toBe('warn');
388
+ expect(f.summary).toContain('timed out');
389
+ });
390
+
391
+ it('warns when the newest run predates the newest publish — that publish dispatched nothing', () => {
392
+ insertHookModule('celilo-mgmt');
393
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-2' });
394
+ seedRun({ ranAt: new Date(now - 60 * MINUTE) });
395
+ const f = checkBuildBusPublishing(bus, db, { now });
396
+ expect(f.status).toBe('warn');
397
+ expect(f.summary).toContain('dispatched nothing');
398
+ });
399
+
400
+ it('is ok when the newest run succeeded and is fresh', () => {
401
+ insertHookModule('celilo-mgmt');
402
+ bus.emitRaw('build-bus.publish', { eventId: 'evt-1' });
403
+ seedRun();
404
+ const f = checkBuildBusPublishing(bus, db, { now });
405
+ expect(f.status).toBe('ok');
406
+ expect(f.summary).toContain('@celilo/cli@2.2.1');
407
+ });
408
+ });
409
+
295
410
  describe('checkSubscribers', () => {
296
411
  it('fails when a deployed module declares a subscription the bus is missing', () => {
297
412
  insertModule(
@@ -449,7 +564,11 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
449
564
  .run();
450
565
  const f = checkCapabilityProviders(db);
451
566
  expect(f.status).toBe('ok');
452
- expect(f.detail.join(' ')).toContain('celilo capability chain');
567
+ // celilo#1308: `celilo capability chain` no longer exists, so the detail
568
+ // says the verification is by hand rather than naming a command that
569
+ // cannot run.
570
+ expect(f.detail.join(' ')).toContain('verify these by hand');
571
+ expect(f.detail.join(' ')).not.toContain('celilo capability chain');
453
572
  });
454
573
  });
455
574
 
@@ -980,7 +1099,7 @@ describe('runFleetChecks includes host liveness (#728)', () => {
980
1099
  bus.close();
981
1100
  db.$client.close();
982
1101
  resetTestDbPath();
983
- process.env.EVENT_BUS_DB = undefined;
1102
+ delete process.env.EVENT_BUS_DB;
984
1103
  try {
985
1104
  rmSync(dir, { recursive: true, force: true });
986
1105
  } 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';
@@ -384,6 +387,172 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
384
387
  };
385
388
  }
386
389
 
390
+ /**
391
+ * The build-bus self-update chain, asserted outcome-first like every check
392
+ * here. Four facts separate the ways `on_upstream_publish` silently does
393
+ * nothing (celilo#1304: the CLI on a management box aged in place while the
394
+ * doctor's dispatcher check read green — that check covers the event-bus
395
+ * dispatcher, which no build-bus hook ever travels through):
396
+ *
397
+ * 1. no module declares the hook → nothing can self-update; not in use
398
+ * 2. `build-bus.publish` events arrive → the webhook path works end to end
399
+ * 3. hook-run rows exist for an event → the dispatcher ran matching hooks
400
+ * 4. the newest run exited 0 → the self-update itself succeeded
401
+ *
402
+ * Facts 3 and 4 read the `build_bus_hook_runs` ledger the dispatcher writes;
403
+ * before that ledger existed, a failed self-update existed only as daemon
404
+ * console output, which no surface reads.
405
+ */
406
+ export function checkBuildBusPublishing(
407
+ bus: Bus,
408
+ db: DbClient,
409
+ opts: { now?: number } = {},
410
+ ): FleetFinding {
411
+ const now = opts.now ?? Date.now();
412
+
413
+ const hookModules = db
414
+ .select({ id: modules.id })
415
+ .from(modules)
416
+ .where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
417
+ .all()
418
+ .filter((m) => hasUpstreamPublishHooks(db, m.id));
419
+
420
+ const finding = (
421
+ status: FleetFindingStatus,
422
+ summary: string,
423
+ detail: string[],
424
+ remediation: string | null,
425
+ ): FleetFinding => ({
426
+ id: 'build-bus-publishing',
427
+ title: 'build-bus webhooks arriving and self-update hooks running',
428
+ status,
429
+ summary,
430
+ detail,
431
+ remediation,
432
+ autoFixable: false,
433
+ });
434
+
435
+ if (hookModules.length === 0) {
436
+ return finding(
437
+ 'ok',
438
+ 'no installed module declares on_upstream_publish — build-bus self-update not in use here',
439
+ [],
440
+ null,
441
+ );
442
+ }
443
+
444
+ const newestPublish = bus.recentEvents({ type: 'build-bus.publish', limit: 1 })[0];
445
+ if (!newestPublish) {
446
+ return finding(
447
+ 'warn',
448
+ `${hookModules.map((m) => m.id).join(', ')} self-update can never fire: no build-bus publish has ever arrived`,
449
+ [
450
+ '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.',
451
+ ],
452
+ "start the receiver: `celilo subscribers serve --secret $CELILO_BUS_SECRET` under a supervisor unit, and register this host in the publisher's build-bus-subscribers.json with the same secret (export CELILO_BUS_SECRET in that unit)",
453
+ );
454
+ }
455
+
456
+ const runs = db
457
+ .select()
458
+ .from(buildBusHookRuns)
459
+ .orderBy(desc(buildBusHookRuns.id))
460
+ .limit(10)
461
+ .all();
462
+
463
+ const detail: string[] = [
464
+ `newest publish: ${newestPublish.emittedAt ? `${Math.round((now - newestPublish.emittedAt) / 60000)}min ago` : 'unknown age'}`,
465
+ ];
466
+
467
+ const newest = runs[0];
468
+ if (!newest) {
469
+ return finding(
470
+ 'warn',
471
+ 'publish events arrive but no self-update hook has ever run',
472
+ [
473
+ ...detail,
474
+ '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.',
475
+ ],
476
+ "check how the receiver daemon is started (drop --no-dispatch) and compare the publish events against the installed modules' on_upstream_publish match rules",
477
+ );
478
+ }
479
+
480
+ const runAgeMin = newest.ranAt ? Math.round((now - newest.ranAt.getTime()) / 60000) : null;
481
+ const runLine = `newest hook run: ${newest.packageName}@${newest.packageVersion} on ${newest.moduleId}${runAgeMin != null ? `, ${runAgeMin}min ago` : ''}`;
482
+
483
+ if (newest.timedOut) {
484
+ return finding(
485
+ 'warn',
486
+ `self-update hook timed out after ${newest.durationMs}ms`,
487
+ [
488
+ ...detail,
489
+ runLine,
490
+ ` stderr tail: ${(newest.stderrTail ?? '(none)').trim().slice(0, 500)}`,
491
+ ],
492
+ 'inspect the ledger: `select * from build_bus_hook_runs order by id desc limit 10` in the celilo DB',
493
+ );
494
+ }
495
+ if (newest.exitCode !== 0) {
496
+ return finding(
497
+ 'warn',
498
+ `self-update hook exited ${newest.exitCode ?? 'without an exit code (spawn failure)'}`,
499
+ [
500
+ ...detail,
501
+ runLine,
502
+ ` stderr tail: ${(newest.stderrTail ?? '(none)').trim().slice(0, 500)}`,
503
+ ],
504
+ 'fix the failing hook script, then re-publish or re-emit the build-bus.publish event',
505
+ );
506
+ }
507
+
508
+ // A success that predates the newest publish means the newest publish was
509
+ // dropped on the dispatch side even though earlier ones ran. The ledger's
510
+ // ran_at has second resolution (unixepoch), so the event's millisecond
511
+ // stamp is truncated to the same granularity before comparing — otherwise
512
+ // a run in the same second as its event reads as older than it.
513
+ const newestPublishSec = Math.floor((newestPublish.emittedAt ?? 0) / 1000) * 1000;
514
+ if (newest.ranAt && newest.ranAt.getTime() < newestPublishSec) {
515
+ return finding(
516
+ 'warn',
517
+ 'newest publish event predates the newest hook run — that publish dispatched nothing',
518
+ [...detail, runLine],
519
+ 'check the receiver daemon logs for that event and the match rules against what the publisher sent',
520
+ );
521
+ }
522
+
523
+ return finding(
524
+ 'ok',
525
+ `last self-update hook succeeded (${newest.packageName}@${newest.packageVersion})`,
526
+ [...detail, runLine],
527
+ null,
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Does module `id`'s installed manifest declare an `on_upstream_publish`
533
+ * hook? Reads the manifest from disk exactly as the hook dispatcher's own
534
+ * loader does, so the check and the dispatcher cannot disagree about what
535
+ * "in use" means. A manifest that has vanished or fails to parse reads as
536
+ * "no hooks" — the dispatcher skips it silently too (Rule 6.2: the skip is
537
+ * visible here as fact 1 rather than as a crash).
538
+ */
539
+ function hasUpstreamPublishHooks(db: DbClient, moduleId: string): boolean {
540
+ const row = db
541
+ .select({ sourcePath: modules.sourcePath })
542
+ .from(modules)
543
+ .where(eq(modules.id, moduleId))
544
+ .get();
545
+ if (!row?.sourcePath) return false;
546
+ try {
547
+ const parsed = parseYaml(readFileSync(join(row.sourcePath, 'manifest.yml'), 'utf-8')) as {
548
+ hooks?: { on_upstream_publish?: unknown[] };
549
+ };
550
+ return (parsed.hooks?.on_upstream_publish ?? []).length > 0;
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+
387
556
  /** A deployed module + its parsed manifest (INSTALLED/VERIFIED only). */
388
557
  interface DeployedModule {
389
558
  id: string;
@@ -668,7 +837,10 @@ export function checkCapabilityProviders(db: DbClient): FleetFinding {
668
837
  }
669
838
  if (traceNeeded.length > 0) {
670
839
  detail.push(
671
- `${traceNeeded.length} derived value(s) resolve through another capability — verify with \`celilo capability chain <module> <var>\` (ISS-0114): ${traceNeeded.join('; ')}`,
840
+ // celilo#1308: `celilo capability chain` no longer exists (the ISS-0115
841
+ // rework removed it), so prescribing it sent the operator to an
842
+ // "unknown command" error. No CLI command traces a chain; say so.
843
+ `${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
844
  );
673
845
  }
674
846
 
@@ -1194,6 +1366,7 @@ export async function runFleetChecks(
1194
1366
  return [
1195
1367
  checkSchemaDrift(db),
1196
1368
  checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
1369
+ checkBuildBusPublishing(bus, db, { now: opts.now }),
1197
1370
  checkSubscribers(bus, db),
1198
1371
  checkCapabilityProviders(db),
1199
1372
  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
+ });