@celilo/cli 4.0.0 → 5.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/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- 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/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- 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 +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- 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/module-configs.test.ts +1 -1
- 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/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- 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/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate celilo#1378 asks for: drive the reboot against a FAKE
|
|
3
|
+
* host, with an operation in flight (it must refuse and name it) and with
|
|
4
|
+
* nothing in flight (it must reboot, and the return must be verified rather
|
|
5
|
+
* than assumed).
|
|
6
|
+
*
|
|
7
|
+
* Every dependency is injected, so nothing here reboots anything.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test';
|
|
11
|
+
import type { InFlightConflict } from './module-operations';
|
|
12
|
+
import {
|
|
13
|
+
type RebootMarker,
|
|
14
|
+
collectRebootPendingFacts,
|
|
15
|
+
judgeRebootPending,
|
|
16
|
+
requestReboot,
|
|
17
|
+
verifyReturn,
|
|
18
|
+
} from './system-reboot';
|
|
19
|
+
|
|
20
|
+
const BOOT_A = '11111111-1111-1111-1111-111111111111';
|
|
21
|
+
const BOOT_B = '22222222-2222-2222-2222-222222222222';
|
|
22
|
+
|
|
23
|
+
function conflict(describe: string): InFlightConflict {
|
|
24
|
+
return { operation: {} as InFlightConflict['operation'], describe };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A fake host: records what was asked of it, answers what it was told to. */
|
|
28
|
+
function fakeHost(opts: { conflicts?: string[]; bootId?: string | null } = {}) {
|
|
29
|
+
const state = { rebooted: 0, marker: null as RebootMarker | null };
|
|
30
|
+
return {
|
|
31
|
+
state,
|
|
32
|
+
deps: {
|
|
33
|
+
conflicts: () => (opts.conflicts ?? []).map(conflict),
|
|
34
|
+
bootId: () => (opts.bootId === undefined ? BOOT_A : opts.bootId),
|
|
35
|
+
issueReboot: () => {
|
|
36
|
+
state.rebooted += 1;
|
|
37
|
+
return { status: 0 };
|
|
38
|
+
},
|
|
39
|
+
writeMarker: (m: RebootMarker) => {
|
|
40
|
+
state.marker = m;
|
|
41
|
+
},
|
|
42
|
+
now: () => 1_700_000_000_000,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('requestReboot — refuses while the fleet is busy', () => {
|
|
48
|
+
it('refuses with an operation in flight, and names which one', () => {
|
|
49
|
+
const host = fakeHost({ conflicts: ['deploy of homebridge (pid 12345)'] });
|
|
50
|
+
|
|
51
|
+
const result = requestReboot(host.deps, 'pending kernel');
|
|
52
|
+
|
|
53
|
+
expect(result.success).toBe(false);
|
|
54
|
+
expect(result.error).toContain('deploy of homebridge (pid 12345)');
|
|
55
|
+
// The whole point: naming it is not enough if it rebooted anyway.
|
|
56
|
+
expect(host.state.rebooted).toBe(0);
|
|
57
|
+
expect(host.state.marker).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('refuses when the boot id cannot be read, because the return could never be verified', () => {
|
|
61
|
+
const host = fakeHost({ bootId: null });
|
|
62
|
+
|
|
63
|
+
const result = requestReboot(host.deps);
|
|
64
|
+
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
expect(result.error).toContain('could not be verified');
|
|
67
|
+
expect(host.state.rebooted).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reboots with nothing in flight, recording the boot id the return is checked against', () => {
|
|
71
|
+
const host = fakeHost();
|
|
72
|
+
|
|
73
|
+
const result = requestReboot(host.deps, 'pending kernel 6.8.0-1064-raspi');
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
expect(host.state.rebooted).toBe(1);
|
|
77
|
+
expect(host.state.marker?.bootIdBefore).toBe(BOOT_A);
|
|
78
|
+
expect(host.state.marker?.reason).toContain('6.8.0-1064-raspi');
|
|
79
|
+
// It reports the reboot as ISSUED, never as completed — the process
|
|
80
|
+
// asking for it dies in it (celilo#1378).
|
|
81
|
+
expect(result.message).toContain('--verify');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('leaves the marker in place when the reboot command itself fails', () => {
|
|
85
|
+
const host = fakeHost();
|
|
86
|
+
const result = requestReboot(
|
|
87
|
+
{ ...host.deps, issueReboot: () => ({ status: 1 }) },
|
|
88
|
+
'pending kernel',
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result.success).toBe(false);
|
|
92
|
+
expect(result.error).toContain('was not rebooted');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('verifyReturn — only the host actually coming back is success', () => {
|
|
97
|
+
const marker: RebootMarker = { bootIdBefore: BOOT_A, requestedAt: 1, reason: 'kernel' };
|
|
98
|
+
const cameBack = {
|
|
99
|
+
bootId: () => BOOT_B,
|
|
100
|
+
readMarker: () => marker,
|
|
101
|
+
heartbeatLive: async () => true,
|
|
102
|
+
cliVersion: () => '3.0.1',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it('reports success once the boot id changed, the dispatcher is live and the CLI answers', async () => {
|
|
106
|
+
let cleared = 0;
|
|
107
|
+
const result = await verifyReturn({
|
|
108
|
+
...cameBack,
|
|
109
|
+
clearMarker: () => {
|
|
110
|
+
cleared += 1;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
expect(result.message).toContain('3.0.1');
|
|
116
|
+
expect(cleared).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('fails when the boot id is unchanged — the host never went down', async () => {
|
|
120
|
+
const result = await verifyReturn({ ...cameBack, bootId: () => BOOT_A, clearMarker: () => {} });
|
|
121
|
+
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
expect(result.error).toContain('has NOT rebooted');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('fails when the box booted but the dispatcher did not, and says so', async () => {
|
|
127
|
+
const result = await verifyReturn({
|
|
128
|
+
...cameBack,
|
|
129
|
+
heartbeatLive: async () => false,
|
|
130
|
+
clearMarker: () => {},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.success).toBe(false);
|
|
134
|
+
expect(result.error).toContain('dispatcher');
|
|
135
|
+
expect(result.error).toContain('unverified');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('fails when the CLI does not answer', async () => {
|
|
139
|
+
const result = await verifyReturn({
|
|
140
|
+
...cameBack,
|
|
141
|
+
cliVersion: () => null,
|
|
142
|
+
clearMarker: () => {},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
expect(result.error).toContain('--version');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps the marker when verification fails, so the audit still sees it', async () => {
|
|
150
|
+
let cleared = 0;
|
|
151
|
+
await verifyReturn({
|
|
152
|
+
...cameBack,
|
|
153
|
+
heartbeatLive: async () => false,
|
|
154
|
+
clearMarker: () => {
|
|
155
|
+
cleared += 1;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(cleared).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('says so plainly when no reboot was ever requested', async () => {
|
|
163
|
+
const result = await verifyReturn({ ...cameBack, readMarker: () => null });
|
|
164
|
+
|
|
165
|
+
expect(result.success).toBe(false);
|
|
166
|
+
expect(result.error).toContain('No reboot is pending');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('judgeRebootPending — two independent tells', () => {
|
|
171
|
+
const none = {
|
|
172
|
+
markerPresent: false,
|
|
173
|
+
deferredPackages: [],
|
|
174
|
+
runningKernel: '6.8.0-1064-raspi',
|
|
175
|
+
installedKernel: '6.8.0-1064-raspi',
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
it('is the #1378 case: a running kernel older than the installed one', () => {
|
|
179
|
+
const verdict = judgeRebootPending({ ...none, runningKernel: '6.8.0-1063-raspi' });
|
|
180
|
+
|
|
181
|
+
expect(verdict.pending).toBe(true);
|
|
182
|
+
expect(verdict.reasons[0]).toContain('6.8.0-1063-raspi');
|
|
183
|
+
expect(verdict.reasons[0]).toContain('6.8.0-1064-raspi');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("reports Debian's own marker, naming the packages that deferred", () => {
|
|
187
|
+
const verdict = judgeRebootPending({
|
|
188
|
+
...none,
|
|
189
|
+
markerPresent: true,
|
|
190
|
+
deferredPackages: ['docker.service', 'dbus.service'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(verdict.pending).toBe(true);
|
|
194
|
+
expect(verdict.reasons.join(' ')).toContain('docker.service');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('is clean when the kernel matches and nothing deferred', () => {
|
|
198
|
+
expect(judgeRebootPending(none)).toEqual({ pending: false, unmeasured: false, reasons: [] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('is UNMEASURED, not clean, when the host could not be asked', () => {
|
|
202
|
+
// An absent /var/run/reboot-required on a host that has no /var/run is not
|
|
203
|
+
// evidence of a healthy kernel. Reporting it clean is the #1326 shape.
|
|
204
|
+
const verdict = judgeRebootPending({
|
|
205
|
+
markerPresent: false,
|
|
206
|
+
deferredPackages: [],
|
|
207
|
+
runningKernel: null,
|
|
208
|
+
installedKernel: null,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(verdict).toEqual({ pending: false, unmeasured: true, reasons: [] });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('collectRebootPendingFacts', () => {
|
|
216
|
+
it('reads the marker and splits the deferred package list', () => {
|
|
217
|
+
const facts = collectRebootPendingFacts(
|
|
218
|
+
(path) => (path.endsWith('.pkgs') ? 'docker\n\ndbus\n' : ''),
|
|
219
|
+
'6.8.0-1063-raspi',
|
|
220
|
+
'6.8.0-1064-raspi',
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(facts.markerPresent).toBe(true);
|
|
224
|
+
expect(facts.deferredPackages).toEqual(['docker', 'dbus']);
|
|
225
|
+
expect(facts.runningKernel).toBe('6.8.0-1063-raspi');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('reports no marker when the file is absent', () => {
|
|
229
|
+
expect(collectRebootPendingFacts(() => null).markerPresent).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebooting the management server (celilo#1378).
|
|
3
|
+
*
|
|
4
|
+
* celilo already DETECTS that celilo-mgr needs a reboot — `apt-upgrade` prints
|
|
5
|
+
* needrestart's pending-kernel block — and until now offered no way to act on
|
|
6
|
+
* it. The only path was SSH and `sudo reboot`: out of band, unrecorded, and not
|
|
7
|
+
* something an agent can reach, since agents operate celilo-mgr only through
|
|
8
|
+
* the MCP server.
|
|
9
|
+
*
|
|
10
|
+
* Rebooting this box is not `systemctl reboot`. It takes down the event
|
|
11
|
+
* dispatcher, the MCP server, the remote API and anything deploying, so the
|
|
12
|
+
* reboot is gated on nothing being in flight and on explicit consent.
|
|
13
|
+
*
|
|
14
|
+
* WHY VERIFICATION IS A SECOND CALL. The process that asks for the reboot dies
|
|
15
|
+
* in it — it runs ON the host it is rebooting. So no single invocation can both
|
|
16
|
+
* reboot and report that the host came back; a command that claimed to would be
|
|
17
|
+
* reporting a request as an outcome, which is the thing the issue asks us not
|
|
18
|
+
* to do. Instead `requestReboot` records a marker carrying the CURRENT boot id,
|
|
19
|
+
* and `verifyReturn` is what reports success: it passes only once the boot id
|
|
20
|
+
* has actually changed AND the dispatcher and CLI answer. An unverified marker
|
|
21
|
+
* is a reboot that did not come back, and the audit reports it as one.
|
|
22
|
+
*
|
|
23
|
+
* WHAT "IN FLIGHT" COVERS, and what it does not. The gate is `checkInFlight`,
|
|
24
|
+
* the module-operation lock that backup and restore already consult — a deploy,
|
|
25
|
+
* uninstall, backup or restore registers there, so all four hold the reboot off.
|
|
26
|
+
* celilo#1378 also names the e2e rig lock; that one is deliberately NOT checked,
|
|
27
|
+
* because the rig runs on a developer's machine and never on celilo-mgr, so
|
|
28
|
+
* reading it here would be a check that cannot reach its subject.
|
|
29
|
+
*
|
|
30
|
+
* Every judgement here is pure over injected facts, so the refusal and the
|
|
31
|
+
* verification are both testable against a fake host.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { release } from 'node:os';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
import { getDataDir } from '../config/paths';
|
|
38
|
+
import { type InFlightConflict, checkInFlight } from './module-operations';
|
|
39
|
+
|
|
40
|
+
/** Debian writes this the moment a package needs a reboot to take effect. */
|
|
41
|
+
export const REBOOT_REQUIRED_PATH = '/var/run/reboot-required';
|
|
42
|
+
/** …and the packages that asked for it here, one per line. */
|
|
43
|
+
export const REBOOT_REQUIRED_PKGS_PATH = '/var/run/reboot-required.pkgs';
|
|
44
|
+
/** The kernel's own identity for this boot. It changes on every boot, and only then. */
|
|
45
|
+
export const BOOT_ID_PATH = '/proc/sys/kernel/random/boot_id';
|
|
46
|
+
|
|
47
|
+
/** What the host says about whether it needs a reboot. */
|
|
48
|
+
export interface RebootPendingFacts {
|
|
49
|
+
/** `/var/run/reboot-required` is present. */
|
|
50
|
+
markerPresent: boolean;
|
|
51
|
+
/** Packages named in `/var/run/reboot-required.pkgs`. */
|
|
52
|
+
deferredPackages: string[];
|
|
53
|
+
/** `uname -r`, or null when it could not be read. */
|
|
54
|
+
runningKernel: string | null;
|
|
55
|
+
/** The newest kernel installed on disk, or null when it could not be read. */
|
|
56
|
+
installedKernel: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RebootPendingVerdict {
|
|
60
|
+
pending: boolean;
|
|
61
|
+
/** True when the host could not be asked at all — NOT the same as "no reboot needed". */
|
|
62
|
+
unmeasured: boolean;
|
|
63
|
+
/** Operator-readable reasons, in the order they were found. */
|
|
64
|
+
reasons: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Two independent tells, and either one is enough.
|
|
69
|
+
*
|
|
70
|
+
* The marker file is Debian's own flag and covers the deferred service
|
|
71
|
+
* restarts. The kernel comparison covers the case the marker misses and the
|
|
72
|
+
* case #1378 was filed from: running 6.8.0-1063-raspi with 1064 installed.
|
|
73
|
+
*/
|
|
74
|
+
export function judgeRebootPending(facts: RebootPendingFacts): RebootPendingVerdict {
|
|
75
|
+
const { markerPresent, deferredPackages, runningKernel, installedKernel } = facts;
|
|
76
|
+
|
|
77
|
+
const kernelKnown = runningKernel !== null && installedKernel !== null;
|
|
78
|
+
// Nothing was measured: no marker AND no kernel pair to compare. An absent
|
|
79
|
+
// marker alone is not evidence of a healthy host — the file lives under
|
|
80
|
+
// /var/run, which a container or a non-Debian host simply does not have.
|
|
81
|
+
if (!markerPresent && !kernelKnown) {
|
|
82
|
+
return { pending: false, unmeasured: true, reasons: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reasons: string[] = [];
|
|
86
|
+
if (kernelKnown && runningKernel !== installedKernel) {
|
|
87
|
+
reasons.push(
|
|
88
|
+
`the running kernel is ${runningKernel} but ${installedKernel} is installed — the new kernel loads on reboot and not before`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (markerPresent) {
|
|
92
|
+
reasons.push(
|
|
93
|
+
deferredPackages.length > 0
|
|
94
|
+
? `${REBOOT_REQUIRED_PATH} is present; deferred by: ${deferredPackages.join(', ')}`
|
|
95
|
+
: `${REBOOT_REQUIRED_PATH} is present`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { pending: reasons.length > 0, unmeasured: false, reasons };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read the host's own answer. The only impure part of the detection. */
|
|
103
|
+
export function collectRebootPendingFacts(
|
|
104
|
+
read: (path: string) => string | null = readFileOrNull,
|
|
105
|
+
runningKernel: string | null = null,
|
|
106
|
+
installedKernel: string | null = null,
|
|
107
|
+
): RebootPendingFacts {
|
|
108
|
+
return {
|
|
109
|
+
markerPresent: read(REBOOT_REQUIRED_PATH) !== null,
|
|
110
|
+
deferredPackages: (read(REBOOT_REQUIRED_PKGS_PATH) ?? '')
|
|
111
|
+
.split('\n')
|
|
112
|
+
.map((line) => line.trim())
|
|
113
|
+
.filter((line) => line.length > 0),
|
|
114
|
+
runningKernel,
|
|
115
|
+
installedKernel,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readFileOrNull(path: string): string | null {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(path, 'utf-8');
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The marker a reboot request leaves behind, so the return can be verified. */
|
|
128
|
+
export interface RebootMarker {
|
|
129
|
+
/** The boot id at the moment the reboot was requested. */
|
|
130
|
+
bootIdBefore: string;
|
|
131
|
+
/** Epoch ms. */
|
|
132
|
+
requestedAt: number;
|
|
133
|
+
/** Why the operator asked — echoed back by the audit finding. */
|
|
134
|
+
reason: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function rebootMarkerPath(dataDir: string = getDataDir()): string {
|
|
138
|
+
return join(dataDir, 'state', 'reboot-requested.json');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function readRebootMarker(path: string = rebootMarkerPath()): RebootMarker | null {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<RebootMarker>;
|
|
144
|
+
if (typeof parsed.bootIdBefore !== 'string' || typeof parsed.requestedAt !== 'number') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
bootIdBefore: parsed.bootIdBefore,
|
|
149
|
+
requestedAt: parsed.requestedAt,
|
|
150
|
+
reason: parsed.reason ?? '',
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function writeRebootMarker(marker: RebootMarker, path: string = rebootMarkerPath()): void {
|
|
158
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
159
|
+
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`, 'utf-8');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function clearRebootMarker(path: string = rebootMarkerPath()): void {
|
|
163
|
+
rmSync(path, { force: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RequestRebootDeps {
|
|
167
|
+
/** In-flight module operations. Defaults to the real `checkInFlight`. */
|
|
168
|
+
conflicts?: () => InFlightConflict[];
|
|
169
|
+
/** This boot's id, or null when unreadable. */
|
|
170
|
+
bootId: () => string | null;
|
|
171
|
+
/** Fire the reboot. Only reached once every gate above has passed. */
|
|
172
|
+
issueReboot: () => { status: number | null };
|
|
173
|
+
writeMarker?: (marker: RebootMarker) => void;
|
|
174
|
+
now?: () => number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RequestRebootResult {
|
|
178
|
+
success: boolean;
|
|
179
|
+
error?: string;
|
|
180
|
+
message?: string;
|
|
181
|
+
/** The boot id recorded for `verifyReturn` to compare against. */
|
|
182
|
+
bootIdBefore?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Refuse, or reboot. Consent is the CALLER's to obtain (flag or interview) —
|
|
187
|
+
* this function is the part that must not be bypassed, so it holds only the
|
|
188
|
+
* checks, never the prompt.
|
|
189
|
+
*/
|
|
190
|
+
export function requestReboot(deps: RequestRebootDeps, reason = ''): RequestRebootResult {
|
|
191
|
+
const conflicts = (deps.conflicts ?? checkInFlight)();
|
|
192
|
+
if (conflicts.length > 0) {
|
|
193
|
+
// Name what we are waiting on. "Busy, try later" sends the operator to
|
|
194
|
+
// guess, and the guess is usually a reboot anyway.
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
error: `Refusing to reboot: ${conflicts.length} module operation(s) in flight.\n${conflicts
|
|
198
|
+
.map((c) => ` • ${c.describe}`)
|
|
199
|
+
.join(
|
|
200
|
+
'\n',
|
|
201
|
+
)}\nWait for them to finish, or release them with \`celilo module operations clear\` if they are not really running.`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const bootIdBefore = deps.bootId();
|
|
206
|
+
if (bootIdBefore === null) {
|
|
207
|
+
// Without a before-value there is nothing to compare after, so a reboot
|
|
208
|
+
// here could never be verified — and an unverifiable reboot of the
|
|
209
|
+
// control plane is exactly what this command exists to replace.
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
error: `Refusing to reboot: could not read this boot's id from ${BOOT_ID_PATH}, so the host coming back could not be verified afterwards.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
(deps.writeMarker ?? writeRebootMarker)({
|
|
217
|
+
bootIdBefore,
|
|
218
|
+
requestedAt: (deps.now ?? Date.now)(),
|
|
219
|
+
reason,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const { status } = deps.issueReboot();
|
|
223
|
+
if (status !== 0) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: `Reboot command exited ${status ?? 'on a signal'}. The host was not rebooted; the pending marker is left in place for \`celilo system reboot --verify\` to clear.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
success: true,
|
|
232
|
+
bootIdBefore,
|
|
233
|
+
message:
|
|
234
|
+
'Reboot issued. This process goes down with the host, so it cannot report the return — ' +
|
|
235
|
+
'run `celilo system reboot --verify` once celilo-mgr answers again, or read `celilo system audit`, ' +
|
|
236
|
+
'which reports an unverified reboot as a finding.',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface VerifyReturnDeps {
|
|
241
|
+
bootId: () => string | null;
|
|
242
|
+
readMarker?: () => RebootMarker | null;
|
|
243
|
+
clearMarker?: () => void;
|
|
244
|
+
/** Is the event dispatcher alive and emitting? */
|
|
245
|
+
heartbeatLive: () => Promise<boolean>;
|
|
246
|
+
/** Does the installed CLI answer `--version`? Returns the version, or null. */
|
|
247
|
+
cliVersion: () => string | null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface VerifyReturnResult {
|
|
251
|
+
success: boolean;
|
|
252
|
+
error?: string;
|
|
253
|
+
message?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The half that is allowed to say "the host came back".
|
|
258
|
+
*
|
|
259
|
+
* All three must hold: the boot id CHANGED (so we are not reading the machine
|
|
260
|
+
* that never went down), the dispatcher is live, and the CLI answers. Any one
|
|
261
|
+
* of them alone passes on a host that is half up — a booted box whose
|
|
262
|
+
* dispatcher never started is not a successful reboot of a control plane.
|
|
263
|
+
*/
|
|
264
|
+
export async function verifyReturn(deps: VerifyReturnDeps): Promise<VerifyReturnResult> {
|
|
265
|
+
const marker = (deps.readMarker ?? (() => readRebootMarker()))();
|
|
266
|
+
if (marker === null) {
|
|
267
|
+
return {
|
|
268
|
+
success: false,
|
|
269
|
+
error: 'No reboot is pending verification — nothing recorded a reboot request.',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const bootIdNow = deps.bootId();
|
|
274
|
+
if (bootIdNow === null) {
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
error: `Could not read ${BOOT_ID_PATH}, so the reboot could not be verified.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (bootIdNow === marker.bootIdBefore) {
|
|
281
|
+
return {
|
|
282
|
+
success: false,
|
|
283
|
+
error: `The host has NOT rebooted: the boot id is still ${bootIdNow}, the one recorded when the reboot was requested.`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const heartbeat = await deps.heartbeatLive();
|
|
288
|
+
const version = deps.cliVersion();
|
|
289
|
+
if (!heartbeat || version === null) {
|
|
290
|
+
const missing = [
|
|
291
|
+
heartbeat ? null : 'the event dispatcher is not reporting a live heartbeat',
|
|
292
|
+
version === null ? 'the installed celilo CLI did not answer `--version`' : null,
|
|
293
|
+
].filter((m): m is string => m !== null);
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
error: `The host rebooted but did not come back fully: ${missing.join('; ')}. The reboot stays unverified.`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
(deps.clearMarker ?? (() => clearRebootMarker()))();
|
|
301
|
+
return {
|
|
302
|
+
success: true,
|
|
303
|
+
message: `celilo-mgr rebooted and came back: dispatcher live, CLI ${version} answering.`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The newest kernel the host has on disk, from /boot/vmlinuz-<release>.
|
|
309
|
+
*
|
|
310
|
+
* Compared against `os.release()` — the kernel actually running — because
|
|
311
|
+
* that pair is the #1378 condition itself. Sorted numerically per dotted
|
|
312
|
+
* component so `-1064-` beats `-999-`, which a plain string sort gets wrong.
|
|
313
|
+
*/
|
|
314
|
+
export function newestInstalledKernel(bootEntries: string[], prefix = 'vmlinuz-'): string | null {
|
|
315
|
+
const releases = bootEntries
|
|
316
|
+
.filter((name) => name.startsWith(prefix))
|
|
317
|
+
.map((name) => name.slice(prefix.length))
|
|
318
|
+
.filter((release) => release.length > 0);
|
|
319
|
+
if (releases.length === 0) return null;
|
|
320
|
+
|
|
321
|
+
return releases.sort(compareKernelRelease).at(-1) ?? null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function compareKernelRelease(a: string, b: string): number {
|
|
325
|
+
const partsOf = (s: string) => s.split(/[.\-+]/);
|
|
326
|
+
const pa = partsOf(a);
|
|
327
|
+
const pb = partsOf(b);
|
|
328
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
329
|
+
const na = Number(pa[i]);
|
|
330
|
+
const nb = Number(pb[i]);
|
|
331
|
+
const bothNumeric = Number.isFinite(na) && Number.isFinite(nb);
|
|
332
|
+
const cmp = bothNumeric ? na - nb : (pa[i] ?? '').localeCompare(pb[i] ?? '');
|
|
333
|
+
if (cmp !== 0) return cmp;
|
|
334
|
+
}
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Everything the audit check needs, read from this host. */
|
|
339
|
+
export function collectRebootPendingDeps(
|
|
340
|
+
bootDir = '/boot',
|
|
341
|
+
markerPath: string = rebootMarkerPath(),
|
|
342
|
+
): { verdict: RebootPendingVerdict; marker: RebootMarker | null } {
|
|
343
|
+
let bootEntries: string[] = [];
|
|
344
|
+
try {
|
|
345
|
+
bootEntries = readdirSync(bootDir);
|
|
346
|
+
} catch {
|
|
347
|
+
// No /boot to read — the kernel half is simply unmeasured, which
|
|
348
|
+
// judgeRebootPending reports as such rather than as a clean host.
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
verdict: judgeRebootPending(
|
|
353
|
+
collectRebootPendingFacts(readFileOrNull, release(), newestInstalledKernel(bootEntries)),
|
|
354
|
+
),
|
|
355
|
+
marker: readRebootMarker(markerPath),
|
|
356
|
+
};
|
|
357
|
+
}
|