@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- 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 +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- 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 +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- 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 +27 -8
- 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/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- 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 +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- 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/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- 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 +214 -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/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- 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
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|