@celilo/cli 2.1.0 → 2.2.1

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 (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { createHash } from 'node:crypto';
2
3
  import type { IndexEntry } from './client';
3
4
  import { DEFAULT_REGISTRY, RegistryClient } from './client';
4
5
 
@@ -312,3 +313,151 @@ describe('RegistryClient owner methods', () => {
312
313
  );
313
314
  });
314
315
  });
316
+
317
+ // ── get() retry ───────────────────────────────────────────────────────────────
318
+ //
319
+ // Every registry read goes through one private `get()`. It used to make exactly
320
+ // one attempt, so a single transient — the shape the 15s→30s timeout bump was
321
+ // already chasing — failed a module install outright. Measured 2026-09-04:
322
+ // `module import iptables` failed twice with "Download failed: The operation
323
+ // timed out" while the identical call had succeeded minutes earlier.
324
+
325
+ describe('RegistryClient.get retry', () => {
326
+ let fetchSpy: ReturnType<typeof spyOn>;
327
+
328
+ beforeEach(() => {
329
+ fetchSpy = spyOn(globalThis, 'fetch');
330
+ });
331
+
332
+ afterEach(() => {
333
+ fetchSpy.mockRestore();
334
+ });
335
+
336
+ test('a transient network failure is retried and the download succeeds', async () => {
337
+ const payload = new Uint8Array([1, 2, 3, 4]);
338
+ fetchSpy
339
+ .mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError'))
340
+ .mockResolvedValueOnce(new Response(payload, { status: 200 }));
341
+
342
+ const client = new RegistryClient('https://reg.example.com');
343
+ const data = await client.download('iptables', '3.1.4');
344
+
345
+ expect(new Uint8Array(data)).toEqual(payload);
346
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
347
+ });
348
+
349
+ test('a 5xx is retried', async () => {
350
+ fetchSpy
351
+ .mockResolvedValueOnce(new Response(null, { status: 503 }))
352
+ .mockResolvedValueOnce(new Response(new Uint8Array([9]), { status: 200 }));
353
+
354
+ const client = new RegistryClient('https://reg.example.com');
355
+ await client.download('iptables', '3.1.4');
356
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
357
+ });
358
+
359
+ test('a 404 is NOT retried — it is an answer, not a transient', async () => {
360
+ fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
361
+
362
+ const client = new RegistryClient('https://reg.example.com');
363
+ await expect(client.download('nope', '1.0.0')).rejects.toThrow('HTTP 404');
364
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
365
+ });
366
+
367
+ test('gives up after a bounded number of attempts', async () => {
368
+ fetchSpy.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
369
+
370
+ const client = new RegistryClient('https://reg.example.com');
371
+ const error = await client.download('iptables', '3.1.4').then(
372
+ () => undefined,
373
+ (e: unknown) => e,
374
+ );
375
+ expect(error).toBeInstanceOf(Error);
376
+ // The exhaustion report is the event, not the last attempt (celilo#1264):
377
+ // the bare lastError read as an unretried first attempt, because that is
378
+ // exactly what the same string meant before the retry existed.
379
+ expect((error as Error).message).toMatch(/failed after 3 attempts over \d+(\.\d+)?s/);
380
+ expect((error as Error).message).toContain('The operation timed out.');
381
+ expect((error as { cause?: unknown }).cause).toBeInstanceOf(DOMException);
382
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
383
+ });
384
+ });
385
+
386
+ // ── download integrity ────────────────────────────────────────────────────────
387
+ //
388
+ // `cksum` is computed at publish time, shipped in every index entry, and was
389
+ // never checked on the consuming side. A short download was therefore accepted
390
+ // and blew up later in gunzip as "zlib: unexpected end of file", which blames
391
+ // the package rather than the transfer. Measured 2026-09-04 in the e2e rig.
392
+ // Retrying cannot help a fault it cannot detect, so verification is what makes
393
+ // the retry meaningful.
394
+
395
+ describe('RegistryClient.download integrity', () => {
396
+ let fetchSpy: ReturnType<typeof spyOn>;
397
+ const body = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
398
+ // sha256 of the 8 bytes above, in the `sha256:<hex>` shape publish() writes.
399
+ const goodCksum = `sha256:${createHash('sha256').update(body).digest('hex')}`;
400
+
401
+ beforeEach(() => {
402
+ fetchSpy = spyOn(globalThis, 'fetch');
403
+ });
404
+
405
+ afterEach(() => {
406
+ fetchSpy.mockRestore();
407
+ });
408
+
409
+ test('a truncated payload is rejected and retried, then succeeds', async () => {
410
+ fetchSpy
411
+ .mockResolvedValueOnce(new Response(body.slice(0, 3), { status: 200 })) // short
412
+ .mockResolvedValueOnce(new Response(body, { status: 200 }));
413
+
414
+ const client = new RegistryClient('https://reg.example.com');
415
+ const data = await client.download('iptables', '3.1.4', goodCksum);
416
+
417
+ expect(new Uint8Array(data)).toEqual(body);
418
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
419
+ });
420
+
421
+ test('a payload that never verifies fails with an error naming the integrity check', async () => {
422
+ // A fresh Response per call: one shared instance would be consumed by the
423
+ // first attempt and the retry would report "Body already used", hiding the
424
+ // integrity failure this test exists to assert.
425
+ fetchSpy.mockImplementation(() => new Response(body.slice(0, 3), { status: 200 }));
426
+
427
+ const client = new RegistryClient('https://reg.example.com');
428
+ await expect(client.download('iptables', '3.1.4', goodCksum)).rejects.toThrow(
429
+ /integrity check/i,
430
+ );
431
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
432
+ });
433
+
434
+ test('a correct payload verifies on the first attempt', async () => {
435
+ fetchSpy.mockResolvedValue(new Response(body, { status: 200 }));
436
+
437
+ const client = new RegistryClient('https://reg.example.com');
438
+ await client.download('iptables', '3.1.4', goodCksum);
439
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ test('no expected cksum still downloads — callers may not have one', async () => {
443
+ fetchSpy.mockResolvedValue(new Response(body, { status: 200 }));
444
+
445
+ const client = new RegistryClient('https://reg.example.com');
446
+ const data = await client.download('iptables', '3.1.4');
447
+ expect(new Uint8Array(data)).toEqual(body);
448
+ });
449
+
450
+ test('a non-digest cksum sentinel skips verification instead of failing', async () => {
451
+ // The registry's bootstrap path publishes `cksum: 'bootstrap'`
452
+ // (packages/registry-server/src/bootstrap.ts:114) because those modules are
453
+ // packaged on demand and have no stable digest. Treating a sentinel as a
454
+ // digest rejects a perfectly good 2.7MB package on every import.
455
+ fetchSpy.mockImplementation(() => new Response(body, { status: 200 }));
456
+
457
+ const client = new RegistryClient('https://reg.example.com');
458
+ const data = await client.download('iptables', '3.1.4', 'bootstrap');
459
+
460
+ expect(new Uint8Array(data)).toEqual(body);
461
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
462
+ });
463
+ });
@@ -11,6 +11,98 @@ import { readFile } from 'node:fs/promises';
11
11
 
12
12
  export const DEFAULT_REGISTRY = 'https://celilo.computer/registry';
13
13
 
14
+ /** Attempts per idempotent registry read, including the first. */
15
+ const GET_ATTEMPTS = 3;
16
+ /** Per-attempt ceiling for metadata reads. Packages use DOWNLOAD_TIMEOUT_MS. */
17
+ const GET_TIMEOUT_MS = 30_000;
18
+ /** Linear backoff: 1s, then 2s. Short, because a deploy is waiting on this. */
19
+ const GET_RETRY_DELAY_MS = 1_000;
20
+ /**
21
+ * Per-attempt ceiling for a package download.
22
+ *
23
+ * This was briefly 180s, on the reasoning that a package is megabytes and
24
+ * metadata is not. That was wrong, and measurably so: the whole retry budget has
25
+ * to fit inside the CALLER's window, and celilo's own e2e harness gives a command
26
+ * 120s by default (`packages/e2e/src/container-manager.ts`). Three attempts at
27
+ * 180s allows ~543s, so a slow download stopped failing as a download and started
28
+ * being killed by the caller — a worse error, further from the cause.
29
+ *
30
+ * 30s x 3 attempts plus backoff is ~93s, which fits. The durability comes from
31
+ * retrying, not from a wider window: a 2.6MB transfer that normally takes about a
32
+ * second does not need 180s, it needs another go.
33
+ */
34
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
35
+
36
+ /**
37
+ * Read a body to completion, reporting progress so a failure can say how far it
38
+ * got. `Response.arrayBuffer()` gives no partial count when it throws.
39
+ */
40
+ async function readCounting(
41
+ resp: Response,
42
+ onProgress: (bytes: number) => void,
43
+ ): Promise<ArrayBuffer> {
44
+ if (!resp.body) return resp.arrayBuffer();
45
+
46
+ const reader = resp.body.getReader();
47
+ const chunks: Uint8Array[] = [];
48
+ let total = 0;
49
+
50
+ try {
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done) break;
54
+ if (value) {
55
+ chunks.push(value);
56
+ total += value.byteLength;
57
+ onProgress(total);
58
+ }
59
+ }
60
+ } finally {
61
+ reader.releaseLock();
62
+ }
63
+
64
+ const out = new Uint8Array(total);
65
+ let offset = 0;
66
+ for (const chunk of chunks) {
67
+ out.set(chunk, offset);
68
+ offset += chunk.byteLength;
69
+ }
70
+ return out.buffer;
71
+ }
72
+
73
+ /**
74
+ * A status worth trying again. 5xx is the server failing, 408 and 429 are it
75
+ * asking us to wait. Every other 4xx is a settled answer.
76
+ */
77
+ function isRetryableStatus(status: number): boolean {
78
+ return status >= 500 || status === 408 || status === 429;
79
+ }
80
+
81
+ /**
82
+ * A settled answer from the registry. Retrying cannot change it, so `withRetry`
83
+ * rethrows it immediately rather than spending the backoff on a known 404.
84
+ */
85
+ class RegistryAnswer extends Error {}
86
+
87
+ /** `publish` writes `sha256:<hex>`; tolerate a bare hex digest from older entries. */
88
+ function normalizeCksum(value: string): string {
89
+ return value.replace(/^sha256:/, '').toLowerCase();
90
+ }
91
+
92
+ /**
93
+ * Is this index entry's `cksum` an actual digest we can check against?
94
+ *
95
+ * Not every entry carries one. The registry's bootstrap path publishes the
96
+ * literal string `bootstrap` (packages/registry-server/src/bootstrap.ts), because
97
+ * those modules are packaged on demand and have no stable digest to publish.
98
+ * A sentinel means "no integrity data", which is a reason to skip the check and
99
+ * not a reason to reject the package — treating it as a digest rejects every
100
+ * bootstrap-served import.
101
+ */
102
+ function isVerifiableCksum(value: string): boolean {
103
+ return /^[0-9a-f]{64}$/.test(normalizeCksum(value));
104
+ }
105
+
14
106
  export interface SparseConfig {
15
107
  dl: string;
16
108
  api: string;
@@ -109,9 +201,56 @@ export class RegistryClient {
109
201
  return `${this.baseUrl}/api/v1/modules/${encodeURIComponent(name)}/${encodeURIComponent(version)}/download`;
110
202
  }
111
203
 
112
- async download(name: string, version: string): Promise<ArrayBuffer> {
113
- const resp = await this.get(this.downloadUrl(name, version));
114
- return resp.arrayBuffer();
204
+ /**
205
+ * Fetch a module .netapp, verifying it against the index entry's `cksum`.
206
+ *
207
+ * `cksum` has always been computed at publish time and shipped in every index
208
+ * entry, and nothing on this side ever checked it. A short transfer was
209
+ * therefore accepted as a complete package and failed later in gunzip as
210
+ * "zlib: unexpected end of file", which points at the package rather than at
211
+ * the download that produced it.
212
+ *
213
+ * Verification is also what makes the retry mean anything: a fault nobody can
214
+ * detect is a fault nobody can retry. Pass `expectedCksum` whenever the caller
215
+ * holds the entry — both callers do.
216
+ */
217
+ async download(name: string, version: string, expectedCksum?: string): Promise<ArrayBuffer> {
218
+ const url = this.downloadUrl(name, version);
219
+
220
+ return this.withRetry(async () => {
221
+ const startedAt = Date.now();
222
+ const resp = await this.fetchOnce(url, DOWNLOAD_TIMEOUT_MS);
223
+
224
+ // Read in chunks so a failure can say how far it got. A bare
225
+ // `arrayBuffer()` that times out reports nothing, which is why three runs
226
+ // of this failure told us only that it was slow and never how slow. Bytes
227
+ // and elapsed together separate a steady trickle from a stall, and those
228
+ // two want different fixes (a bigger window versus an idle timeout).
229
+ let data: ArrayBuffer;
230
+ let received = 0;
231
+ try {
232
+ data = await readCounting(resp, (n) => {
233
+ received = n;
234
+ });
235
+ } catch (err) {
236
+ const seconds = (Date.now() - startedAt) / 1000;
237
+ const rate = seconds > 0 ? received / 1024 / seconds : 0;
238
+ throw new Error(
239
+ `Download of ${name}@${version} failed after ${received} bytes in ${seconds.toFixed(1)}s (${rate.toFixed(0)} KiB/s): ${err instanceof Error ? err.message : String(err)}`,
240
+ );
241
+ }
242
+
243
+ if (expectedCksum && isVerifiableCksum(expectedCksum)) {
244
+ const actual = createHash('sha256').update(new Uint8Array(data)).digest('hex');
245
+ if (actual !== normalizeCksum(expectedCksum)) {
246
+ throw new Error(
247
+ `Package ${name}@${version} failed its integrity check: got ${data.byteLength} bytes with sha256 ${actual}, expected ${normalizeCksum(expectedCksum)}. A short or corrupted download, not a bad package.`,
248
+ );
249
+ }
250
+ }
251
+
252
+ return data;
253
+ });
115
254
  }
116
255
 
117
256
  /**
@@ -232,14 +371,67 @@ export class RegistryClient {
232
371
  }
233
372
 
234
373
  private async get(url: string): Promise<Response> {
235
- // 30s (was 15s): a module .netapp can be tens of MB and the download must
236
- // finish within one window. 15s was too aggressive on slow links — the
237
- // download of a ~19MB module over a multi-hop path (e2e sim NAT, real WAN)
238
- // intermittently timed out mid-transfer.
239
- const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
240
- if (!resp.ok) {
241
- throw new Error(`Registry error at ${url}: HTTP ${resp.status}`);
374
+ // 30s per attempt: a module .netapp can be tens of MB, and the download of a
375
+ // ~19MB module over a multi-hop path (e2e sim NAT, real WAN) intermittently
376
+ // times out mid-transfer.
377
+ //
378
+ // The window used to be the whole story — one attempt, and the response to
379
+ // observed flakiness was widening it from 15s to 30s. A wider single window
380
+ // is not a durable fetch, it is a bigger gap to fall through. Measured
381
+ // 2026-09-04: `module import iptables` failed twice with "Download failed:
382
+ // The operation timed out" on a loaded host, while the identical call had
383
+ // succeeded minutes earlier. This client is the fleet's module delivery
384
+ // path, so a single transient failing an import is an operator-facing
385
+ // outage, not just an e2e flake.
386
+ //
387
+ // Every caller of get() is an idempotent read (config, search, metadata,
388
+ // download), so retrying is safe. `publish` does not route through here.
389
+ // A 4xx is an answer and is never retried: a 404 must stay fast and say
390
+ // "not found" rather than stall for the whole backoff.
391
+ return this.withRetry(() => this.fetchOnce(url));
392
+ }
393
+
394
+ /** One attempt. Throws `RegistryAnswer` for a status retrying cannot change. */
395
+ private async fetchOnce(url: string, timeoutMs = GET_TIMEOUT_MS): Promise<Response> {
396
+ const resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
397
+ if (resp.ok) return resp;
398
+
399
+ const message = `Registry error at ${url}: HTTP ${resp.status}`;
400
+ if (!isRetryableStatus(resp.status)) throw new RegistryAnswer(message);
401
+ throw new Error(message);
402
+ }
403
+
404
+ /**
405
+ * Retry a whole operation, not a piece of one.
406
+ *
407
+ * The unit matters. Retrying only the request leaves the body download
408
+ * outside the retry, and a body is where a large transfer actually fails.
409
+ */
410
+ private async withRetry<T>(run: () => Promise<T>): Promise<T> {
411
+ const startedAt = Date.now();
412
+ let lastError: unknown;
413
+
414
+ for (let attempt = 1; attempt <= GET_ATTEMPTS; attempt++) {
415
+ try {
416
+ return await run();
417
+ } catch (err) {
418
+ if (err instanceof RegistryAnswer) throw err;
419
+ lastError = err;
420
+ if (attempt < GET_ATTEMPTS) {
421
+ await new Promise((resolve) => setTimeout(resolve, GET_RETRY_DELAY_MS * attempt));
422
+ }
423
+ }
242
424
  }
243
- return resp;
425
+
426
+ // Exhausting the budget is itself the event worth reporting. The bare last
427
+ // error said only what the final attempt said, which is the same string the
428
+ // caller got before the retry existed, so the operator cannot tell whether
429
+ // the retry ran (celilo#1264). The original error rides along as `cause`.
430
+ const seconds = (Date.now() - startedAt) / 1000;
431
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
432
+ throw new Error(
433
+ `Registry fetch failed after ${GET_ATTEMPTS} attempts over ${seconds.toFixed(1)}s: ${detail}`,
434
+ { cause: lastError },
435
+ );
244
436
  }
245
437
  }
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { type Route, alerts, modules, monitors } from '../../db/schema';
7
7
  import { setupTestDatabaseAt } from '../../test-utils/database';
8
+ import { resetTestDbPath } from '../../test-utils/db-path';
8
9
  import { acknowledgeAlert, findLiveAlertByKey, resolveAlertManually, silenceAlert } from './ack';
9
10
  import { moduleCheckAlertKey } from './keys';
10
11
  import { createPerson, createRoute } from './people';
@@ -79,7 +80,7 @@ describe('ack / silence / resolve', () => {
79
80
 
80
81
  afterEach(() => {
81
82
  db.$client.close();
82
- process.env.CELILO_DB_PATH = undefined;
83
+ resetTestDbPath();
83
84
  try {
84
85
  rmSync(dir, { recursive: true, force: true });
85
86
  } catch {
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { closeDb, getDb } from '../../db/client';
17
17
  import { runMigrations } from '../../db/migrate';
18
18
  import { modules, monitors } from '../../db/schema';
19
+ import { resetTestDbPath } from '../../test-utils/db-path';
19
20
  import { getModuleConfigValue, upsertModuleConfig } from '../module-config';
20
21
  import { migrateMonitorCadences } from './cadence-migration';
21
22
  import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, loadModuleHealthCadences } from './health-cadence';
@@ -66,7 +67,7 @@ describe('migrateMonitorCadences', () => {
66
67
 
67
68
  afterEach(() => {
68
69
  closeDb();
69
- process.env.CELILO_DB_PATH = undefined;
70
+ resetTestDbPath();
70
71
  rmSync(dir, { recursive: true, force: true });
71
72
  });
72
73
 
@@ -132,7 +133,7 @@ describe("a module_hook row's stored cadence is not consulted", () => {
132
133
 
133
134
  afterEach(() => {
134
135
  closeDb();
135
- process.env.CELILO_DB_PATH = undefined;
136
+ resetTestDbPath();
136
137
  rmSync(dir, { recursive: true, force: true });
137
138
  });
138
139
 
@@ -20,6 +20,7 @@ import { join } from 'node:path';
20
20
  import type { DbClient } from '../../db/client';
21
21
  import { modules } from '../../db/schema';
22
22
  import { setupTestDatabaseAt } from '../../test-utils/database';
23
+ import { resetTestDbPath } from '../../test-utils/db-path';
23
24
  import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
24
25
  import { loadModuleCoverage } from './coverage-source';
25
26
  import { healthCoverageFailingKeys } from './health-coverage';
@@ -37,7 +38,7 @@ describe('health coverage and the control plane', () => {
37
38
 
38
39
  afterEach(() => {
39
40
  db.$client.close();
40
- process.env.CELILO_DB_PATH = undefined;
41
+ resetTestDbPath();
41
42
  try {
42
43
  rmSync(dir, { recursive: true, force: true });
43
44
  } catch {
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Alert, type Route, alerts, modules, monitors } from '../../db/schema';
8
8
  import { setupTestDatabaseAt } from '../../test-utils/database';
9
+ import { resetTestDbPath } from '../../test-utils/db-path';
9
10
  import { moduleCheckAlertKey } from './keys';
10
11
  import { deliverDeferred } from './notifier';
11
12
  import { createPerson, createRoute } from './people';
@@ -80,7 +81,7 @@ describe('quiet-hours deferral', () => {
80
81
 
81
82
  afterEach(() => {
82
83
  db.$client.close();
83
- process.env.CELILO_DB_PATH = undefined;
84
+ resetTestDbPath();
84
85
  try {
85
86
  rmSync(dir, { recursive: true, force: true });
86
87
  } catch {
@@ -21,6 +21,7 @@ import { join } from 'node:path';
21
21
  import type { DbClient } from '../../db/client';
22
22
  import { type Alert, type Route, modules } from '../../db/schema';
23
23
  import { setupTestDatabaseAt } from '../../test-utils/database';
24
+ import { resetTestDbPath } from '../../test-utils/db-path';
24
25
  import type { EscalationStep, RouteForEscalation } from './escalation';
25
26
  import { interpretInbound } from './inbound';
26
27
  import {
@@ -249,7 +250,7 @@ describe('delivery loop against the signal-cli simulator', () => {
249
250
 
250
251
  afterEach(() => {
251
252
  db.$client.close();
252
- process.env.CELILO_DB_PATH = undefined;
253
+ resetTestDbPath();
253
254
  try {
254
255
  rmSync(dir, { recursive: true, force: true });
255
256
  } catch {
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { modules, monitors } from '../../db/schema';
7
7
  import { setupTestDatabaseAt } from '../../test-utils/database';
8
+ import { resetTestDbPath } from '../../test-utils/db-path';
8
9
  import {
9
10
  closeDeployWindows,
10
11
  ensureMonitorOnDeploy,
@@ -34,7 +35,7 @@ describe('deploy hooks', () => {
34
35
 
35
36
  afterEach(() => {
36
37
  db.$client.close();
37
- process.env.CELILO_DB_PATH = undefined;
38
+ resetTestDbPath();
38
39
  try {
39
40
  rmSync(dir, { recursive: true, force: true });
40
41
  } catch {
@@ -6,9 +6,11 @@ import {
6
6
  humaniseRemaining,
7
7
  moduleHealthCell,
8
8
  renderAlertTable,
9
+ renderMonitorRunMessage,
9
10
  sortAlertRows,
10
11
  toAlertRow,
11
12
  } from './format';
13
+ import type { MonitorRunOutcomeSummary } from './run-monitor';
12
14
 
13
15
  const NOW = new Date('2026-07-28T12:00:00Z');
14
16
  const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000);
@@ -190,3 +192,58 @@ describe('moduleHealthCell', () => {
190
192
  );
191
193
  });
192
194
  });
195
+
196
+ describe('renderMonitorRunMessage — names the failing check (#1266)', () => {
197
+ function summary(over: Partial<MonitorRunOutcomeSummary>): MonitorRunOutcomeSummary {
198
+ return {
199
+ monitorId: 'mon-1',
200
+ outcome: 'success',
201
+ createdIds: [],
202
+ resolvedIds: [],
203
+ ...over,
204
+ } as MonitorRunOutcomeSummary;
205
+ }
206
+
207
+ const caddyRunning = {
208
+ key: 'module:caddy/check:caddy_running',
209
+ severity: 'critical' as const,
210
+ message: 'caddy systemd service is not active',
211
+ };
212
+
213
+ test('a failing check is named, with its reason', () => {
214
+ const message = renderMonitorRunMessage('caddy', summary({ failingKeys: [caddyRunning] }));
215
+ expect(message).toContain('module:caddy/check:caddy_running');
216
+ expect(message).toContain('caddy systemd service is not active');
217
+ expect(message).toContain('1 failing');
218
+ });
219
+
220
+ test('every failing key is named, not just the first', () => {
221
+ const diskSpace = {
222
+ key: 'module:caddy/check:disk-space',
223
+ severity: 'warning' as const,
224
+ message: '/var 94% used',
225
+ };
226
+ const message = renderMonitorRunMessage(
227
+ 'caddy',
228
+ summary({ failingKeys: [caddyRunning, diskSpace] }),
229
+ );
230
+ expect(message).toContain('module:caddy/check:caddy_running');
231
+ expect(message).toContain('module:caddy/check:disk-space');
232
+ expect(message).toContain('2 failing');
233
+ });
234
+
235
+ test('a healthy run keeps the zero the suite greps for', () => {
236
+ const message = renderMonitorRunMessage('caddy', summary({ failingKeys: [] }));
237
+ expect(message).toContain('0 failing');
238
+ expect(message).toContain('0 resolved');
239
+ });
240
+
241
+ test('a run that could not execute says why instead of inventing a count', () => {
242
+ const message = renderMonitorRunMessage(
243
+ 'caddy',
244
+ summary({ outcome: 'error', errorMessage: 'hook exited 1' }),
245
+ );
246
+ expect(message).toContain('check could not run');
247
+ expect(message).toContain('hook exited 1');
248
+ });
249
+ });
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Alert, AlertState } from '../../db/schema';
13
+ import type { MonitorRunOutcomeSummary } from './run-monitor';
13
14
 
14
15
  export interface AlertRow {
15
16
  key: string;
@@ -148,3 +149,26 @@ export function moduleHealthCell(input: {
148
149
  if (input.firingCount > 0) return `${input.firingCount} firing`;
149
150
  return 'ok';
150
151
  }
152
+
153
+ /**
154
+ * The message `celilo monitor run <target>` reports.
155
+ *
156
+ * The count line stays greppable (`0 failing` is what a healthy fleet prints),
157
+ * and each failing key is named underneath it with its reason — an operator
158
+ * paged by this line learns WHAT failed, not just that something did (#1266).
159
+ *
160
+ * Pure — takes the run summary, returns the string — so the wording can be
161
+ * asserted without a database or a fleet.
162
+ */
163
+ export function renderMonitorRunMessage(target: string, summary: MonitorRunOutcomeSummary): string {
164
+ if (summary.outcome === 'error') {
165
+ return `${target}: check could not run — ${summary.errorMessage ?? 'unknown error'}`;
166
+ }
167
+ const lines = [
168
+ `${target}: ${summary.failingKeys.length} failing, ${summary.resolvedIds.length} resolved`,
169
+ ];
170
+ for (const failing of summary.failingKeys) {
171
+ lines.push(` [${failing.severity}] ${failing.key}: ${failing.message}`);
172
+ }
173
+ return lines.join('\n');
174
+ }
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../../db/client';
8
8
  import { type Route, alerts, modules, monitors, notificationDeliveries } from '../../db/schema';
9
9
  import { setupTestDatabaseAt } from '../../test-utils/database';
10
+ import { resetTestDbPath } from '../../test-utils/db-path';
10
11
  import {
11
12
  type InboundPollDeps,
12
13
  type TransportReadRecord,
@@ -105,7 +106,7 @@ describe('pollInbound', () => {
105
106
 
106
107
  afterEach(() => {
107
108
  db.$client.close();
108
- process.env.CELILO_DB_PATH = undefined;
109
+ resetTestDbPath();
109
110
  try {
110
111
  rmSync(dir, { recursive: true, force: true });
111
112
  } catch {
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import type { DbClient } from '../../db/client';
6
6
  import { type NotificationDelivery, modules, people, routes } from '../../db/schema';
7
7
  import { setupTestDatabaseAt } from '../../test-utils/database';
8
+ import { resetTestDbPath } from '../../test-utils/db-path';
8
9
  import { interpretInbound, parseInbound } from './inbound';
9
10
  import { mintDelivery } from './tokens';
10
11
 
@@ -107,7 +108,7 @@ describe('interpretInbound', () => {
107
108
 
108
109
  afterEach(() => {
109
110
  db.$client.close();
110
- process.env.CELILO_DB_PATH = undefined;
111
+ resetTestDbPath();
111
112
  try {
112
113
  rmSync(dir, { recursive: true, force: true });
113
114
  } catch {
@@ -6,6 +6,7 @@ import { defineEvents, openBus } from '@celilo/event-bus';
6
6
  import type { DbClient } from '../../db/client';
7
7
  import { type Route, modules } from '../../db/schema';
8
8
  import { setupTestDatabaseAt } from '../../test-utils/database';
9
+ import { resetTestDbPath } from '../../test-utils/db-path';
9
10
  import {
10
11
  type NotificationResponderHandle,
11
12
  startNotificationResponder,
@@ -88,7 +89,7 @@ describe('notification responder', () => {
88
89
  handle?.stop();
89
90
  handle = undefined;
90
91
  db.$client.close();
91
- process.env.CELILO_DB_PATH = undefined;
92
+ resetTestDbPath();
92
93
  try {
93
94
  rmSync(dir, { recursive: true, force: true });
94
95
  } catch {