@celilo/cli 2.1.0 → 2.2.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 (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -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
  }
@@ -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
+ }
@@ -47,7 +47,7 @@ export interface MonitorRunOutcomeSummary {
47
47
  monitorId: string;
48
48
  outcome: 'success' | 'error';
49
49
  errorMessage?: string;
50
- failingKeyCount: number;
50
+ failingKeys: FailingKey[];
51
51
  createdIds: string[];
52
52
  resolvedIds: string[];
53
53
  }
@@ -186,7 +186,7 @@ export async function runOneMonitor(
186
186
  monitorId: monitor.id,
187
187
  outcome: product.outcome,
188
188
  errorMessage: product.errorMessage,
189
- failingKeyCount: product.failingKeys.length,
189
+ failingKeys: product.failingKeys,
190
190
  createdIds,
191
191
  resolvedIds,
192
192
  };
@@ -6,15 +6,16 @@
6
6
  import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { eq } from 'drizzle-orm';
9
+ import { create as tarCreate } from 'tar';
9
10
  import { getDbPath, getMasterKeyPath } from '../config/paths';
10
11
  import { getDb } from '../db/client';
11
12
  import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
12
13
  import { invokeHook } from '../hooks/executor';
14
+ import { createHookStores } from '../hooks/hook-store';
13
15
  import { createConsoleLogger } from '../hooks/logger';
14
16
  import type { ModuleManifest } from '../manifest/schema';
15
17
  import { decryptSecret } from '../secrets/encryption';
16
18
  import { getOrCreateMasterKey } from '../secrets/master-key';
17
- import { shellEscape } from '../utils/shell';
18
19
  import { encryptFileToFile } from './backup-cipher';
19
20
  import { buildManifest } from './backup-manifest';
20
21
  import {
@@ -153,8 +154,7 @@ export async function createSystemStateBackup(
153
154
  // Tar the envelope. Operator can re-extract a backup file manually
154
155
  // for diagnostics: `age -d -p file.backup | tar -t` shows the contents.
155
156
  const tarPath = join(tempDir, 'envelope.tar');
156
- const { execSync } = await import('node:child_process');
157
- execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
157
+ await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']);
158
158
 
159
159
  // Encrypt the tar, streamed — see backup-cipher.ts. The plaintext is
160
160
  // never held in memory, so a large fleet DB can't OOM the snapshot.
@@ -401,6 +401,7 @@ export async function createModuleBackup(
401
401
  debug: false,
402
402
  systems: getModuleSystems(moduleId, db),
403
403
  remoteAccess: remoteAccessPolicy(moduleId, db),
404
+ hookStores: () => createHookStores(db, moduleId),
404
405
  },
405
406
  );
406
407
 
@@ -434,8 +435,7 @@ export async function createModuleBackup(
434
435
 
435
436
  // Tar the envelope (manifest.json + data/).
436
437
  const tarPath = join(tempDir, 'envelope.tar');
437
- const { execSync } = await import('node:child_process');
438
- execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
438
+ await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']);
439
439
 
440
440
  // Encrypt the tar, streamed — see backup-cipher.ts. Module artifacts run
441
441
  // to hundreds of MB (forgejo's are ~774 MB); holding one in memory is
@@ -566,6 +566,7 @@ export async function importModuleBackup(
566
566
  debug: false,
567
567
  systems: getModuleSystems(moduleId, db),
568
568
  remoteAccess: remoteAccessPolicy(moduleId, db),
569
+ hookStores: () => createHookStores(db, moduleId),
569
570
  },
570
571
  );
571
572
 
@@ -590,8 +591,7 @@ export async function importModuleBackup(
590
591
 
591
592
  // Tar the artifacts
592
593
  const tarPath = join(tempDir, 'backup.tar');
593
- const { execSync } = await import('node:child_process');
594
- execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
594
+ await tarCreate({ file: tarPath, cwd: artifactDir }, ['.']);
595
595
 
596
596
  // Encrypt the tar, streamed — see backup-cipher.ts.
597
597
  const masterKey = await getOrCreateMasterKey();
@@ -16,16 +16,25 @@
16
16
 
17
17
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
18
18
  import { execSync } from 'node:child_process';
19
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
+ import {
20
+ copyFileSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from 'node:fs';
20
28
  import { tmpdir } from 'node:os';
21
29
  import { join } from 'node:path';
30
+ import { getDbPath } from '../config/paths';
22
31
  import { closeDb, getDb } from '../db/client';
23
32
  import { runMigrations } from '../db/migrate';
24
33
  import { backups, systemConfig } from '../db/schema';
25
34
  import { getOrCreateMasterKey } from '../secrets/master-key';
26
35
  import { decryptFileToFile, encryptFileToFile } from './backup-cipher';
27
36
  import { createSystemStateBackup } from './backup-create';
28
- import { MANIFEST_SCHEMA_VERSION, parseManifest } from './backup-manifest';
37
+ import { MANIFEST_SCHEMA_VERSION, buildManifest, parseManifest } from './backup-manifest';
29
38
  import { restoreSystemStateBackup } from './backup-restore';
30
39
  import { addBackupStorage, setDefaultBackupStorage, verifyBackupStorage } from './backup-storage';
31
40
 
@@ -121,6 +130,40 @@ describe('backup envelope round-trip', () => {
121
130
  expect(restoreResult.error).toBeUndefined();
122
131
  });
123
132
 
133
+ it('an archive written by SYSTEM tar (the pre-library producer) restores via the tar library', async () => {
134
+ // The tar library replaced `tar -cf/-xf` shell-outs (celilo#1235). A
135
+ // self-consistent round-trip cannot detect a layout change, because the
136
+ // new extract sites would happily read back the new layout. So this test
137
+ // builds the envelope with the SYSTEM tar — byte-for-byte the command the
138
+ // old code ran — and restores it with the current code, proving a backup
139
+ // taken BEFORE the change still restores after it.
140
+ const db = getDb();
141
+ db.insert(systemConfig).values({ key: 'pre-change-sentinel', value: 'before-backup' }).run();
142
+
143
+ // Take a real backup to get a valid row + storage location, then swap the
144
+ // artifact for one produced by the system tar the old code shelled out to.
145
+ const result = await createSystemStateBackup();
146
+ expect(result.success).toBe(true);
147
+ const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
148
+
149
+ const envelopeDir = join(dir, 'pre-change-envelope');
150
+ mkdirSync(envelopeDir, { recursive: true });
151
+ copyFileSync(getDbPath(), join(envelopeDir, 'celilo.db'));
152
+ writeFileSync(
153
+ join(envelopeDir, 'manifest.json'),
154
+ JSON.stringify(buildManifest({ kind: 'system' }), null, 2),
155
+ );
156
+ const tarPath = join(dir, 'pre-change.tar');
157
+ execSync(`tar -cf '${tarPath}' -C '${envelopeDir}' .`);
158
+ const masterKey = await getOrCreateMasterKey();
159
+ await encryptFileToFile(tarPath, artifactPath, masterKey);
160
+
161
+ const backupRow = db.select().from(backups).all()[0];
162
+ const restoreResult = await restoreSystemStateBackup(backupRow);
163
+ expect(restoreResult.success).toBe(true);
164
+ expect(restoreResult.error).toBeUndefined();
165
+ });
166
+
124
167
  it('system restore refuses an artifact with a wrong manifest kind', async () => {
125
168
  // Create a valid backup, then poison the manifest by re-packing.
126
169
  const result = await createSystemStateBackup();