@celilo/cli 1.13.0 → 2.0.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 +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wiring, end to end: does `celilo console get` actually carry the chain?
|
|
3
|
+
*
|
|
4
|
+
* `orderFirewallChain` is unit-tested, `computeClosure` is unit-tested, and
|
|
5
|
+
* `loadClosureInputs` is unit-tested against real rows. None of that says the
|
|
6
|
+
* command joins them up. A dropped argument at this one call site returns a
|
|
7
|
+
* perfectly well-formed answer with an empty `chains` — which reads as a fleet
|
|
8
|
+
* with no delegation, and is the failure mode CLAUDE.md names.
|
|
9
|
+
*
|
|
10
|
+
* So this drives the REAL command against a REAL database and asserts on what
|
|
11
|
+
* it emits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
let testDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-console-chain-'));
|
|
23
|
+
process.env.CELILO_DB_PATH = join(testDir, 'test.db');
|
|
24
|
+
process.env.CELILO_DATA_DIR = testDir;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
29
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
|
|
33
|
+
async function seedFleet() {
|
|
34
|
+
const { getDb } = await import('../../db/client');
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const consumer = JSON.stringify({ requires: { capabilities: [{ name: 'firewall' }] } });
|
|
37
|
+
|
|
38
|
+
db.$client.run(
|
|
39
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data, state)
|
|
40
|
+
VALUES ('caddy', 'Caddy', '1.0.0', '/p', '${consumer}', 'VERIFIED')`,
|
|
41
|
+
);
|
|
42
|
+
for (const id of ['iptables', 'axon']) {
|
|
43
|
+
db.$client.run(
|
|
44
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data, state)
|
|
45
|
+
VALUES ('${id}', '${id}', '1.0.0', '/p', '{}', 'VERIFIED')`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
db.$client.run(
|
|
49
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
50
|
+
VALUES ('iptables', 'firewall', '1.0.0', '{"nat_ip":"192.168.0.253"}')`,
|
|
51
|
+
);
|
|
52
|
+
db.$client.run(
|
|
53
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
54
|
+
VALUES ('axon', 'firewall', '1.0.0', '{"has_external":true}')`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('celilo console get, and the delegation chain', () => {
|
|
59
|
+
test('--json carries the chain, in order', async () => {
|
|
60
|
+
await seedFleet();
|
|
61
|
+
const { handleConsoleGet } = await import('./console');
|
|
62
|
+
const result = handleConsoleGet(['caddy'], { json: true });
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
const payload = JSON.parse(result.success ? result.message : '{}');
|
|
66
|
+
expect(payload.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the router is in the closure, though no manifest mentions it', async () => {
|
|
70
|
+
// caddy requires `firewall` and nothing else. Walking manifests alone stops
|
|
71
|
+
// at the two registered providers and has no way to know one stands on the
|
|
72
|
+
// other — which is the whole reason the chain is derived.
|
|
73
|
+
await seedFleet();
|
|
74
|
+
const { handleConsoleGet } = await import('./console');
|
|
75
|
+
const payload = JSON.parse(
|
|
76
|
+
(() => {
|
|
77
|
+
const r = handleConsoleGet(['caddy'], { json: true });
|
|
78
|
+
return r.success ? r.message : '{}';
|
|
79
|
+
})(),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const axon = payload.nodes.find((n: { moduleId: string }) => n.moduleId === 'axon');
|
|
83
|
+
expect(axon).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the human output prints it as a path, not as more rows', async () => {
|
|
87
|
+
// A list of two firewalls is exactly the claim being corrected. The arrow is
|
|
88
|
+
// the information.
|
|
89
|
+
await seedFleet();
|
|
90
|
+
const { handleConsoleGet } = await import('./console');
|
|
91
|
+
const result = handleConsoleGet(['caddy'], {});
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
expect(result.success ? result.message : '').toContain('iptables -> axon');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -73,7 +73,7 @@ export function handleConsoleGet(
|
|
|
73
73
|
const depth = parseDepth(flags.depth);
|
|
74
74
|
if (depth instanceof Error) return { success: false, error: depth.message };
|
|
75
75
|
|
|
76
|
-
const { manifests, providerStates } = loadClosureInputs(db);
|
|
76
|
+
const { manifests, providerStates, chains } = loadClosureInputs(db);
|
|
77
77
|
|
|
78
78
|
// Real bindings, for every module the walk might reach. The console draws what
|
|
79
79
|
// the fleet IS doing, not what its manifests permit.
|
|
@@ -86,6 +86,7 @@ export function handleConsoleGet(
|
|
|
86
86
|
providerStates,
|
|
87
87
|
depth,
|
|
88
88
|
bindings,
|
|
89
|
+
chains,
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
if (hasFlag(flags, 'json')) {
|
|
@@ -98,14 +99,21 @@ export function handleConsoleGet(
|
|
|
98
99
|
return { success: true, message: `${moduleId} depends on nothing.` };
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// The chain is printed as a path rather than as more rows, because that is the
|
|
103
|
+
// whole claim: these providers are ordered, and a list of them is not.
|
|
104
|
+
const chainLines = result.chains.map(
|
|
105
|
+
(chain) => `${chain.capability}: ${chain.moduleIds.join(' -> ')}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
101
108
|
return {
|
|
102
109
|
success: true,
|
|
103
|
-
message:
|
|
104
|
-
.map(
|
|
110
|
+
message: [
|
|
111
|
+
...result.nodes.map(
|
|
105
112
|
(n) =>
|
|
106
113
|
`hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
|
|
107
|
-
)
|
|
108
|
-
.
|
|
114
|
+
),
|
|
115
|
+
...(chainLines.length > 0 ? ['', 'delegates upstream:', ...chainLines] : []),
|
|
116
|
+
].join('\n'),
|
|
109
117
|
};
|
|
110
118
|
}
|
|
111
119
|
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* and audit checks into the injectable deps `runOneMonitor` expects.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { hostname } from 'node:os';
|
|
9
10
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
10
11
|
import { getEventBusPath } from '../../config/paths';
|
|
11
12
|
import { getDb } from '../../db/client';
|
|
12
13
|
import type { MonitorKind } from '../../db/schema';
|
|
14
|
+
import { readJailMode } from '../../hooks/jail';
|
|
13
15
|
import {
|
|
14
16
|
isSchedulableBuiltin,
|
|
15
17
|
runBuiltinCheckForMonitor,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
import { loadModuleCoverage } from '../../services/alerting/coverage-source';
|
|
18
20
|
import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
|
|
19
21
|
import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
|
|
22
|
+
import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
|
|
20
23
|
import {
|
|
21
24
|
createMonitor,
|
|
22
25
|
ensureSweepSubscriber,
|
|
@@ -50,6 +53,7 @@ function buildDeps() {
|
|
|
50
53
|
runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
|
|
51
54
|
runBuiltinCheck: (category: DriftCategory) => runBuiltinCheckForMonitor(category, db),
|
|
52
55
|
loadModuleCoverage: () => loadModuleCoverage(db),
|
|
56
|
+
loadJailState: () => ({ record: readJailMode(), host: hostname() }),
|
|
53
57
|
now: () => new Date(),
|
|
54
58
|
graceMs: DEFAULT_GRACE_MS,
|
|
55
59
|
};
|
|
@@ -120,7 +124,10 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
|
|
|
120
124
|
// underscore heuristic alone would file it as a module hook against a module
|
|
121
125
|
// that does not exist.
|
|
122
126
|
const kind: MonitorKind =
|
|
123
|
-
target === HEALTH_COVERAGE_CHECK ||
|
|
127
|
+
target === HEALTH_COVERAGE_CHECK ||
|
|
128
|
+
target === HOOK_JAIL_CHECK ||
|
|
129
|
+
isSchedulableBuiltin(target) ||
|
|
130
|
+
target.includes('_')
|
|
124
131
|
? 'builtin_check'
|
|
125
132
|
: 'module_hook';
|
|
126
133
|
|
|
@@ -148,7 +155,13 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
|
|
|
148
155
|
const cadence = parseCadence(interval);
|
|
149
156
|
const intervalMinutes = cadence !== null && cadence !== 'manual' ? cadence.minutes : 0;
|
|
150
157
|
|
|
151
|
-
|
|
158
|
+
// The hook-jail check is a SELF-monitor: it watches celilo's own confinement
|
|
159
|
+
// of module hooks, and the alerting spec marks monitors watching the system
|
|
160
|
+
// itself unsuppressible. That is a property of the target, not an operator
|
|
161
|
+
// choice — losing the jail during a broad outage is exactly when no ancestor
|
|
162
|
+
// alert may silence it.
|
|
163
|
+
const suppressible = target === HOOK_JAIL_CHECK ? false : undefined;
|
|
164
|
+
createMonitor(db, { kind, target, intervalMinutes, suppressible });
|
|
152
165
|
|
|
153
166
|
// Creating the first monitor is also what switches the sweep on. Registering
|
|
154
167
|
// here rather than at install means a celilo with no monitors carries no
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo person list --json` — the shape the web console maps an identity
|
|
3
|
+
* against.
|
|
4
|
+
*
|
|
5
|
+
* Worth its own test for a reason that is not obvious from the three lines it
|
|
6
|
+
* covers. The console server decides whether an authenticated browser subject
|
|
7
|
+
* corresponds to a real person, and it decides it by reading THIS. Its own
|
|
8
|
+
* tests answer that question against a fake celilo, so they prove the console
|
|
9
|
+
* parses what it was told to expect and say nothing about what celilo emits.
|
|
10
|
+
* This is the end that closes.
|
|
11
|
+
*
|
|
12
|
+
* If the payload ever stops being `[{ name, timezone }]`, the console does not
|
|
13
|
+
* error: it maps nobody, draws no acknowledge control, and reports every
|
|
14
|
+
* operator as unknown to the fleet. That reads as a configuration problem for
|
|
15
|
+
* as long as anyone is willing to believe it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { closeDb, getDb } from '../../db/client';
|
|
23
|
+
import { createPerson } from '../../services/alerting/people';
|
|
24
|
+
import { setupTestDatabaseAt } from '../../test-utils/database';
|
|
25
|
+
import { handlePerson } from './notify-config';
|
|
26
|
+
|
|
27
|
+
let dir: string;
|
|
28
|
+
const previousDbPath = process.env.CELILO_DB_PATH;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
|
|
32
|
+
const dbPath = join(dir, 'test.db');
|
|
33
|
+
const db = await setupTestDatabaseAt(dbPath);
|
|
34
|
+
createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' });
|
|
35
|
+
createPerson(db, { name: 'ada', timezone: 'Europe/London' });
|
|
36
|
+
db.$client.close();
|
|
37
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
closeDb();
|
|
42
|
+
if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
|
|
43
|
+
else process.env.CELILO_DB_PATH = previousDbPath;
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Run `person list`, insisting it succeeded before reading what it said. */
|
|
48
|
+
async function personList(flags: Record<string, boolean | string>): Promise<string> {
|
|
49
|
+
const result = await handlePerson('list', [], flags);
|
|
50
|
+
if (!result.success) throw new Error(`person list failed: ${result.error}`);
|
|
51
|
+
return result.message;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listJson(): Promise<{ name: string; timezone: string }[]> {
|
|
55
|
+
return JSON.parse(await personList({ json: true }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('celilo person list --json', () => {
|
|
59
|
+
test('names every person, sorted the way listPeople sorts them', async () => {
|
|
60
|
+
expect((await listJson()).map((p) => p.name)).toEqual(['ada', 'peter']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('carries the timezone, which is the only other thing a person IS', async () => {
|
|
64
|
+
const ada = (await listJson()).find((p) => p.name === 'ada');
|
|
65
|
+
expect(ada?.timezone).toBe('Europe/London');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an empty fleet is an empty array, not a sentence about it', async () => {
|
|
69
|
+
// The human listing prints "Nobody configured." and a hint. A caller that
|
|
70
|
+
// parsed that would throw, which is a better failure than the one this
|
|
71
|
+
// avoids — but the console polls this, so it gets JSON either way.
|
|
72
|
+
getDb().$client.run('DELETE FROM people');
|
|
73
|
+
expect(await listJson()).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('without --json it stays the human table, and says how many', async () => {
|
|
77
|
+
expect(await personList({})).toContain('2 person');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
listRoutes,
|
|
34
34
|
} from '../../services/alerting/people';
|
|
35
35
|
import { parseClockTime } from '../../services/alerting/quiet-hours';
|
|
36
|
+
import { hasFlag } from '../parser';
|
|
36
37
|
import type { CommandResult } from '../types';
|
|
37
38
|
|
|
38
39
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -109,8 +110,18 @@ function personAdd(args: string[], flags: Record<string, boolean | string>): Com
|
|
|
109
110
|
return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
function personList(): CommandResult {
|
|
113
|
+
function personList(flags: Record<string, boolean | string> = {}): CommandResult {
|
|
113
114
|
const people = listPeople(getDb());
|
|
115
|
+
|
|
116
|
+
// The web console's subject-to-person mapping reads this. It never invents a
|
|
117
|
+
// person and never lets celilo fall back to `people[0]`, so it has to be able
|
|
118
|
+
// to ASK who exists. `person:list` is already in the read-only grant set, so
|
|
119
|
+
// the flag adds a shape rather than an authority.
|
|
120
|
+
if (hasFlag(flags, 'json')) {
|
|
121
|
+
const payload = people.map((p) => ({ name: p.name, timezone: p.timezone }));
|
|
122
|
+
return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
if (people.length === 0) {
|
|
115
126
|
console.log('\nNobody configured.\n');
|
|
116
127
|
console.log(
|
|
@@ -158,7 +169,7 @@ export async function handlePerson(
|
|
|
158
169
|
switch (subcommand) {
|
|
159
170
|
case undefined:
|
|
160
171
|
case 'list':
|
|
161
|
-
return personList();
|
|
172
|
+
return personList(flags);
|
|
162
173
|
case 'add':
|
|
163
174
|
return personAdd(args, flags);
|
|
164
175
|
case 'remove':
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import type { JailModeRecord } from '../../hooks/jail';
|
|
3
|
+
import {
|
|
4
|
+
type HookExecutionInput,
|
|
5
|
+
compareVersions,
|
|
6
|
+
renderHookExecutionSection,
|
|
7
|
+
} from './system-doctor';
|
|
3
8
|
|
|
4
9
|
describe('compareVersions', () => {
|
|
5
10
|
test('detects ascending major/minor/patch', () => {
|
|
@@ -34,3 +39,118 @@ describe('compareVersions', () => {
|
|
|
34
39
|
expect(compareVersions('v1.2.3', '1.2.3')).toBe(0);
|
|
35
40
|
});
|
|
36
41
|
});
|
|
42
|
+
|
|
43
|
+
describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
44
|
+
const HOST = 'celilo-mgr';
|
|
45
|
+
const jailedRecord: JailModeRecord = {
|
|
46
|
+
mode: 'jailed',
|
|
47
|
+
backend: 'bubblewrap',
|
|
48
|
+
host: HOST,
|
|
49
|
+
recordedAt: '2026-08-28T09:00:00.000Z',
|
|
50
|
+
};
|
|
51
|
+
const regressedRecord: JailModeRecord = {
|
|
52
|
+
mode: 'unjailed',
|
|
53
|
+
backend: 'none',
|
|
54
|
+
reason: 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
|
|
55
|
+
host: HOST,
|
|
56
|
+
recordedAt: '2026-08-28T09:00:00.000Z',
|
|
57
|
+
lastJailed: { backend: 'bubblewrap', recordedAt: '2026-08-27T09:00:00.000Z' },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const render = (over: Partial<HookExecutionInput>) =>
|
|
61
|
+
renderHookExecutionSection({
|
|
62
|
+
availability: { backend: 'bubblewrap' },
|
|
63
|
+
policy: 'auto',
|
|
64
|
+
record: jailedRecord,
|
|
65
|
+
host: HOST,
|
|
66
|
+
monitored: true,
|
|
67
|
+
...over,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const text = (r: { lines: string[] }) => r.lines.join('\n');
|
|
71
|
+
|
|
72
|
+
test('a jailing host is a ✔ and nothing more', () => {
|
|
73
|
+
const r = render({});
|
|
74
|
+
expect(text(r)).toContain('hooks run jailed (bubblewrap)');
|
|
75
|
+
expect(r.failCount).toBe(0);
|
|
76
|
+
expect(r.warnCount).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('unjailed says why in the reason sentence, as a warning and not a failure', () => {
|
|
80
|
+
const r = render({
|
|
81
|
+
availability: {
|
|
82
|
+
backend: 'none',
|
|
83
|
+
reason:
|
|
84
|
+
'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.',
|
|
85
|
+
},
|
|
86
|
+
record: undefined,
|
|
87
|
+
monitored: null,
|
|
88
|
+
});
|
|
89
|
+
expect(text(r)).toContain('hooks run unjailed — bubblewrap is not installed');
|
|
90
|
+
expect(text(r)).toContain('no execution mode recorded yet');
|
|
91
|
+
expect(r.warnCount).toBe(1);
|
|
92
|
+
expect(r.failCount).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('CELILO_HOOK_JAIL=off is reported as the operator’s own act', () => {
|
|
96
|
+
const r = render({ policy: 'off', record: undefined });
|
|
97
|
+
expect(text(r)).toContain('CELILO_HOOK_JAIL=off');
|
|
98
|
+
expect(r.warnCount).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
|
|
102
|
+
// The doctor and the executor share one predicate (autoJailDefers), so
|
|
103
|
+
// the doctor cannot report a jail the executor will not build. When D14
|
|
104
|
+
// lands and the flag flips, this goes back to ✔ with no code change here.
|
|
105
|
+
const r = render({ availability: { backend: 'sandbox-exec' }, record: undefined });
|
|
106
|
+
expect(text(r)).toContain('hooks run unjailed — macOS hooks run unjailed');
|
|
107
|
+
expect(text(r)).not.toContain('hooks run jailed');
|
|
108
|
+
expect(r.warnCount).toBe(1);
|
|
109
|
+
expect(r.failCount).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('required with no backend is a failure, because every hook fails', () => {
|
|
113
|
+
const r = render({
|
|
114
|
+
policy: 'required',
|
|
115
|
+
availability: { backend: 'none', reason: 'the profile did not load' },
|
|
116
|
+
record: undefined,
|
|
117
|
+
});
|
|
118
|
+
expect(text(r)).toContain('CELILO_HOOK_JAIL=required and no jail is available');
|
|
119
|
+
expect(r.failCount).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a host that used to jail and has stopped is a failure naming when', () => {
|
|
123
|
+
const r = render({
|
|
124
|
+
availability: { backend: 'none', reason: 'the profile did not load' },
|
|
125
|
+
record: regressedRecord,
|
|
126
|
+
});
|
|
127
|
+
expect(text(r)).toContain('ran hooks jailed (bubblewrap) until 2026-08-27T09:00:00.000Z');
|
|
128
|
+
expect(r.failCount).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('an unmonitored regression points at the self-monitor', () => {
|
|
132
|
+
const r = render({
|
|
133
|
+
availability: { backend: 'none', reason: 'the profile did not load' },
|
|
134
|
+
record: regressedRecord,
|
|
135
|
+
monitored: false,
|
|
136
|
+
});
|
|
137
|
+
expect(text(r)).toContain('celilo monitor add hook_jail');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a jailing but unmonitored host is told the transition alerts only if monitored', () => {
|
|
141
|
+
const r = render({ monitored: false });
|
|
142
|
+
expect(text(r)).toContain('celilo monitor add hook_jail');
|
|
143
|
+
expect(r.failCount).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a dev box with no DB gets no monitor hint', () => {
|
|
147
|
+
const r = render({ monitored: null });
|
|
148
|
+
expect(text(r)).not.toContain('monitor add');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("another host's record reads as a move, not this host's history", () => {
|
|
152
|
+
const r = render({ record: { ...regressedRecord, host: 'old-box' } });
|
|
153
|
+
expect(text(r)).toContain('belongs to host "old-box"');
|
|
154
|
+
expect(r.failCount).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -32,13 +32,27 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
|
+
import { hostname } from 'node:os';
|
|
35
36
|
import { dirname, join, resolve } from 'node:path';
|
|
36
37
|
import { resolveBrowser } from '@celilo/capabilities';
|
|
37
38
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
38
39
|
import cliPkg from '../../../package.json' with { type: 'json' };
|
|
39
40
|
import { getDbPath, getEventBusPath } from '../../config/paths';
|
|
40
41
|
import { getDb } from '../../db/client';
|
|
42
|
+
import {
|
|
43
|
+
JAIL_OFF_REASON,
|
|
44
|
+
type JailAvailability,
|
|
45
|
+
type JailModeRecord,
|
|
46
|
+
type JailPolicy,
|
|
47
|
+
SANDBOX_EXEC_AUTO_DEFERRED_REASON,
|
|
48
|
+
autoJailDefers,
|
|
49
|
+
detectJailBackend,
|
|
50
|
+
jailPolicy,
|
|
51
|
+
readJailMode,
|
|
52
|
+
} from '../../hooks/jail';
|
|
41
53
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
54
|
+
import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
|
|
55
|
+
import { findMonitorByTarget } from '../../services/alerting/monitors';
|
|
42
56
|
import {
|
|
43
57
|
type FleetFinding,
|
|
44
58
|
type FleetFindingStatus,
|
|
@@ -336,6 +350,112 @@ function provisionedBrowserFlavor(): string | null {
|
|
|
336
350
|
}
|
|
337
351
|
}
|
|
338
352
|
|
|
353
|
+
export interface HookExecutionInput {
|
|
354
|
+
availability: JailAvailability;
|
|
355
|
+
policy: JailPolicy;
|
|
356
|
+
/** What the last real module-hook run recorded, if any. */
|
|
357
|
+
record: JailModeRecord | undefined;
|
|
358
|
+
host: string;
|
|
359
|
+
/**
|
|
360
|
+
* Whether a `hook_jail` monitor exists. `null` when there is no celilo DB to
|
|
361
|
+
* ask (a dev box), which also suppresses the add-the-monitor hint.
|
|
362
|
+
*/
|
|
363
|
+
monitored: boolean | null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The hook-execution section (hook-process-boundary task 4.6).
|
|
368
|
+
*
|
|
369
|
+
* Reports design D8's three states the way the design assigns them: jailed is
|
|
370
|
+
* a ✔ and nothing more; unjailed-and-always-was is reported with the reason in
|
|
371
|
+
* one actionable sentence and is a warning, not an event; a host that USED to
|
|
372
|
+
* jail and has stopped is a failure, and the self-monitor (`celilo monitor add
|
|
373
|
+
* hook_jail`) is what turns it into an alert.
|
|
374
|
+
*
|
|
375
|
+
* The headline is the LIVE answer — the probe runs bubblewrap the way a real
|
|
376
|
+
* spawn would — while the recorded mode says what the last hook actually did.
|
|
377
|
+
* They disagree exactly when the host changed since the last hook ran, which
|
|
378
|
+
* is worth seeing, not reconciling away.
|
|
379
|
+
*
|
|
380
|
+
* Pure so every branch is testable on a host with no jail (Rule 10.4); the
|
|
381
|
+
* caller injects what the machine says.
|
|
382
|
+
*/
|
|
383
|
+
export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
384
|
+
lines: string[];
|
|
385
|
+
failCount: number;
|
|
386
|
+
warnCount: number;
|
|
387
|
+
} {
|
|
388
|
+
const { availability, policy, record, host, monitored } = input;
|
|
389
|
+
const lines: string[] = ['Hook execution'];
|
|
390
|
+
let failCount = 0;
|
|
391
|
+
let warnCount = 0;
|
|
392
|
+
|
|
393
|
+
// ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
|
|
394
|
+
// the executor will not build. One predicate, two consumers.
|
|
395
|
+
const autoDeferred = policy === 'auto' && autoJailDefers(availability);
|
|
396
|
+
const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
|
|
397
|
+
if (policy === 'required' && availability.backend === 'none') {
|
|
398
|
+
lines.push(
|
|
399
|
+
` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
|
|
400
|
+
);
|
|
401
|
+
lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
|
|
402
|
+
failCount++;
|
|
403
|
+
} else if (policy === 'off') {
|
|
404
|
+
lines.push(` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${JAIL_OFF_REASON}`);
|
|
405
|
+
warnCount++;
|
|
406
|
+
} else if (availability.backend === 'none') {
|
|
407
|
+
lines.push(
|
|
408
|
+
` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${availability.reason ?? 'no reason was recorded'}`,
|
|
409
|
+
);
|
|
410
|
+
warnCount++;
|
|
411
|
+
} else if (autoDeferred) {
|
|
412
|
+
lines.push(
|
|
413
|
+
` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${SANDBOX_EXEC_AUTO_DEFERRED_REASON}`,
|
|
414
|
+
);
|
|
415
|
+
warnCount++;
|
|
416
|
+
} else {
|
|
417
|
+
lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const regressed =
|
|
421
|
+
record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
|
|
422
|
+
if (regressed && record.lastJailed) {
|
|
423
|
+
lines.push(
|
|
424
|
+
` ${ANSI.red}✗${ANSI.reset} this host ran hooks jailed (${record.lastJailed.backend}) until ${record.lastJailed.recordedAt} and has stopped`,
|
|
425
|
+
);
|
|
426
|
+
if (record.reason) lines.push(` ${ANSI.dim}${record.reason}${ANSI.reset}`);
|
|
427
|
+
failCount++;
|
|
428
|
+
if (monitored === false) {
|
|
429
|
+
lines.push(
|
|
430
|
+
` ${ANSI.dim}→ nothing alerts on this — celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
} else if (!record) {
|
|
434
|
+
lines.push(
|
|
435
|
+
` ${ANSI.dim}no execution mode recorded yet — the first real module-hook run writes it${ANSI.reset}`,
|
|
436
|
+
);
|
|
437
|
+
} else if (record.host !== host) {
|
|
438
|
+
lines.push(
|
|
439
|
+
` ${ANSI.dim}last recorded mode belongs to host "${record.host}" (this data directory moved) — the next hook run re-records${ANSI.reset}`,
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
const backend = record.mode === 'jailed' ? ` (${record.backend})` : '';
|
|
443
|
+
lines.push(
|
|
444
|
+
` ${ANSI.dim}last hook ran ${record.mode}${backend} at ${record.recordedAt}${ANSI.reset}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The moment this hint pays for itself is BEFORE a regression: the monitor
|
|
449
|
+
// has to exist for the transition to page anyone.
|
|
450
|
+
if (liveJailed && monitored === false) {
|
|
451
|
+
lines.push(
|
|
452
|
+
` ${ANSI.dim}→ a host that stops jailing alerts only if monitored: celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { lines, failCount, warnCount };
|
|
457
|
+
}
|
|
458
|
+
|
|
339
459
|
/**
|
|
340
460
|
* mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
|
|
341
461
|
* package.json). The fleet dispatcher check compares this against the
|
|
@@ -616,6 +736,33 @@ export async function handleSystemDoctor(
|
|
|
616
736
|
lines.push(...prereqResult.lines);
|
|
617
737
|
lines.push('');
|
|
618
738
|
|
|
739
|
+
// Hook execution mode (hook-process-boundary task 4.6). The policy read
|
|
740
|
+
// throws on a typo'd CELILO_HOOK_JAIL — report that rather than crash the
|
|
741
|
+
// doctor, since the same throw is about to fail every hook invocation too.
|
|
742
|
+
let hookExec: { lines: string[]; failCount: number; warnCount: number };
|
|
743
|
+
try {
|
|
744
|
+
hookExec = renderHookExecutionSection({
|
|
745
|
+
availability: detectJailBackend(),
|
|
746
|
+
policy: jailPolicy(),
|
|
747
|
+
record: readJailMode(),
|
|
748
|
+
host: hostname(),
|
|
749
|
+
monitored: existsSync(getDbPath())
|
|
750
|
+
? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined
|
|
751
|
+
: null,
|
|
752
|
+
});
|
|
753
|
+
} catch (error) {
|
|
754
|
+
hookExec = {
|
|
755
|
+
lines: [
|
|
756
|
+
'Hook execution',
|
|
757
|
+
` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
|
|
758
|
+
],
|
|
759
|
+
failCount: 1,
|
|
760
|
+
warnCount: 0,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
lines.push(...hookExec.lines);
|
|
764
|
+
lines.push('');
|
|
765
|
+
|
|
619
766
|
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
|
620
767
|
const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
|
|
621
768
|
const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
|
|
@@ -748,6 +895,9 @@ export async function handleSystemDoctor(
|
|
|
748
895
|
}
|
|
749
896
|
if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
|
|
750
897
|
if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
|
|
898
|
+
if (hookExec.failCount > 0) {
|
|
899
|
+
problems.push(`${hookExec.failCount} hook-execution problem(s)`);
|
|
900
|
+
}
|
|
751
901
|
if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
|
|
752
902
|
if (aspectFailCount > 0) problems.push(`${aspectFailCount} system(s) missing a fleet aspect`);
|
|
753
903
|
|
|
@@ -760,7 +910,7 @@ export async function handleSystemDoctor(
|
|
|
760
910
|
}
|
|
761
911
|
|
|
762
912
|
// Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
|
|
763
|
-
const warnTotal = fleet.warnCount + aspectWarnCount;
|
|
913
|
+
const warnTotal = fleet.warnCount + aspectWarnCount + hookExec.warnCount;
|
|
764
914
|
if (warnTotal > 0) {
|
|
765
915
|
lines.push(`${ANSI.yellow}OK with warnings${ANSI.reset} — ${warnTotal} warning(s); see above`);
|
|
766
916
|
} else {
|