@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.
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- 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
|
+
});
|
package/src/registry/client.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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();
|