@celilo/cli 2.0.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -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
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Health-coverage's view of the control plane (celilo#1225).
3
+ *
4
+ * `celilo-mgmt` declares no `health_check` hook, and that is deliberate: celilo
5
+ * runs `runFleetChecks` for it directly, which asks eight questions where the
6
+ * hook asked two. The hook was deleted because it could not answer them from
7
+ * inside the jail — it reported a present database missing and failed the
8
+ * deploy.
9
+ *
10
+ * Without the carve-out here, the module best observed by celilo would be the
11
+ * one celilo warns is unobservable. That warning would be both false and
12
+ * unfixable, since the fix it names — add a health_check hook — is the thing
13
+ * that was removed on purpose.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import type { DbClient } from '../../db/client';
21
+ import { modules } from '../../db/schema';
22
+ import { setupTestDatabaseAt } from '../../test-utils/database';
23
+ import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
24
+ import { loadModuleCoverage } from './coverage-source';
25
+ import { healthCoverageFailingKeys } from './health-coverage';
26
+
27
+ describe('health coverage and the control plane', () => {
28
+ let dir: string;
29
+ let db: DbClient;
30
+
31
+ beforeEach(async () => {
32
+ dir = mkdtempSync(join(tmpdir(), 'cov-'));
33
+ const dbPath = join(dir, 'celilo.db');
34
+ process.env.CELILO_DB_PATH = dbPath;
35
+ db = await setupTestDatabaseAt(dbPath);
36
+ });
37
+
38
+ afterEach(() => {
39
+ db.$client.close();
40
+ process.env.CELILO_DB_PATH = undefined;
41
+ try {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ });
47
+
48
+ function installModule(id: string, manifestData: Record<string, unknown>): void {
49
+ db.insert(modules)
50
+ .values({
51
+ id,
52
+ name: id,
53
+ version: '1.0.0',
54
+ state: 'INSTALLED',
55
+ sourcePath: `/modules/${id}`,
56
+ manifestData,
57
+ })
58
+ .run();
59
+ }
60
+
61
+ test('the control plane counts as verifiable despite declaring no hook', () => {
62
+ installModule(CONTROL_PLANE_MODULE_ID, { id: CONTROL_PLANE_MODULE_ID, hooks: {} });
63
+
64
+ const coverage = loadModuleCoverage(db);
65
+ const controlPlane = coverage.find((m) => m.id === CONTROL_PLANE_MODULE_ID);
66
+
67
+ expect(controlPlane?.hasHealthCheckHook).toBe(true);
68
+ // The finding that must NOT be raised — the one whose remedy is to add
69
+ // back the hook this change deleted.
70
+ const failing = healthCoverageFailingKeys(coverage);
71
+ const neverVerified = failing.filter((f) => f.message.includes('can never be verified'));
72
+ expect(neverVerified).toHaveLength(0);
73
+ });
74
+
75
+ test('an ordinary module with no hook is still reported unobservable', () => {
76
+ // The carve-out is for the control plane alone. If it leaked to every
77
+ // module, the check would stop finding anything and look healthy.
78
+ installModule('homebridge', { id: 'homebridge', hooks: {} });
79
+
80
+ const failing = healthCoverageFailingKeys(loadModuleCoverage(db));
81
+ const messages = failing.map((f) => f.message);
82
+ expect(
83
+ messages.some((m) => m.includes('homebridge') && m.includes('can never be verified')),
84
+ ).toBe(true);
85
+ });
86
+ });
@@ -9,6 +9,7 @@
9
9
  import type { DbClient } from '../../db/client';
10
10
  import { modules } from '../../db/schema';
11
11
  import type { ModuleManifest } from '../../manifest/schema';
12
+ import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
12
13
  import { loadModuleHealthCadences } from './health-cadence';
13
14
  import type { ModuleCoverageInput } from './health-coverage';
14
15
 
@@ -28,7 +29,16 @@ export function loadModuleCoverage(db: DbClient): ModuleCoverageInput[] {
28
29
  return {
29
30
  id: module.id,
30
31
  state: module.state,
31
- hasHealthCheckHook: Boolean(manifest.hooks?.health_check),
32
+ // The control plane is verifiable without declaring a hook, and that
33
+ // is the point rather than an exemption: celilo runs `runFleetChecks`
34
+ // for it directly (`controlPlaneHealthChecks`), which asks eight
35
+ // questions where its old hook asked two. The hook was deleted because
36
+ // it could not answer them from inside the jail — it reported a
37
+ // present database missing (celilo#1225). Reading only the manifest
38
+ // here would raise a coverage warning about the best-observed module
39
+ // in the fleet.
40
+ hasHealthCheckHook:
41
+ Boolean(manifest.hooks?.health_check) || module.id === CONTROL_PLANE_MODULE_ID,
32
42
  cadence: cadences.get(module.id)?.cadence ?? null,
33
43
  };
34
44
  });
@@ -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();
@@ -4,21 +4,21 @@
4
4
  * For system state backups, restores the Celilo database.
5
5
  */
6
6
 
7
- import { execSync } from 'node:child_process';
8
7
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
9
8
  import { tmpdir } from 'node:os';
10
9
  import { join } from 'node:path';
11
10
  import { eq } from 'drizzle-orm';
11
+ import { extract as tarExtract } from 'tar';
12
12
  import { getDbPath } from '../config/paths';
13
13
  import { closeDb, getDb } from '../db/client';
14
14
  import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
15
15
  import type { Backup } from '../db/schema';
16
16
  import { invokeHook } from '../hooks/executor';
17
+ import { createHookStores } from '../hooks/hook-store';
17
18
  import { createConsoleLogger } from '../hooks/logger';
18
19
  import type { ModuleManifest } from '../manifest/schema';
19
20
  import { decryptSecret } from '../secrets/encryption';
20
21
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
- import { shellEscape } from '../utils/shell';
22
22
  import { decryptFileToFile } from './backup-cipher';
23
23
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
24
24
  import { createStorageProvider } from './backup-storage';
@@ -70,7 +70,7 @@ export async function restoreSystemStateBackup(backup: Backup): Promise<RestoreR
70
70
  mkdirSync(envelopeDir, { recursive: true });
71
71
  const tarPath = join(tempDir, 'envelope.tar');
72
72
  await decryptFileToFile(encryptedPath, tarPath, masterKey);
73
- execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
73
+ await tarExtract({ file: tarPath, cwd: envelopeDir });
74
74
 
75
75
  // Read + validate manifest BEFORE touching the live DB. An
76
76
  // incompatible artifact must not get past this point.
@@ -180,6 +180,9 @@ export async function restoreModuleBackup(
180
180
  if (!backup.moduleId) {
181
181
  return { success: false, error: 'Backup has no associated module' };
182
182
  }
183
+ // Captured because the narrowing above does not survive into the hookStores
184
+ // callback below, where TypeScript widens the parameter back to string|null.
185
+ const moduleId = backup.moduleId;
183
186
 
184
187
  const db = getDb();
185
188
  const mod = db.select().from(modules).where(eq(modules.id, backup.moduleId)).get();
@@ -221,7 +224,7 @@ export async function restoreModuleBackup(
221
224
  const masterKey = await getOrCreateMasterKey();
222
225
  const tarPath = join(tempDir, 'envelope.tar');
223
226
  await decryptFileToFile(encryptedPath, tarPath, masterKey);
224
- execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
227
+ await tarExtract({ file: tarPath, cwd: envelopeDir });
225
228
 
226
229
  // Read + validate envelope manifest BEFORE invoking the hook.
227
230
  const manifestPath = join(envelopeDir, 'manifest.json');
@@ -290,6 +293,7 @@ export async function restoreModuleBackup(
290
293
  debug: false,
291
294
  systems: getModuleSystems(backup.moduleId, db),
292
295
  remoteAccess: remoteAccessPolicy(backup.moduleId, db),
296
+ hookStores: () => createHookStores(db, moduleId),
293
297
  },
294
298
  );
295
299