@hivemagnus/magnus-agent 1.1.2 → 1.1.3
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/.env.example +3 -0
- package/README.md +47 -2
- package/deploy/magnus-agent-unprivileged.service +5 -0
- package/deploy/magnus-agent.service +5 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +18 -3
- package/dist/env-catalog.js +2 -1
- package/dist/executors/historical-mutation.d.ts +5 -6
- package/dist/executors/historical-mutation.js +15 -2
- package/dist/executors/reviewed-historical-records.d.ts +22 -0
- package/dist/executors/reviewed-historical-records.js +117 -0
- package/dist/executors/scheduler-runner.js +12 -1
- package/dist/upgrade/install-service.js +15 -5
- package/dist/upgrade/routes.js +6 -1
- package/dist/upgrade/self-upgrade.d.ts +85 -10
- package/dist/upgrade/self-upgrade.js +459 -57
- package/launcher/launcher.mjs +383 -50
- package/package.json +5 -2
|
@@ -6,14 +6,34 @@
|
|
|
6
6
|
* `magnus-agent install-service`) starts the new version on trial and switches
|
|
7
7
|
* back if it does not report an accepted heartbeat in time. Nothing here uses
|
|
8
8
|
* sudo: the agent only writes inside its own install directory.
|
|
9
|
+
*
|
|
10
|
+
* The state file and the links are shared with the launcher: both write them
|
|
11
|
+
* durably (temp file, fsync, rename, fsync of the directory) and both take
|
|
12
|
+
* upgrade-state.lock (O_EXCL) around a read-modify-write.
|
|
9
13
|
*/
|
|
10
14
|
import { createHash } from 'node:crypto';
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
12
|
-
import { accessSync, constants, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { join, resolve } from 'node:path';
|
|
14
|
-
import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, } from '@hivemagnus/magnus-contracts';
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
+
import { accessSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lchownSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
17
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, satisfiesRange, } from '@hivemagnus/magnus-contracts';
|
|
19
|
+
import { childEnvironment } from '../utils/child-env.js';
|
|
20
|
+
import { logger } from '../utils/logger.js';
|
|
15
21
|
export const STATE_FILE = 'upgrade-state.json';
|
|
22
|
+
export const LOCK_FILE = 'upgrade-state.lock';
|
|
23
|
+
/** Written while on trial when heartbeats cannot reach the control API; the launcher then extends the trial. */
|
|
24
|
+
export const ALIVE_FILE = 'trial-alive.json';
|
|
16
25
|
export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
|
|
26
|
+
/** The launcher protocol this agent needs. Kept equal to `magnusAgent.minLauncher` in package.json. */
|
|
27
|
+
export const MIN_LAUNCHER_PROTOCOL = 1;
|
|
28
|
+
/** Oldest npm the upgrade steps are tested with (overrides, `audit signatures`). */
|
|
29
|
+
export const MIN_NPM_VERSION = '10.0.0';
|
|
30
|
+
/** Per-step limits and the total budget; the server waits 15 minutes for an upgrade. */
|
|
31
|
+
export const STEP_TIMEOUT_MS = { pack: 3 * 60_000, install: 8 * 60_000, audit: 3 * 60_000, smoke: 60_000 };
|
|
32
|
+
export const STAGE_BUDGET_MS = 13 * 60_000;
|
|
33
|
+
/** After staging, the agent exits even if a graceful shutdown hangs. */
|
|
34
|
+
export const EXIT_BACKSTOP_MS = 60_000;
|
|
35
|
+
const NPM_NETWORK = ['--fetch-timeout=60000', '--fetch-retries=2'];
|
|
36
|
+
const ENTRY = ['node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js'];
|
|
17
37
|
export function installDir(env = process.env) {
|
|
18
38
|
return resolve(env['AGENT_INSTALL_DIR'] ?? process.cwd());
|
|
19
39
|
}
|
|
@@ -46,19 +66,138 @@ export function adoptInstallOwner(dir, path, uid = process.getuid?.()) {
|
|
|
46
66
|
if (lstatSync(path, { throwIfNoEntry: false }))
|
|
47
67
|
walk(path);
|
|
48
68
|
}
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
function fsyncDir(dir) {
|
|
70
|
+
try {
|
|
71
|
+
const fd = openSync(dir, 'r');
|
|
72
|
+
try {
|
|
73
|
+
fsyncSync(fd);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// some filesystems cannot fsync a directory
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Temp file, fsync, rename, fsync the directory: a crash leaves the old or the new content, never a torn file. */
|
|
84
|
+
export function writeDurable(dir, name, text, mode = 0o600) {
|
|
85
|
+
const path = join(dir, name);
|
|
51
86
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
try {
|
|
88
|
+
const fd = openSync(temporary, 'w', mode);
|
|
89
|
+
try {
|
|
90
|
+
writeSync(fd, text);
|
|
91
|
+
fsyncSync(fd);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
adoptInstallOwner(dir, temporary);
|
|
97
|
+
renameSync(temporary, path);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
rmSync(temporary, { force: true });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
fsyncDir(dir);
|
|
104
|
+
}
|
|
105
|
+
export function writeUpgradeState(dir, state) {
|
|
106
|
+
writeDurable(dir, STATE_FILE, `${JSON.stringify(state, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
/** Atomically point `<dir>/<name>` (current, last-good) at versions/<version>. */
|
|
109
|
+
export function pointLink(dir, name, version) {
|
|
110
|
+
const temporary = join(dir, `${name}.${process.pid}.tmp`);
|
|
111
|
+
try {
|
|
112
|
+
rmSync(temporary, { force: true });
|
|
113
|
+
symlinkSync(join('versions', version), temporary, 'dir');
|
|
114
|
+
adoptInstallOwner(dir, temporary);
|
|
115
|
+
renameSync(temporary, join(dir, name));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
rmSync(temporary, { force: true });
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
fsyncDir(dir);
|
|
55
122
|
}
|
|
56
123
|
export function pointCurrentAt(dir, version) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
124
|
+
pointLink(dir, 'current', version);
|
|
125
|
+
}
|
|
126
|
+
/** The X of a `<name> -> versions/X` link, or null. */
|
|
127
|
+
export function linkVersion(dir, name) {
|
|
128
|
+
try {
|
|
129
|
+
return /^versions[\\/]([^\\/]+)$/.exec(readlinkSync(join(dir, name)))?.[1] ?? null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** versions/<version> holds a runnable agent. */
|
|
136
|
+
export function versionComplete(dir, version) {
|
|
137
|
+
return !!version && existsSync(join(dir, 'versions', version, ...ENTRY));
|
|
138
|
+
}
|
|
139
|
+
/** ./current resolves to versions/<version>. */
|
|
140
|
+
export function runsVersion(dir, version) {
|
|
141
|
+
try {
|
|
142
|
+
return realpathSync(join(dir, 'current')) === realpathSync(join(dir, 'versions', version));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
149
|
+
function lockIsStale(path, staleMs) {
|
|
150
|
+
try {
|
|
151
|
+
const pid = Number.parseInt(readFileSync(path, 'utf8'), 10);
|
|
152
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error.code === 'ESRCH')
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return Date.now() - statSync(path).mtimeMs > staleMs;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** O_EXCL lock shared with the launcher around a read-modify-write of the state file. Synchronous; waits up to timeoutMs. */
|
|
168
|
+
export function withStateLock(dir, fn, { timeoutMs = 3_000, staleMs = 15_000 } = {}) {
|
|
169
|
+
const path = join(dir, LOCK_FILE);
|
|
170
|
+
const until = Date.now() + timeoutMs;
|
|
171
|
+
let fd;
|
|
172
|
+
for (;;) {
|
|
173
|
+
try {
|
|
174
|
+
fd = openSync(path, 'wx', 0o600);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'EEXIST')
|
|
179
|
+
throw error;
|
|
180
|
+
if (lockIsStale(path, staleMs)) {
|
|
181
|
+
rmSync(path, { force: true });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() >= until)
|
|
185
|
+
throw Object.assign(new Error(`${LOCK_FILE} is held by another process`), { code: 'ELOCKED' });
|
|
186
|
+
Atomics.wait(sleeper, 0, 0, 25);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
writeSync(fd, String(process.pid));
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
closeSync(fd);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
return fn();
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
rmSync(path, { force: true });
|
|
200
|
+
}
|
|
62
201
|
}
|
|
63
202
|
export function detectLayout(dir, env = process.env) {
|
|
64
203
|
if (env['MAGNUS_AGENT_CONTAINER'] === '1')
|
|
@@ -75,6 +214,63 @@ export function detectLayout(dir, env = process.env) {
|
|
|
75
214
|
function selfUpgradeSwitch(env) {
|
|
76
215
|
return (env['MAGNUS_AGENT_SELF_UPGRADE'] ?? 'true').trim().toLowerCase() !== 'false';
|
|
77
216
|
}
|
|
217
|
+
function readJson(path) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The npm that belongs to the running Node.js (npm-cli.js beside it, run with
|
|
227
|
+
* process.execPath), then MAGNUS_AGENT_NPM, then npm on PATH. A systemd PATH
|
|
228
|
+
* often lacks the directory of a version-managed Node.js.
|
|
229
|
+
*/
|
|
230
|
+
export function resolveNpm(env = process.env, execPath = process.execPath) {
|
|
231
|
+
const bin = dirname(execPath);
|
|
232
|
+
const configured = env['MAGNUS_AGENT_NPM']?.trim();
|
|
233
|
+
if (configured)
|
|
234
|
+
return { command: configured, args: [], pathPrefix: bin, version: null, problem: null };
|
|
235
|
+
for (const cli of [join(bin, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(bin, 'node_modules', 'npm', 'bin', 'npm-cli.js')]) {
|
|
236
|
+
if (!existsSync(cli))
|
|
237
|
+
continue;
|
|
238
|
+
const version = readJson(join(dirname(cli), '..', 'package.json'))?.version ?? null;
|
|
239
|
+
let problem = null;
|
|
240
|
+
try {
|
|
241
|
+
if (version && compareVersions(version, MIN_NPM_VERSION) < 0)
|
|
242
|
+
problem = `npm ${version} beside ${execPath} is older than ${MIN_NPM_VERSION}`;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// unparseable version: let npm itself decide
|
|
246
|
+
}
|
|
247
|
+
return { command: execPath, args: [cli], pathPrefix: bin, version, problem };
|
|
248
|
+
}
|
|
249
|
+
const folder = [bin, ...(env['PATH'] ?? '').split(delimiter)].find(entry => entry && existsSync(join(entry, 'npm')));
|
|
250
|
+
if (folder)
|
|
251
|
+
return { command: join(folder, 'npm'), args: [], pathPrefix: bin, version: null, problem: null };
|
|
252
|
+
return { command: 'npm', args: [], pathPrefix: bin, version: null, problem: `npm was not found beside ${execPath} or on PATH` };
|
|
253
|
+
}
|
|
254
|
+
function confirmWindowSeconds(env, layout) {
|
|
255
|
+
const fromLauncher = Number.parseInt(env['MAGNUS_AGENT_LAUNCHER_CONFIRM_SECONDS'] ?? '', 10);
|
|
256
|
+
if (Number.isInteger(fromLauncher) && fromLauncher > 0)
|
|
257
|
+
return fromLauncher;
|
|
258
|
+
if (layout !== 'versioned')
|
|
259
|
+
return null;
|
|
260
|
+
// Launcher protocol 1 does not pass the window: compute it the same way.
|
|
261
|
+
const value = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS'] ?? '', 10);
|
|
262
|
+
return Number.isInteger(value) && value >= 10 ? Math.min(value, 3600) : 180;
|
|
263
|
+
}
|
|
264
|
+
function writable(path) {
|
|
265
|
+
try {
|
|
266
|
+
accessSync(path, constants.W_OK);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const clip = (text, max) => (text == null ? null : text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
78
274
|
/** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
|
|
79
275
|
export function selfUpgradeStatus(version, env = process.env) {
|
|
80
276
|
const dir = installDir(env);
|
|
@@ -89,48 +285,144 @@ export function selfUpgradeStatus(version, env = process.env) {
|
|
|
89
285
|
else if (layout === 'legacy')
|
|
90
286
|
reason = 'not running under the upgrade launcher; run `sudo magnus-agent install-service` once on this node';
|
|
91
287
|
else {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
288
|
+
// Paths left behind by root (an earlier privileged run) block an unprivileged agent.
|
|
289
|
+
const blocked = [dir, join(dir, 'versions'), ...[join(dir, 'staging'), join(dir, STATE_FILE)].filter(path => existsSync(path))]
|
|
290
|
+
.find(path => !writable(path));
|
|
291
|
+
if (blocked)
|
|
292
|
+
reason = `the agent cannot write ${blocked}; run \`sudo magnus-agent install-service\` again`;
|
|
293
|
+
else
|
|
294
|
+
reason = resolveNpm(env).problem;
|
|
99
295
|
}
|
|
100
296
|
return {
|
|
101
297
|
protocol: AGENT_SELF_UPGRADE_PROTOCOL,
|
|
102
298
|
launcherProtocol: Number.isInteger(launcherProtocol) ? launcherProtocol : null,
|
|
103
299
|
layout,
|
|
104
300
|
enabled: reason === null,
|
|
105
|
-
reason,
|
|
301
|
+
reason: clip(reason, 300),
|
|
302
|
+
confirmSeconds: confirmWindowSeconds(env, layout),
|
|
106
303
|
upgrade: state ? reportOf(state) : { state: 'idle', from: null, to: version },
|
|
107
304
|
};
|
|
108
305
|
}
|
|
109
306
|
function reportOf(state) {
|
|
110
307
|
return {
|
|
111
|
-
state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: state.reason
|
|
308
|
+
state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: clip(state.reason, 500),
|
|
112
309
|
...(state.attempt !== undefined ? { attempt: state.attempt } : {}), ...(state.updatedAt ? { updatedAt: state.updatedAt } : {}),
|
|
113
310
|
};
|
|
114
311
|
}
|
|
312
|
+
/** The upgrade state when `version` is the one on trial. */
|
|
313
|
+
export function trialOf(version, env = process.env) {
|
|
314
|
+
const state = readUpgradeState(installDir(env));
|
|
315
|
+
return state?.state === 'trial' && state.to === version ? state : null;
|
|
316
|
+
}
|
|
317
|
+
/** Tell the launcher this version runs but cannot reach the control API, so it extends the trial instead of reverting. */
|
|
318
|
+
export function recordTrialAlive(version, error, env = process.env, now = Date.now()) {
|
|
319
|
+
const state = trialOf(version, env);
|
|
320
|
+
if (!state)
|
|
321
|
+
return false;
|
|
322
|
+
try {
|
|
323
|
+
writeDurable(installDir(env), ALIVE_FILE, `${JSON.stringify({ version, requestId: state.requestId, at: now, error: error.slice(0, 200) })}\n`);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** A failed heartbeat POST that says nothing about this version: no answer, or a gateway with no upstream. */
|
|
331
|
+
export function controlApiUnreachable(error) {
|
|
332
|
+
if (!(error instanceof Error))
|
|
333
|
+
return true;
|
|
334
|
+
if (error.name === 'HTTPError') {
|
|
335
|
+
const status = error.response?.status ?? 0;
|
|
336
|
+
return status === 502 || status === 503 || status === 504;
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
/** `export const LAUNCHER_PROTOCOL = N` in a launcher file, without running it. */
|
|
341
|
+
export function declaredLauncherProtocol(path) {
|
|
342
|
+
try {
|
|
343
|
+
const match = /^export const LAUNCHER_PROTOCOL = (\d+);$/m.exec(readFileSync(path, 'utf8'));
|
|
344
|
+
return match ? Number(match[1]) : null;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Replace launcher/launcher.mjs with the confirmed version's copy when that
|
|
352
|
+
* declares a higher protocol and passes a self-check (`--protocol`). The
|
|
353
|
+
* running launcher keeps its loaded code; the new one is used from the next
|
|
354
|
+
* service start. The previous file is kept as launcher.mjs.previous.
|
|
355
|
+
*/
|
|
356
|
+
export function upgradeLauncher(dir, version, execPath = process.execPath) {
|
|
357
|
+
const candidate = join(dir, 'versions', version, 'node_modules', ...AGENT_PACKAGE.split('/'), 'launcher', 'launcher.mjs');
|
|
358
|
+
const folder = join(dir, 'launcher');
|
|
359
|
+
const installed = join(folder, 'launcher.mjs');
|
|
360
|
+
const offered = declaredLauncherProtocol(candidate);
|
|
361
|
+
const have = declaredLauncherProtocol(installed);
|
|
362
|
+
if (offered === null || have === null || offered <= have)
|
|
363
|
+
return null;
|
|
364
|
+
const probe = spawnSync(execPath, [candidate, '--protocol'], { encoding: 'utf8', timeout: 10_000, env: childEnvironment() });
|
|
365
|
+
if (probe.status !== 0 || probe.stdout.trim() !== String(offered))
|
|
366
|
+
throw new Error(`the launcher from ${version} failed its self-check`);
|
|
367
|
+
copyFileSync(installed, join(folder, 'launcher.mjs.previous'));
|
|
368
|
+
writeDurable(folder, 'launcher.mjs', readFileSync(candidate, 'utf8'), 0o644);
|
|
369
|
+
return `launcher protocol ${have} -> ${offered}`;
|
|
370
|
+
}
|
|
115
371
|
/**
|
|
116
|
-
* Called after every accepted heartbeat: the first one from a version on
|
|
117
|
-
* confirms it
|
|
372
|
+
* Called after every accepted heartbeat: the first one from a version on
|
|
373
|
+
* trial confirms it (unless the trial window has closed or ./current no
|
|
374
|
+
* longer runs it), records it as last-good, and prunes other versions.
|
|
118
375
|
*/
|
|
119
376
|
export function confirmTrial(version, env = process.env, now = new Date()) {
|
|
120
377
|
const dir = installDir(env);
|
|
121
|
-
|
|
122
|
-
|
|
378
|
+
let trial = null;
|
|
379
|
+
try {
|
|
380
|
+
withStateLock(dir, () => {
|
|
381
|
+
const state = readUpgradeState(dir);
|
|
382
|
+
if (!state || state.state !== 'trial' || state.to !== version)
|
|
383
|
+
return;
|
|
384
|
+
const deadline = Date.parse(state.trialDeadline ?? '');
|
|
385
|
+
// The launcher is reverting (or about to): a late confirmation must not race it.
|
|
386
|
+
if (Number.isFinite(deadline) && now.getTime() >= deadline)
|
|
387
|
+
return;
|
|
388
|
+
if (!runsVersion(dir, version))
|
|
389
|
+
return;
|
|
390
|
+
writeUpgradeState(dir, { ...state, state: 'confirmed', reason: null, updatedAt: now.toISOString() });
|
|
391
|
+
trial = state;
|
|
392
|
+
}, { timeoutMs: 1_000 }); // blocks the event loop while it waits; the launcher holds the lock for milliseconds
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
logger.warn('Self-upgrade confirmation deferred', { error: error instanceof Error ? error.message : String(error) });
|
|
123
396
|
return false;
|
|
124
|
-
|
|
125
|
-
|
|
397
|
+
}
|
|
398
|
+
const confirmed = trial;
|
|
399
|
+
if (!confirmed)
|
|
400
|
+
return false;
|
|
401
|
+
const bestEffort = (step, action) => {
|
|
402
|
+
try {
|
|
403
|
+
action();
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
logger.warn(`Self-upgrade: ${step} failed`, { error: error instanceof Error ? error.message : String(error) });
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
bestEffort('recording last-good', () => pointLink(dir, 'last-good', version));
|
|
410
|
+
bestEffort('removing the trial marker', () => rmSync(join(dir, ALIVE_FILE), { force: true }));
|
|
411
|
+
bestEffort('pruning old versions', () => pruneVersions(dir, [confirmed.from, confirmed.to]));
|
|
412
|
+
bestEffort('upgrading the launcher', () => {
|
|
413
|
+
const change = upgradeLauncher(dir, version);
|
|
414
|
+
if (change)
|
|
415
|
+
logger.info(`Self-upgrade: installed the launcher from ${version} (${change}); it takes over at the next service start`);
|
|
416
|
+
});
|
|
126
417
|
return true;
|
|
127
418
|
}
|
|
128
419
|
export function pruneVersions(dir, keep) {
|
|
129
420
|
const versions = join(dir, 'versions');
|
|
130
421
|
if (!existsSync(versions))
|
|
131
422
|
return;
|
|
423
|
+
const kept = [...keep, linkVersion(dir, 'current'), linkVersion(dir, 'last-good')];
|
|
132
424
|
for (const name of readdirSync(versions)) {
|
|
133
|
-
if (
|
|
425
|
+
if (kept.includes(name))
|
|
134
426
|
continue;
|
|
135
427
|
rmSync(join(versions, name), { recursive: true, force: true });
|
|
136
428
|
}
|
|
@@ -144,14 +436,45 @@ export class SelfUpgradeError extends Error {
|
|
|
144
436
|
this.status = status;
|
|
145
437
|
}
|
|
146
438
|
}
|
|
439
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
440
|
+
const KILL_GRACE_MS = 5_000;
|
|
441
|
+
/** Runs a command in its own process group; at timeoutMs the whole group gets SIGTERM, then SIGKILL. */
|
|
147
442
|
export const runProcess = (command, args, options) => new Promise((resolvePromise, reject) => {
|
|
148
|
-
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
443
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
149
444
|
let stdout = '';
|
|
150
445
|
let stderr = '';
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
446
|
+
let timedOut = false;
|
|
447
|
+
const timers = [];
|
|
448
|
+
const signalGroup = (signal) => {
|
|
449
|
+
try {
|
|
450
|
+
if (child.pid)
|
|
451
|
+
process.kill(-child.pid, signal);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
try {
|
|
455
|
+
child.kill(signal);
|
|
456
|
+
}
|
|
457
|
+
catch { /* gone */ }
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
if (options.timeoutMs !== undefined) {
|
|
461
|
+
timers.push(setTimeout(() => {
|
|
462
|
+
timedOut = true;
|
|
463
|
+
signalGroup('SIGTERM');
|
|
464
|
+
timers.push(setTimeout(() => {
|
|
465
|
+
signalGroup('SIGKILL');
|
|
466
|
+
// A descendant outside the group may still hold the pipes: release them so `close` fires.
|
|
467
|
+
timers.push(setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, KILL_GRACE_MS));
|
|
468
|
+
}, KILL_GRACE_MS));
|
|
469
|
+
}, options.timeoutMs));
|
|
470
|
+
}
|
|
471
|
+
const settle = () => { for (const timer of timers)
|
|
472
|
+
clearTimeout(timer); };
|
|
473
|
+
const keep = (buffer, chunk) => (buffer + String(chunk)).slice(-OUTPUT_LIMIT);
|
|
474
|
+
child.stdout.on('data', chunk => { stdout = keep(stdout, chunk); });
|
|
475
|
+
child.stderr.on('data', chunk => { stderr = keep(stderr, chunk); });
|
|
476
|
+
child.on('error', error => { settle(); reject(error); });
|
|
477
|
+
child.on('close', code => { settle(); resolvePromise({ code: code ?? 1, stdout, stderr, timedOut }); });
|
|
155
478
|
});
|
|
156
479
|
/** npm pack names files `<scope>-<name>-<version>.tgz`. */
|
|
157
480
|
export function tarballName(version) {
|
|
@@ -174,12 +497,31 @@ export function sourceOverrides(sourceDir, agentTarball) {
|
|
|
174
497
|
}
|
|
175
498
|
return overrides;
|
|
176
499
|
}
|
|
177
|
-
|
|
500
|
+
const SIMPLE_RANGE = /^(?:\*|\^?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
|
|
501
|
+
/** Why `engines.node` rules out `nodeVersion`, or null. Range forms satisfiesRange cannot judge are not refused. */
|
|
502
|
+
export function engineProblem(range, nodeVersion) {
|
|
503
|
+
if (typeof range !== 'string' || !range.trim())
|
|
504
|
+
return null;
|
|
505
|
+
if (!range.split('||').every(part => SIMPLE_RANGE.test(part.trim())))
|
|
506
|
+
return null;
|
|
507
|
+
return satisfiesRange(nodeVersion, range) ? null : `requires Node.js ${range.trim()}; this node runs ${nodeVersion}`;
|
|
508
|
+
}
|
|
509
|
+
let staging = null;
|
|
178
510
|
/** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
|
|
179
511
|
export async function stageUpgrade(options) {
|
|
180
512
|
const env = options.env ?? process.env;
|
|
181
513
|
const run = options.run ?? runProcess;
|
|
514
|
+
const clock = options.clock ?? Date.now;
|
|
515
|
+
const started = clock();
|
|
182
516
|
const { request, version: from } = options;
|
|
517
|
+
const dir = installDir(env);
|
|
518
|
+
// A retried request (same requestId and target) reports where it got to instead of failing.
|
|
519
|
+
const recorded = readUpgradeState(dir);
|
|
520
|
+
if ((recorded?.requestId === request.requestId && recorded.to === request.version)
|
|
521
|
+
|| (staging?.requestId === request.requestId && staging.version === request.version)) {
|
|
522
|
+
return { from: recorded?.from ?? from, to: request.version, integrity: request.integrity, requestId: request.requestId, repeated: true,
|
|
523
|
+
upgrade: recorded?.requestId === request.requestId ? reportOf(recorded) : { state: 'idle', from, to: request.version, requestId: request.requestId, reason: 'being staged' } };
|
|
524
|
+
}
|
|
183
525
|
const status = selfUpgradeStatus(from, env);
|
|
184
526
|
if (!status.enabled)
|
|
185
527
|
throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', status.reason ?? 'self-upgrade is unavailable');
|
|
@@ -195,17 +537,37 @@ export async function stageUpgrade(options) {
|
|
|
195
537
|
throw new SelfUpgradeError('SELF_UPGRADE_BUSY', `an upgrade to ${status.upgrade?.to} is still on trial`);
|
|
196
538
|
if (staging)
|
|
197
539
|
throw new SelfUpgradeError('SELF_UPGRADE_BUSY', 'an upgrade is already being staged');
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
540
|
+
// The launcher can only switch back to a complete versions/<from> that ./current runs now.
|
|
541
|
+
if (!versionComplete(dir, from) || !runsVersion(dir, from)) {
|
|
542
|
+
throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', `./current does not run a complete versions/${from}, so a failed upgrade could not be reverted; run \`sudo magnus-agent install-service\` again`);
|
|
543
|
+
}
|
|
544
|
+
const npm = resolveNpm(env);
|
|
545
|
+
if (npm.problem)
|
|
546
|
+
throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', npm.problem);
|
|
547
|
+
staging = { requestId: request.requestId, version: request.version };
|
|
548
|
+
const stagingRoot = join(dir, 'staging');
|
|
549
|
+
const work = join(stagingRoot, request.version);
|
|
201
550
|
const target = join(dir, 'versions', request.version);
|
|
202
551
|
try {
|
|
203
|
-
|
|
552
|
+
// Leftovers of interrupted upgrades, whatever version they were for.
|
|
553
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
204
554
|
mkdirSync(work, { recursive: true, mode: 0o700 });
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
555
|
+
const path = [npm.pathPrefix, env['PATH']].filter(Boolean).join(delimiter);
|
|
556
|
+
// No agent credentials for npm, lifecycle scripts or the smoke test; a per-upgrade cache that goes with the staging directory.
|
|
557
|
+
const npmEnv = childEnvironment({ PATH: path, npm_config_cache: join(work, '.npm'), npm_config_update_notifier: 'false' }, env);
|
|
558
|
+
const step = async (name, limitMs, command, args, cwd, stepEnv) => {
|
|
559
|
+
const remaining = STAGE_BUDGET_MS - (clock() - started);
|
|
560
|
+
if (remaining <= 0)
|
|
561
|
+
throw new SelfUpgradeError('SELF_UPGRADE_TIMEOUT', `the upgrade used its ${STAGE_BUDGET_MS / 60_000} minute budget before ${name}`, 504);
|
|
562
|
+
const timeoutMs = Math.min(limitMs, remaining);
|
|
563
|
+
const result = await run(command, args, { cwd, env: stepEnv, timeoutMs });
|
|
564
|
+
if (result.timedOut)
|
|
565
|
+
throw new SelfUpgradeError('SELF_UPGRADE_TIMEOUT', `${name} did not finish within ${Math.round(timeoutMs / 1000)} s`, 504);
|
|
566
|
+
return result;
|
|
567
|
+
};
|
|
568
|
+
const npmStep = (name, limitMs, args, cwd) => step(name, limitMs, npm.command, [...npm.args, ...args, ...NPM_NETWORK, '--loglevel=error'], cwd, npmEnv);
|
|
569
|
+
const fail = (name, result) => {
|
|
570
|
+
throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `${name} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ')}`, 500);
|
|
209
571
|
};
|
|
210
572
|
// 1. The exact tarball: from an operator-provided directory, or the registry.
|
|
211
573
|
const sourceDir = env['MAGNUS_AGENT_UPGRADE_SOURCE_DIR']?.trim();
|
|
@@ -216,7 +578,7 @@ export async function stageUpgrade(options) {
|
|
|
216
578
|
throw new SelfUpgradeError('SELF_UPGRADE_SOURCE', `${tarball} does not exist`, 422);
|
|
217
579
|
}
|
|
218
580
|
else {
|
|
219
|
-
const packed = await
|
|
581
|
+
const packed = await npmStep('npm pack', STEP_TIMEOUT_MS.pack, ['pack', `${AGENT_PACKAGE}@${request.version}`, '--pack-destination', work, '--json'], work);
|
|
220
582
|
if (packed.code !== 0)
|
|
221
583
|
fail('npm pack', packed);
|
|
222
584
|
tarball = join(work, tarballName(request.version));
|
|
@@ -234,18 +596,27 @@ export async function stageUpgrade(options) {
|
|
|
234
596
|
name: `magnus-agent-${request.version}`, version: request.version, private: true, type: 'module',
|
|
235
597
|
dependencies: { [AGENT_PACKAGE]: `file:${tarball}` }, ...(Object.keys(overrides).length ? { overrides } : {}), allowScripts: ALLOW_SCRIPTS,
|
|
236
598
|
}, null, 2)}\n`);
|
|
237
|
-
const installed = await
|
|
599
|
+
const installed = await npmStep('npm install', STEP_TIMEOUT_MS.install, ['install', '--omit=dev', '--no-audit', '--no-fund'], install);
|
|
238
600
|
if (installed.code !== 0)
|
|
239
601
|
fail('npm install', installed);
|
|
240
|
-
// 4.
|
|
602
|
+
// 4. This node must be able to run it: Node.js engines and the launcher protocol it needs.
|
|
603
|
+
const manifest = readJson(join(install, 'node_modules', ...AGENT_PACKAGE.split('/'), 'package.json')) ?? {};
|
|
604
|
+
const engine = engineProblem(manifest.engines?.node, options.nodeVersion ?? process.versions.node);
|
|
605
|
+
if (engine)
|
|
606
|
+
throw new SelfUpgradeError('SELF_UPGRADE_ENGINE', `${request.version} ${engine}`, 422);
|
|
607
|
+
const needs = Number.isInteger(manifest.magnusAgent?.minLauncher) ? manifest.magnusAgent.minLauncher : 1;
|
|
608
|
+
if (needs > (status.launcherProtocol ?? 0)) {
|
|
609
|
+
throw new SelfUpgradeError('SELF_UPGRADE_LAUNCHER', `${request.version} needs launcher protocol ${needs}; this node runs ${status.launcherProtocol ?? 'none'}. Run \`sudo magnus-agent install-service\` once to update it`, 422);
|
|
610
|
+
}
|
|
611
|
+
// 5. Registry signatures and provenance of everything installed.
|
|
241
612
|
if ((env['MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE'] ?? 'true').trim().toLowerCase() !== 'false') {
|
|
242
|
-
const audit = await
|
|
613
|
+
const audit = await npmStep('npm audit signatures', STEP_TIMEOUT_MS.audit, ['audit', 'signatures', '--omit=dev'], install);
|
|
243
614
|
if (audit.code !== 0)
|
|
244
615
|
fail('npm audit signatures', audit);
|
|
245
616
|
}
|
|
246
|
-
//
|
|
247
|
-
const entry = join(install,
|
|
248
|
-
const smoke = await
|
|
617
|
+
// 6. The new code must at least start and report the requested version.
|
|
618
|
+
const entry = join(install, ...ENTRY);
|
|
619
|
+
const smoke = await step('the installed agent', STEP_TIMEOUT_MS.smoke, process.execPath, [entry, '--version'], install, childEnvironment({ PATH: path }, env));
|
|
249
620
|
if (smoke.code !== 0 || smoke.stdout.trim() !== request.version) {
|
|
250
621
|
throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `the installed agent reports "${smoke.stdout.trim()}", not ${request.version}`, 500);
|
|
251
622
|
}
|
|
@@ -254,16 +625,47 @@ export async function stageUpgrade(options) {
|
|
|
254
625
|
adoptInstallOwner(dir, join(dir, 'versions'));
|
|
255
626
|
adoptInstallOwner(dir, install);
|
|
256
627
|
renameSync(install, target);
|
|
628
|
+
// 7. Record, then switch. A switch that fails records `failed`, so the launcher does not start a doomed trial.
|
|
257
629
|
const now = (options.now ?? new Date()).toISOString();
|
|
258
|
-
|
|
259
|
-
|
|
630
|
+
const staged = { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now };
|
|
631
|
+
try {
|
|
632
|
+
withStateLock(dir, () => {
|
|
633
|
+
writeUpgradeState(dir, staged);
|
|
634
|
+
try {
|
|
635
|
+
pointCurrentAt(dir, request.version);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
639
|
+
try {
|
|
640
|
+
writeUpgradeState(dir, { ...staged, state: 'failed', reason: `switching ./current failed: ${message}` });
|
|
641
|
+
}
|
|
642
|
+
catch { /* the launcher detects it */ }
|
|
643
|
+
throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `switching ./current to ${request.version} failed: ${message}`, 500);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
try {
|
|
649
|
+
rmSync(target, { recursive: true, force: true });
|
|
650
|
+
}
|
|
651
|
+
catch { /* pruned later */ }
|
|
652
|
+
if (error instanceof SelfUpgradeError)
|
|
653
|
+
throw error;
|
|
654
|
+
const code = error.code;
|
|
655
|
+
throw new SelfUpgradeError(code === 'ELOCKED' ? 'SELF_UPGRADE_BUSY' : 'SELF_UPGRADE_FAILED', `recording the upgrade failed: ${error instanceof Error ? error.message : String(error)}`, code === 'ELOCKED' ? 409 : 500);
|
|
656
|
+
}
|
|
260
657
|
return { from, to: request.version, integrity, requestId: request.requestId };
|
|
261
658
|
}
|
|
262
659
|
finally {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
660
|
+
try {
|
|
661
|
+
rmSync(work, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
catch { /* cleared at the next upgrade */ }
|
|
664
|
+
try {
|
|
665
|
+
adoptInstallOwner(dir, stagingRoot);
|
|
666
|
+
}
|
|
667
|
+
catch { /* reported by selfUpgradeStatus */ }
|
|
668
|
+
staging = null;
|
|
267
669
|
}
|
|
268
670
|
}
|
|
269
671
|
//# sourceMappingURL=self-upgrade.js.map
|