@celilo/cli 4.0.0 → 4.1.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_SUBSYSTEMS.md +1 -0
- package/package.json +5 -4
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +8 -2
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/services/api-access.test.ts +44 -0
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/context.ts +14 -1
|
@@ -73,6 +73,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
73
73
|
'schema',
|
|
74
74
|
'capability_abi',
|
|
75
75
|
'browser_pin',
|
|
76
|
+
'server_bun_pin',
|
|
77
|
+
'reboot_pending',
|
|
76
78
|
'terraform_plan',
|
|
77
79
|
'module_versions',
|
|
78
80
|
'module_configs',
|
|
@@ -96,6 +98,8 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
|
|
|
96
98
|
];
|
|
97
99
|
|
|
98
100
|
export const CATEGORY_LABELS: Record<DriftCategory, string> = {
|
|
101
|
+
server_bun_pin: 'Server bun pin',
|
|
102
|
+
reboot_pending: 'Pending reboot',
|
|
99
103
|
module_integrity: 'Module integrity',
|
|
100
104
|
detect_without_converge: 'Drift without converge',
|
|
101
105
|
jail_exemptions: 'Hook jail exemptions',
|
package/src/hooks/broker.ts
CHANGED
|
@@ -105,6 +105,18 @@ export interface Broker {
|
|
|
105
105
|
outcome(): HookOutcome | undefined;
|
|
106
106
|
/** Every protocol-level complaint, for the error message when one matters. */
|
|
107
107
|
faults(): string[];
|
|
108
|
+
/**
|
|
109
|
+
* The capability calls that are still in flight, newest last, each rendered
|
|
110
|
+
* as `provider_module:capability.method (Ns)`.
|
|
111
|
+
*
|
|
112
|
+
* A hook that dies at its total timeout has usually died WAITING on one of
|
|
113
|
+
* these, and the bare "Hook total timeout exceeded" names only the hook —
|
|
114
|
+
* never what it was waiting for. Measured 2026-09-21 (celilo#1403):
|
|
115
|
+
* bna-yard-sale's on_install died at 120s blocked on caddy's 75s route
|
|
116
|
+
* reconcile, and the log's only tell was a `publishStaticSite` push line
|
|
117
|
+
* with no matching completion. The operator had to infer the provider.
|
|
118
|
+
*/
|
|
119
|
+
pendingCalls(): string[];
|
|
108
120
|
/** Refuse further calls. Idempotent. Called on kill and again on cleanup. */
|
|
109
121
|
stop(): void;
|
|
110
122
|
/** Close the socket and remove its directory. */
|
|
@@ -184,6 +196,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
184
196
|
let outcome: HookOutcome | undefined;
|
|
185
197
|
let stopped = false;
|
|
186
198
|
const faults: string[] = [];
|
|
199
|
+
/** In-flight capability calls by frame id, so a timeout can name them. */
|
|
200
|
+
const pending = new Map<string, { capability: string; method: string; startedAt: number }>();
|
|
187
201
|
let connection: Socket | undefined;
|
|
188
202
|
// Already resolved: no connection means nothing left to deliver.
|
|
189
203
|
let connectionClosed: Promise<void> = Promise.resolve();
|
|
@@ -300,6 +314,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
300
314
|
return;
|
|
301
315
|
}
|
|
302
316
|
|
|
317
|
+
pending.set(id, { capability: capabilityName, method, startedAt: Date.now() });
|
|
303
318
|
try {
|
|
304
319
|
const value = await (fn as (...a: unknown[]) => unknown).apply(capability, args);
|
|
305
320
|
options.onActivity();
|
|
@@ -307,6 +322,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
307
322
|
} catch (error) {
|
|
308
323
|
options.onActivity();
|
|
309
324
|
send({ type: 'throw', id, error: serializeError(error) });
|
|
325
|
+
} finally {
|
|
326
|
+
pending.delete(id);
|
|
310
327
|
}
|
|
311
328
|
}
|
|
312
329
|
|
|
@@ -384,6 +401,20 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
384
401
|
]),
|
|
385
402
|
outcome: () => outcome,
|
|
386
403
|
faults: () => [...faults],
|
|
404
|
+
pendingCalls: () => {
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
return [...pending.values()]
|
|
407
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
408
|
+
.map((call) => {
|
|
409
|
+
// `providerModuleId` is stamped onto the capability's data half by
|
|
410
|
+
// the loader, so the message can name the MODULE the hook is waiting
|
|
411
|
+
// on and not only the capability it asked through.
|
|
412
|
+
const view = options.capabilities[call.capability] as Record<string, unknown> | undefined;
|
|
413
|
+
const provider = typeof view?.providerModuleId === 'string' ? view.providerModuleId : '';
|
|
414
|
+
const waited = Math.round((now - call.startedAt) / 1000);
|
|
415
|
+
return `${provider ? `${provider}:` : ''}${call.capability}.${call.method} (${waited}s)`;
|
|
416
|
+
});
|
|
417
|
+
},
|
|
387
418
|
stop: () => {
|
|
388
419
|
stopped = true;
|
|
389
420
|
},
|
|
@@ -103,14 +103,20 @@ async function requireProviderReconcile(consumingModuleId: string, what: string)
|
|
|
103
103
|
`public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
|
|
104
104
|
);
|
|
105
105
|
}
|
|
106
|
+
// The handler's own words, when it left any. A crash is retried, so its
|
|
107
|
+
// delivery sits in `pending` with the error already recorded while the
|
|
108
|
+
// deadline runs out — which is how celilo#1401 reported a TIMEOUT for a
|
|
109
|
+
// caddy crash, for days, with `0 ok, 0 failed of 1` and the cause nowhere.
|
|
110
|
+
const because = reconcile.lastError ? ` The provider reported: ${reconcile.lastError}` : '';
|
|
111
|
+
|
|
106
112
|
if (reconcile.timedOut) {
|
|
107
113
|
throw new Error(
|
|
108
|
-
`public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live
|
|
114
|
+
`public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.${because}`,
|
|
109
115
|
);
|
|
110
116
|
}
|
|
111
117
|
if (reconcile.failed > 0) {
|
|
112
118
|
throw new Error(
|
|
113
|
-
`public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared
|
|
119
|
+
`public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.${because}`,
|
|
114
120
|
);
|
|
115
121
|
}
|
|
116
122
|
// ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO
|
package/src/hooks/executor.ts
CHANGED
|
@@ -511,7 +511,14 @@ export async function executeHookScript(
|
|
|
511
511
|
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
512
512
|
};
|
|
513
513
|
|
|
514
|
-
|
|
514
|
+
// Snapshotted inside the timer rather than read at throw time: the child's
|
|
515
|
+
// death rejects its in-flight calls, so by the time we build the message
|
|
516
|
+
// the map that knows the answer has drained itself.
|
|
517
|
+
let pendingCallsAtKill: string[] = [];
|
|
518
|
+
const totalTimer = setTimeout(() => {
|
|
519
|
+
pendingCallsAtKill = broker.pendingCalls();
|
|
520
|
+
kill('total');
|
|
521
|
+
}, timeoutMs);
|
|
515
522
|
// Debug runs get no idle timer, same as before: an operator stepping
|
|
516
523
|
// through a browser hook is silent for minutes on purpose.
|
|
517
524
|
const idleTimer = context.debug
|
|
@@ -536,7 +543,18 @@ export async function executeHookScript(
|
|
|
536
543
|
// nothing keeps running past this line. That is the difference from the
|
|
537
544
|
// `Promise.race` this replaces (celilo#1003).
|
|
538
545
|
if (killedFor === 'total') {
|
|
539
|
-
|
|
546
|
+
// Name what it was WAITING ON, not just that it waited. A hook whose
|
|
547
|
+
// budget is consumed by one slow provider used to die as a bare
|
|
548
|
+
// "Hook total timeout exceeded", so the operator had to infer the
|
|
549
|
+
// provider from a capability push line with no matching completion
|
|
550
|
+
// (celilo#1403: bna-yard-sale's 120s on_install blocked on caddy's 75s
|
|
551
|
+
// route reconcile). Captured before `close()`, while the calls are
|
|
552
|
+
// still in flight — `stop()` refuses new calls but clears nothing.
|
|
553
|
+
const waitingOn = pendingCallsAtKill;
|
|
554
|
+
const waitingSuffix = waitingOn.length > 0 ? `, waiting on ${waitingOn.join(', ')}` : '';
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)${waitingSuffix}`,
|
|
557
|
+
);
|
|
540
558
|
}
|
|
541
559
|
if (killedFor === 'idle') {
|
|
542
560
|
throw new Error(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that blocks inside a capability call it never gets an
|
|
3
|
+
* answer to, and is killed at its own total timeout (celilo#1403).
|
|
4
|
+
*
|
|
5
|
+
* The real shape is a consumer's `on_install` calling
|
|
6
|
+
* `public_web.publishStaticSite`, which blocks on the provider's route
|
|
7
|
+
* reconcile. This fixture reproduces the boundary condition — a call in flight
|
|
8
|
+
* when the kill lands — without a provider.
|
|
9
|
+
*
|
|
10
|
+
* It logs BEFORE the call so the run is not mistaken for an idle timeout, then
|
|
11
|
+
* awaits forever.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineHook } from '@celilo/capabilities';
|
|
15
|
+
|
|
16
|
+
interface SlowCapability {
|
|
17
|
+
neverReturns(request: Record<string, unknown>): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default defineHook({
|
|
21
|
+
hook: 'container_created',
|
|
22
|
+
requires: [],
|
|
23
|
+
handler: async (ctx) => {
|
|
24
|
+
const slow = (ctx.capabilities as unknown as Record<string, SlowCapability>).slow_provider;
|
|
25
|
+
ctx.logger.info('calling the provider');
|
|
26
|
+
await slow.neverReturns({});
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hook killed at its TOTAL timeout must name what it was waiting on.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-21 (celilo#1403): bna-yard-sale's `on_install` called
|
|
5
|
+
* `public_web.publishStaticSite`, which blocks on caddy's route reconcile —
|
|
6
|
+
* 70-75s across the fleet's sites. The consumer died at its 120s budget with:
|
|
7
|
+
*
|
|
8
|
+
* [bna-yard-sale:on_install] -> public_web.publishStaticSite
|
|
9
|
+
* [bna-yard-sale:on_install] OK dns_registrar.registerHost
|
|
10
|
+
* [bna-yard-sale:on_install] OK dns_internal.registerRecord
|
|
11
|
+
* [bna-yard-sale:on_install] FAIL Hook total timeout exceeded (120s)
|
|
12
|
+
*
|
|
13
|
+
* Both DNS calls completed; the push line had no matching completion, and that
|
|
14
|
+
* ABSENCE was the only evidence of which provider held it. The error named the
|
|
15
|
+
* hook and never the cause, so the operator had to infer it from a gap in the
|
|
16
|
+
* log — the same class of missing signal the greenwave/axon ISP-router hooks
|
|
17
|
+
* already carry a comment about.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { executeHookScript } from './executor';
|
|
24
|
+
import { createCapturingLogger } from './logger';
|
|
25
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
26
|
+
import type { HookContext } from './types';
|
|
27
|
+
|
|
28
|
+
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
29
|
+
|
|
30
|
+
/** A capability whose one method never settles — the provider that holds the hook. */
|
|
31
|
+
function hangingCapabilities(): Record<string, unknown> {
|
|
32
|
+
return {
|
|
33
|
+
slow_provider: {
|
|
34
|
+
providerModuleId: 'caddy',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
neverReturns: () => new Promise(() => {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runUntilTotalTimeout(): Promise<Error> {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
|
|
43
|
+
try {
|
|
44
|
+
const context: HookContext = {
|
|
45
|
+
config: configStore(),
|
|
46
|
+
secrets: secretStore(),
|
|
47
|
+
systems: [],
|
|
48
|
+
logger: createCapturingLogger().logger,
|
|
49
|
+
debug: false,
|
|
50
|
+
screenshotDir: dir,
|
|
51
|
+
stateDir: dir,
|
|
52
|
+
capabilities: hangingCapabilities(),
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await executeHookScript(join(FIXTURES, 'slow-capability-hook.ts'), context, {
|
|
56
|
+
// Total well under idle, so the kill is unambiguously the TOTAL one:
|
|
57
|
+
// a hook blocked in a capability call emits nothing, so a shorter idle
|
|
58
|
+
// timer would win the race and prove a different thing.
|
|
59
|
+
timeoutMs: 3_000,
|
|
60
|
+
idleTimeoutMs: 60_000,
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return error as Error;
|
|
64
|
+
}
|
|
65
|
+
throw new Error('the hook returned; it was supposed to be killed at its total timeout');
|
|
66
|
+
} finally {
|
|
67
|
+
rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('a total-timeout error names the provider it was waiting on (celilo#1403)', () => {
|
|
72
|
+
test('the run reached the total timeout, not the idle one', async () => {
|
|
73
|
+
const error = await runUntilTotalTimeout();
|
|
74
|
+
// Anchor the subject: an idle kill would exercise none of this.
|
|
75
|
+
expect(error.message).toContain('Hook total timeout exceeded');
|
|
76
|
+
expect(error.message).not.toContain('idle');
|
|
77
|
+
}, 20_000);
|
|
78
|
+
|
|
79
|
+
test('the message names the provider, the capability and the method', async () => {
|
|
80
|
+
const error = await runUntilTotalTimeout();
|
|
81
|
+
expect(error.message).toContain('waiting on');
|
|
82
|
+
// The provider MODULE is the operationally useful half — it is what the
|
|
83
|
+
// operator goes and looks at. `caddy` comes from `providerModuleId`.
|
|
84
|
+
expect(error.message).toContain('caddy');
|
|
85
|
+
expect(error.message).toContain('slow_provider.neverReturns');
|
|
86
|
+
}, 20_000);
|
|
87
|
+
});
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { closeDb } from '@/db/client';
|
|
6
|
+
import { COMMANDS, flattenLeaves, isReadOnlyPath, opOf, readOnlyGrants } from '@celilo/core';
|
|
6
7
|
import {
|
|
7
8
|
getPrincipalByName,
|
|
8
9
|
grantPrincipal,
|
|
@@ -136,3 +137,46 @@ describe('api-access (DB-backed)', () => {
|
|
|
136
137
|
expect(await listPrincipals()).toHaveLength(0);
|
|
137
138
|
});
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The recurrence gate for celilo#1404: every command classified read-only must
|
|
143
|
+
* actually be reachable by the read-only principal.
|
|
144
|
+
*
|
|
145
|
+
* The check is cross-instrument on purpose: `readOnlyGrants` decides what the RO
|
|
146
|
+
* principal is granted, and `grantsAllow` is the SERVER's own matcher deciding
|
|
147
|
+
* what that grant admits. What it catches is the two disagreeing about the shape
|
|
148
|
+
* of an op — which is how a derived list still leaves a read command denied.
|
|
149
|
+
* Verified by mutation (Rule 7.6): rendering the op as `module.journal` instead of
|
|
150
|
+
* `module:journal` turns this red.
|
|
151
|
+
*
|
|
152
|
+
* What it does NOT catch is the classifier itself calling a read a write — both
|
|
153
|
+
* sides read the same derivation, so they move together. That direction is
|
|
154
|
+
* covered in read-only-classifier.test.ts by a hand-written list of ops that must
|
|
155
|
+
* never be granted.
|
|
156
|
+
*/
|
|
157
|
+
describe('read-only principal reachability (celilo#1404)', () => {
|
|
158
|
+
const grants = readOnlyGrants(COMMANDS);
|
|
159
|
+
const readLeaves = flattenLeaves(COMMANDS).filter((l) => isReadOnlyPath(l.path));
|
|
160
|
+
|
|
161
|
+
test('the registry has read-only leaves at all', () => {
|
|
162
|
+
// Otherwise the sweep below is vacuously green.
|
|
163
|
+
expect(readLeaves.length).toBeGreaterThan(20);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the server admits every read-only command for the RO principal', () => {
|
|
167
|
+
const denied = readLeaves
|
|
168
|
+
.map((l) => ({ path: l.path.join(' '), op: opOf(l.path) }))
|
|
169
|
+
.filter((l) => !grantsAllow(grants, l.op.split(':')[0], l.op.split(':')[1]));
|
|
170
|
+
expect(denied).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('module journal — the op the live server denied with a bare exit 126', () => {
|
|
174
|
+
expect(grantsAllow(grants, 'module', 'journal')).toBe(true);
|
|
175
|
+
expect(grantsAllow(grants, 'module', 'where')).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('and still denies a write op', () => {
|
|
179
|
+
expect(grantsAllow(grants, 'module', 'deploy')).toBe(false);
|
|
180
|
+
expect(grantsAllow(grants, 'api', 'grant')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -20,6 +20,12 @@ const emptyDeps = {
|
|
|
20
20
|
db: fakeDb,
|
|
21
21
|
},
|
|
22
22
|
browserPin: { consumers: [], provisioned: null },
|
|
23
|
+
serverBunPin: {
|
|
24
|
+
pinnedVersion: '1.4.2',
|
|
25
|
+
runningVersion: '1.4.2',
|
|
26
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
27
|
+
},
|
|
28
|
+
rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
|
|
23
29
|
capabilityAbi: { modules: [] },
|
|
24
30
|
terraformPlan: {
|
|
25
31
|
modules: [],
|
|
@@ -42,6 +48,7 @@ const emptyDeps = {
|
|
|
42
48
|
secretsDecryptable: { results: [] },
|
|
43
49
|
servicesReachable: { results: [] },
|
|
44
50
|
machinesReachable: { results: [] },
|
|
51
|
+
machineInterfaceZones: { machines: [] },
|
|
45
52
|
diskSpace: { results: [] },
|
|
46
53
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
47
54
|
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
@@ -25,13 +25,19 @@ import {
|
|
|
25
25
|
import { type DiskSpaceAuditDeps, auditDiskSpace } from './disk-space';
|
|
26
26
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
27
27
|
import { type JailExemptionsAuditDeps, auditJailExemptions } from './jail-exemptions';
|
|
28
|
+
import {
|
|
29
|
+
type MachineInterfaceZonesAuditDeps,
|
|
30
|
+
auditMachineInterfaceZones,
|
|
31
|
+
} from './machine-interface-zones';
|
|
28
32
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
29
33
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
30
34
|
import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
|
|
31
35
|
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
32
36
|
import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
|
|
37
|
+
import { type RebootPendingAuditDeps, auditRebootPending } from './reboot-pending';
|
|
33
38
|
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
34
39
|
import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
|
|
40
|
+
import { type ServerBunPinAuditDeps, auditServerBunPin } from './server-bun-pin';
|
|
35
41
|
import {
|
|
36
42
|
type ServicesCredentialsAuditDeps,
|
|
37
43
|
auditServicesCredentials,
|
|
@@ -57,6 +63,8 @@ export interface AuditDeps {
|
|
|
57
63
|
schema: SchemaAuditDeps;
|
|
58
64
|
capabilityAbi: CapabilityAbiAuditDeps;
|
|
59
65
|
browserPin: BrowserPinAuditDeps;
|
|
66
|
+
serverBunPin: ServerBunPinAuditDeps;
|
|
67
|
+
rebootPending: RebootPendingAuditDeps;
|
|
60
68
|
terraformPlan: TerraformPlanAuditDeps;
|
|
61
69
|
moduleVersions: ModuleVersionsAuditDeps;
|
|
62
70
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
@@ -72,6 +80,7 @@ export interface AuditDeps {
|
|
|
72
80
|
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
73
81
|
servicesReachable: ServicesReachableAuditDeps;
|
|
74
82
|
machinesReachable: MachinesReachableAuditDeps;
|
|
83
|
+
machineInterfaceZones: MachineInterfaceZonesAuditDeps;
|
|
75
84
|
publicDns: PublicDnsAuditDeps;
|
|
76
85
|
diskSpace: DiskSpaceAuditDeps;
|
|
77
86
|
transportReads: TransportReadsAuditDeps;
|
|
@@ -115,6 +124,8 @@ export async function runAudit(
|
|
|
115
124
|
wrap('schema', auditSchema(deps.schema)),
|
|
116
125
|
wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
|
|
117
126
|
wrap('browser_pin', auditBrowserPin(deps.browserPin)),
|
|
127
|
+
wrap('server_bun_pin', Promise.resolve(auditServerBunPin(deps.serverBunPin))),
|
|
128
|
+
wrap('reboot_pending', Promise.resolve(auditRebootPending(deps.rebootPending))),
|
|
118
129
|
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
119
130
|
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
120
131
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
@@ -136,6 +147,10 @@ export async function runAudit(
|
|
|
136
147
|
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
137
148
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
138
149
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
150
|
+
wrap(
|
|
151
|
+
'interface_classification',
|
|
152
|
+
Promise.resolve(auditMachineInterfaceZones(deps.machineInterfaceZones)),
|
|
153
|
+
),
|
|
139
154
|
wrap('public_dns', auditPublicDns(deps.publicDns)),
|
|
140
155
|
wrap('disk_space', Promise.resolve(auditDiskSpace(deps.diskSpace))),
|
|
141
156
|
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditMachineInterfaceZones } from './machine-interface-zones';
|
|
3
|
+
|
|
4
|
+
describe('auditMachineInterfaceZones', () => {
|
|
5
|
+
test('reports a machine whose stored labels predate the zone model', () => {
|
|
6
|
+
const findings = auditMachineInterfaceZones({
|
|
7
|
+
machines: [
|
|
8
|
+
{
|
|
9
|
+
hostname: 'celilo-mgr',
|
|
10
|
+
changes: [
|
|
11
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', from: 'external', to: 'secure-mgmt' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(findings).toHaveLength(1);
|
|
18
|
+
expect(findings[0].severity).toBe('drift');
|
|
19
|
+
expect(findings[0].code).toBe('stale_interface_zone_labels');
|
|
20
|
+
expect(findings[0].subject).toBe('celilo-mgr');
|
|
21
|
+
expect(findings[0].message).toContain('external → secure-mgmt');
|
|
22
|
+
expect(findings[0].remediation).toContain('celilo machine reclassify celilo-mgr --apply');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reports nothing for a machine whose labels agree', () => {
|
|
26
|
+
expect(auditMachineInterfaceZones({ machines: [{ hostname: 'pi-01', changes: [] }] })).toEqual(
|
|
27
|
+
[],
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored machine interface zone labels that no longer match the zone model.
|
|
3
|
+
*
|
|
4
|
+
* A label written at `machine add` is never revisited, so a zone-model change
|
|
5
|
+
* leaves every earlier machine describing a topology that no longer exists.
|
|
6
|
+
* Nothing said so until this check: celilo#1382 surfaced only as an upgrade
|
|
7
|
+
* failing with "no non-loopback interface in zone 'secure-mgmt'", on a machine
|
|
8
|
+
* that had one.
|
|
9
|
+
*
|
|
10
|
+
* `drift`, not `blocked`: a stale label breaks a deploy only when something
|
|
11
|
+
* resolves by zone, and the remediation is one command.
|
|
12
|
+
*
|
|
13
|
+
* Pure over a pre-computed diff — see services/machine-interface-zones.ts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ZoneReclassification } from '../machine-interface-zones';
|
|
17
|
+
import type { DriftFinding } from './types';
|
|
18
|
+
|
|
19
|
+
export interface MachineInterfaceZonesAuditDeps {
|
|
20
|
+
/** One entry per machine, carrying only the labels that disagree. */
|
|
21
|
+
machines: Array<{ hostname: string; changes: ZoneReclassification[] }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function auditMachineInterfaceZones(deps: MachineInterfaceZonesAuditDeps): DriftFinding[] {
|
|
25
|
+
return deps.machines
|
|
26
|
+
.filter((m) => m.changes.length > 0)
|
|
27
|
+
.map(({ hostname, changes }) => {
|
|
28
|
+
const named = changes
|
|
29
|
+
.map((c) => `${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`)
|
|
30
|
+
.join(', ');
|
|
31
|
+
return {
|
|
32
|
+
category: 'interface_classification',
|
|
33
|
+
severity: 'drift',
|
|
34
|
+
code: 'stale_interface_zone_labels',
|
|
35
|
+
subject: hostname,
|
|
36
|
+
message: `${hostname}: ${changes.length} stored interface zone label(s) predate the current zone model — ${named}`,
|
|
37
|
+
details:
|
|
38
|
+
'Interface zones are decided once, at `machine add`, and never re-checked. When a zone is added or a subnet is declared for one that had none, every machine added earlier keeps the old label. Anything resolving a machine by zone then reports the interface as absent rather than as mislabelled.',
|
|
39
|
+
remediation: `celilo machine reclassify ${hostname} --apply`,
|
|
40
|
+
actionable: true,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { auditRebootPending } from './reboot-pending';
|
|
3
|
+
|
|
4
|
+
const CLEAN = { pending: false, unmeasured: false, reasons: [] };
|
|
5
|
+
const NOW = 1_700_000_000_000;
|
|
6
|
+
|
|
7
|
+
describe('auditRebootPending', () => {
|
|
8
|
+
it('reports a pending reboot as drift, carrying the reason', () => {
|
|
9
|
+
const [finding] = auditRebootPending({
|
|
10
|
+
verdict: {
|
|
11
|
+
pending: true,
|
|
12
|
+
unmeasured: false,
|
|
13
|
+
reasons: ['the running kernel is 6.8.0-1063-raspi'],
|
|
14
|
+
},
|
|
15
|
+
marker: null,
|
|
16
|
+
now: () => NOW,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(finding.severity).toBe('drift');
|
|
20
|
+
expect(finding.code).toBe('reboot_pending');
|
|
21
|
+
expect(finding.message).toContain('6.8.0-1063-raspi');
|
|
22
|
+
expect(finding.remediation).toContain('celilo system reboot');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports an unverified reboot as BLOCKED — a control plane may not have come back', () => {
|
|
26
|
+
const [finding] = auditRebootPending({
|
|
27
|
+
verdict: CLEAN,
|
|
28
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW - 3 * 60 * 60 * 1000, reason: 'kernel' },
|
|
29
|
+
now: () => NOW,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(finding.severity).toBe('blocked');
|
|
33
|
+
expect(finding.code).toBe('reboot_unverified');
|
|
34
|
+
expect(finding.message).toContain('3h ago');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is silent on a host that is current and has no reboot outstanding', () => {
|
|
38
|
+
expect(auditRebootPending({ verdict: CLEAN, marker: null, now: () => NOW })).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports UNMEASURED rather than clean when the host could not be asked', () => {
|
|
42
|
+
const [finding] = auditRebootPending({
|
|
43
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
44
|
+
marker: null,
|
|
45
|
+
now: () => NOW,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(finding.severity).toBe('unmeasured');
|
|
49
|
+
expect(finding.actionable).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('still reports the unverified reboot when the host itself is unmeasurable', () => {
|
|
53
|
+
// The two facts are independent: not being able to read the kernel does
|
|
54
|
+
// not make an outstanding reboot request go away.
|
|
55
|
+
const findings = auditRebootPending({
|
|
56
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
57
|
+
marker: { bootIdBefore: 'a', requestedAt: NOW, reason: '' },
|
|
58
|
+
now: () => NOW,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(findings.map((f) => f.code)).toEqual(['reboot_unverified', 'reboot_pending_unmeasured']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending reboot on the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* `apt-upgrade` printed needrestart's pending-kernel block and that was the
|
|
5
|
+
* only place the condition was ever visible — so it was visible exactly when
|
|
6
|
+
* an operator happened to run an upgrade, and invisible the rest of the time.
|
|
7
|
+
* celilo-mgr sat on a kernel it was not running for days that way.
|
|
8
|
+
*
|
|
9
|
+
* Two findings, and they are different facts:
|
|
10
|
+
*
|
|
11
|
+
* reboot_pending — the host needs a reboot and has not had one.
|
|
12
|
+
* reboot_unverified — celilo ASKED for a reboot and nothing has confirmed
|
|
13
|
+
* the host came back. That is the more serious of the
|
|
14
|
+
* two: it is the shape of a control plane that went
|
|
15
|
+
* down and did not return, or returned without its
|
|
16
|
+
* dispatcher.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RebootMarker, RebootPendingVerdict } from '../system-reboot';
|
|
20
|
+
import type { DriftFinding } from './types';
|
|
21
|
+
|
|
22
|
+
export interface RebootPendingAuditDeps {
|
|
23
|
+
verdict: RebootPendingVerdict;
|
|
24
|
+
/** A reboot celilo requested that `verifyReturn` has not yet cleared. */
|
|
25
|
+
marker: RebootMarker | null;
|
|
26
|
+
/** Defaults to `Date.now()`. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HOUR = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
export function auditRebootPending(deps: RebootPendingAuditDeps): DriftFinding[] {
|
|
33
|
+
const findings: DriftFinding[] = [];
|
|
34
|
+
const now = (deps.now ?? Date.now)();
|
|
35
|
+
|
|
36
|
+
if (deps.marker !== null) {
|
|
37
|
+
const ageHours = Math.round((now - deps.marker.requestedAt) / HOUR);
|
|
38
|
+
findings.push({
|
|
39
|
+
category: 'reboot_pending',
|
|
40
|
+
severity: 'blocked',
|
|
41
|
+
code: 'reboot_unverified',
|
|
42
|
+
subject: 'system',
|
|
43
|
+
message: `celilo requested a reboot ${ageHours}h ago and nothing has confirmed the host came back.`,
|
|
44
|
+
details:
|
|
45
|
+
'The process that asks for a reboot goes down with the host, so the return is verified by a second call. An uncleared marker means that call never succeeded — either the host did not reboot, or it came back without its dispatcher.',
|
|
46
|
+
remediation:
|
|
47
|
+
'Run `celilo system reboot --verify`. It passes only once the boot id has changed and the dispatcher and CLI answer, and it names which of those is missing.',
|
|
48
|
+
actionable: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (deps.verdict.unmeasured) {
|
|
53
|
+
findings.push({
|
|
54
|
+
category: 'reboot_pending',
|
|
55
|
+
severity: 'unmeasured',
|
|
56
|
+
code: 'reboot_pending_unmeasured',
|
|
57
|
+
subject: 'system',
|
|
58
|
+
message:
|
|
59
|
+
'Neither the reboot-required marker nor a kernel pair could be read, so nothing was compared.',
|
|
60
|
+
details:
|
|
61
|
+
'An absent /var/run/reboot-required is not evidence of a current kernel — a host without that path simply never writes one.',
|
|
62
|
+
remediation:
|
|
63
|
+
'Run `celilo system audit` on the management server itself (Debian/Ubuntu), where /var/run/reboot-required and `uname -r` are readable.',
|
|
64
|
+
actionable: false,
|
|
65
|
+
});
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (deps.verdict.pending) {
|
|
70
|
+
findings.push({
|
|
71
|
+
category: 'reboot_pending',
|
|
72
|
+
severity: 'drift',
|
|
73
|
+
code: 'reboot_pending',
|
|
74
|
+
subject: 'system',
|
|
75
|
+
message: `The management server needs a reboot: ${deps.verdict.reasons.join('; ')}.`,
|
|
76
|
+
details:
|
|
77
|
+
'A kernel or service the host has installed is not the one it is running. Until it reboots, an upgrade that has been applied on disk has not taken effect.',
|
|
78
|
+
remediation:
|
|
79
|
+
'Reboot through celilo: `celilo system reboot --allow-reboot`. It refuses while a module operation is in flight, and `--verify` confirms the host came back.',
|
|
80
|
+
actionable: true,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
@@ -38,7 +38,9 @@ import { auditInterfaceClassification } from './interface-classification';
|
|
|
38
38
|
import { auditModuleIntegrity } from './module-integrity';
|
|
39
39
|
import { auditModuleVersions } from './module-versions';
|
|
40
40
|
import { auditPublicDns } from './public-dns';
|
|
41
|
+
import { auditRebootPending } from './reboot-pending';
|
|
41
42
|
import { auditSchema } from './schema';
|
|
43
|
+
import { auditServerBunPin } from './server-bun-pin';
|
|
42
44
|
import { auditTerraformPlan } from './terraform-plan';
|
|
43
45
|
import { auditTrustedSources } from './trusted-sources';
|
|
44
46
|
import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
|
|
@@ -68,6 +70,25 @@ const GATE: Record<DriftCategory, GateEntry> = {
|
|
|
68
70
|
because:
|
|
69
71
|
'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
|
|
70
72
|
},
|
|
73
|
+
reboot_pending: {
|
|
74
|
+
probe: () =>
|
|
75
|
+
auditRebootPending({
|
|
76
|
+
verdict: { pending: false, unmeasured: true, reasons: [] },
|
|
77
|
+
marker: null,
|
|
78
|
+
}),
|
|
79
|
+
because:
|
|
80
|
+
'probed: a host with no /var/run/reboot-required and no readable kernel pair was not measured, and an absent marker is not a current kernel',
|
|
81
|
+
},
|
|
82
|
+
server_bun_pin: {
|
|
83
|
+
probe: () =>
|
|
84
|
+
auditServerBunPin({
|
|
85
|
+
pinnedVersion: null,
|
|
86
|
+
runningVersion: '1.4.2',
|
|
87
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
88
|
+
}),
|
|
89
|
+
because:
|
|
90
|
+
'probed: the deb pin file can be absent (a non-deb install), and then nothing was compared',
|
|
91
|
+
},
|
|
71
92
|
browser_pin: {
|
|
72
93
|
because:
|
|
73
94
|
'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
|