@forgezero/agent 0.1.31 → 0.1.32
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/dist/agent-heartbeat.d.ts +10 -2
- package/dist/agent-heartbeat.js +396 -54
- package/dist/agent-update-helper.d.ts +41 -2
- package/dist/agent-update-helper.js +360 -43
- package/dist/agent-update.d.ts +1 -0
- package/dist/agent-update.js +34 -2
- package/dist/fz-agent.js +418 -73
- package/dist/fz.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/provision.js +363 -49
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -2,10 +2,14 @@ import { type Server } from 'node:net';
|
|
|
2
2
|
import { type AgentRelease, type StagedAgentRelease, type UpdateCommand, type UpdateCommandResult } from './agent-update';
|
|
3
3
|
export declare const AGENT_UPDATE_GROUP = "forgezero-update";
|
|
4
4
|
export declare const AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
5
|
-
|
|
5
|
+
/** Root-private crash transaction. Existing 0.1.31 hosts may contain its legacy success receipt here. */
|
|
6
|
+
export declare const AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
|
|
7
|
+
/** Bounded, group-readable evidence consumed by the unprivileged Agent heartbeat. */
|
|
8
|
+
export declare const AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
|
|
6
9
|
export type AgentUpdateRequest = {
|
|
7
10
|
op: 'apply';
|
|
8
11
|
target: 'compute' | 'metal';
|
|
12
|
+
attemptId?: string;
|
|
9
13
|
currentVersion: string;
|
|
10
14
|
release: AgentRelease;
|
|
11
15
|
};
|
|
@@ -13,6 +17,7 @@ export type AgentUpdateResponse = {
|
|
|
13
17
|
ok: true;
|
|
14
18
|
status: 'staged';
|
|
15
19
|
version: string;
|
|
20
|
+
attemptId: string;
|
|
16
21
|
} | {
|
|
17
22
|
ok: false;
|
|
18
23
|
error: {
|
|
@@ -20,6 +25,24 @@ export type AgentUpdateResponse = {
|
|
|
20
25
|
message: string;
|
|
21
26
|
};
|
|
22
27
|
};
|
|
28
|
+
export type AgentUpdateOutcome = 'activating' | 'active' | 'rolled-back' | 'failed';
|
|
29
|
+
/** Bounded, non-secret update evidence included in the next signed heartbeat. */
|
|
30
|
+
export interface AgentUpdateReceipt {
|
|
31
|
+
attemptId: string;
|
|
32
|
+
fromVersion: string;
|
|
33
|
+
targetVersion: string;
|
|
34
|
+
outcome: AgentUpdateOutcome;
|
|
35
|
+
startedAtTs: number;
|
|
36
|
+
updatedAtTs: number;
|
|
37
|
+
retryAfterTs?: number;
|
|
38
|
+
rollbackHealthy?: boolean;
|
|
39
|
+
reason?: {
|
|
40
|
+
code: string;
|
|
41
|
+
message: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Read only the bounded evidence safe to send to the control plane. */
|
|
45
|
+
export declare function readAgentUpdateReceipt(path?: string): AgentUpdateReceipt | undefined;
|
|
23
46
|
/** Prove a newly started Agent process answers, not merely that PID 1 holds its socket. */
|
|
24
47
|
export declare function probeAgentSocket(socketPath?: string, timeoutMs?: number): Promise<boolean>;
|
|
25
48
|
export declare function activateAgentRelease(staged: StagedAgentRelease, options?: {
|
|
@@ -27,6 +50,8 @@ export declare function activateAgentRelease(staged: StagedAgentRelease, options
|
|
|
27
50
|
run?: (input: UpdateCommand) => Promise<UpdateCommandResult>;
|
|
28
51
|
probe?: () => Promise<boolean>;
|
|
29
52
|
receiptPath?: string;
|
|
53
|
+
journalPath?: string;
|
|
54
|
+
attemptId?: string;
|
|
30
55
|
now?: () => number;
|
|
31
56
|
}): Promise<{
|
|
32
57
|
ok: true;
|
|
@@ -34,12 +59,26 @@ export declare function activateAgentRelease(staged: StagedAgentRelease, options
|
|
|
34
59
|
} | {
|
|
35
60
|
ok: false;
|
|
36
61
|
rolledBack: boolean;
|
|
62
|
+
rollbackHealthy: boolean;
|
|
37
63
|
reason: string;
|
|
38
64
|
}>;
|
|
65
|
+
/** Restore a transaction that lost power or the helper before its health verdict became durable. */
|
|
66
|
+
export declare function recoverInterruptedAgentUpdate(options?: {
|
|
67
|
+
root?: string;
|
|
68
|
+
receiptPath?: string;
|
|
69
|
+
journalPath?: string;
|
|
70
|
+
run?: (input: UpdateCommand) => Promise<UpdateCommandResult>;
|
|
71
|
+
probe?: () => Promise<boolean>;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}): Promise<AgentUpdateReceipt | undefined>;
|
|
39
74
|
export declare function startAgentUpdateHelper(options?: {
|
|
40
75
|
socketPath?: string;
|
|
41
76
|
root?: string;
|
|
42
|
-
activate?: (staged: StagedAgentRelease, target: AgentUpdateRequest['target']) => Promise<unknown>;
|
|
77
|
+
activate?: (staged: StagedAgentRelease, target: AgentUpdateRequest['target'], attemptId: string) => Promise<unknown>;
|
|
78
|
+
receiptPath?: string;
|
|
79
|
+
journalPath?: string;
|
|
80
|
+
recover?: () => Promise<unknown>;
|
|
81
|
+
now?: () => number;
|
|
43
82
|
setTimer?: (callback: () => void, ms: number) => unknown;
|
|
44
83
|
}): Server;
|
|
45
84
|
export declare function requestAgentUpdate(request: AgentUpdateRequest, socketPath?: string, timeoutMs?: number): Promise<AgentUpdateResponse>;
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
chmodSync,
|
|
5
|
+
closeSync,
|
|
5
6
|
existsSync,
|
|
7
|
+
fsyncSync,
|
|
6
8
|
mkdirSync,
|
|
9
|
+
openSync,
|
|
7
10
|
readFileSync,
|
|
8
11
|
readlinkSync,
|
|
9
12
|
renameSync,
|
|
@@ -17,6 +20,25 @@ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
|
17
20
|
var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
|
|
18
21
|
var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
19
22
|
var REGISTRY = "registry.npmjs.org";
|
|
23
|
+
var syncPath = (path) => {
|
|
24
|
+
const descriptor = openSync(path, "r");
|
|
25
|
+
try {
|
|
26
|
+
fsyncSync(descriptor);
|
|
27
|
+
} finally {
|
|
28
|
+
closeSync(descriptor);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var syncReleaseDirectory = (directory) => {
|
|
32
|
+
for (const path of [
|
|
33
|
+
join(directory, "package.json"),
|
|
34
|
+
join(directory, "dist", "fz-agent.js"),
|
|
35
|
+
join(directory, "dist", "fz.js"),
|
|
36
|
+
join(directory, "dist"),
|
|
37
|
+
directory,
|
|
38
|
+
dirname(directory)
|
|
39
|
+
])
|
|
40
|
+
syncPath(path);
|
|
41
|
+
};
|
|
20
42
|
function validateAgentRelease(release) {
|
|
21
43
|
if (release?.package !== "@forgezero/agent")
|
|
22
44
|
throw new Error("agent update package is fixed");
|
|
@@ -135,16 +157,24 @@ async function stageAgentRelease(releaseInput, options) {
|
|
|
135
157
|
]
|
|
136
158
|
}, "agent update extraction");
|
|
137
159
|
await validateReleaseDirectory(unpacked, release, run);
|
|
138
|
-
if (!existsSync(finalDirectory))
|
|
160
|
+
if (!existsSync(finalDirectory)) {
|
|
139
161
|
renameSync(unpacked, finalDirectory);
|
|
140
|
-
|
|
162
|
+
syncReleaseDirectory(finalDirectory);
|
|
163
|
+
} else
|
|
141
164
|
await validateReleaseDirectory(finalDirectory, release, run);
|
|
142
165
|
if (!existsSync(currentLink)) {
|
|
143
166
|
throw new Error("agent update requires an active immutable release to roll back to");
|
|
144
167
|
}
|
|
145
168
|
const previousTarget = readlinkSync(currentLink);
|
|
169
|
+
if (previousTarget !== join("versions", options.currentVersion)) {
|
|
170
|
+
throw new Error("agent update current release does not match the running version");
|
|
171
|
+
}
|
|
172
|
+
if (!existsSync(join(root, previousTarget))) {
|
|
173
|
+
throw new Error("agent update rollback release is missing");
|
|
174
|
+
}
|
|
146
175
|
return {
|
|
147
176
|
version: release.version,
|
|
177
|
+
fromVersion: options.currentVersion,
|
|
148
178
|
directory: finalDirectory,
|
|
149
179
|
previousTarget,
|
|
150
180
|
nextTarget: join("versions", release.version),
|
|
@@ -159,6 +189,7 @@ function selectAgentRelease(staged) {
|
|
|
159
189
|
try {
|
|
160
190
|
symlinkSync(staged.nextTarget, next);
|
|
161
191
|
renameSync(next, staged.currentLink);
|
|
192
|
+
syncPath(dirname(staged.currentLink));
|
|
162
193
|
} finally {
|
|
163
194
|
rmSync(next, { force: true });
|
|
164
195
|
}
|
|
@@ -168,25 +199,188 @@ function restoreAgentRelease(staged) {
|
|
|
168
199
|
try {
|
|
169
200
|
symlinkSync(staged.previousTarget, next);
|
|
170
201
|
renameSync(next, staged.currentLink);
|
|
202
|
+
syncPath(dirname(staged.currentLink));
|
|
171
203
|
} finally {
|
|
172
204
|
rmSync(next, { force: true });
|
|
173
205
|
}
|
|
174
206
|
}
|
|
175
207
|
|
|
176
208
|
// src/agent-update-helper.ts
|
|
177
|
-
import {
|
|
209
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
210
|
+
import {
|
|
211
|
+
chmodSync as chmodSync2,
|
|
212
|
+
closeSync as closeSync2,
|
|
213
|
+
existsSync as existsSync2,
|
|
214
|
+
fsyncSync as fsyncSync2,
|
|
215
|
+
mkdirSync as mkdirSync2,
|
|
216
|
+
openSync as openSync2,
|
|
217
|
+
readFileSync as readFileSync2,
|
|
218
|
+
renameSync as renameSync2,
|
|
219
|
+
rmSync as rmSync2,
|
|
220
|
+
unlinkSync,
|
|
221
|
+
writeFileSync as writeFileSync2
|
|
222
|
+
} from "node:fs";
|
|
178
223
|
import { connect, createServer } from "node:net";
|
|
179
|
-
import { dirname as dirname2 } from "node:path";
|
|
224
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
180
225
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
181
226
|
var AGENT_UPDATE_GROUP = "forgezero-update";
|
|
182
227
|
var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
183
|
-
var
|
|
228
|
+
var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
|
|
229
|
+
var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
|
|
184
230
|
var MAX_REQUEST_BYTES = 8 * 1024;
|
|
185
231
|
var COMPUTE_HELPER_UNITS = [
|
|
186
232
|
"forgezero-deploy-runner.service",
|
|
187
233
|
"forgezero-lifecycle-helper.service",
|
|
188
234
|
"forgezero-software-helper.service"
|
|
189
235
|
];
|
|
236
|
+
var VERSION2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
237
|
+
var ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
238
|
+
var REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
239
|
+
var MAX_REASON_BYTES = 512;
|
|
240
|
+
var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
241
|
+
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
242
|
+
var boundedMessage = (value) => {
|
|
243
|
+
let message = value.replace(/[\r\n]+/g, " ").trim();
|
|
244
|
+
while (Buffer.byteLength(message, "utf8") > MAX_REASON_BYTES)
|
|
245
|
+
message = message.slice(0, -1);
|
|
246
|
+
return message;
|
|
247
|
+
};
|
|
248
|
+
var reason = (code, message) => ({
|
|
249
|
+
code: REASON_CODE.test(code) ? code : "UPDATE_FAILED",
|
|
250
|
+
message: boundedMessage(message) || "Agent update failed"
|
|
251
|
+
});
|
|
252
|
+
var validTime = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
253
|
+
function validateReceipt(value) {
|
|
254
|
+
if (!value || typeof value !== "object")
|
|
255
|
+
throw new Error("Agent update receipt is malformed");
|
|
256
|
+
const receipt = value;
|
|
257
|
+
if (!receipt.attemptId || !ATTEMPT_ID.test(receipt.attemptId))
|
|
258
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
259
|
+
if (!receipt.fromVersion || !VERSION2.test(receipt.fromVersion))
|
|
260
|
+
throw new Error("Agent update source version is invalid");
|
|
261
|
+
if (!receipt.targetVersion || !VERSION2.test(receipt.targetVersion))
|
|
262
|
+
throw new Error("Agent update target version is invalid");
|
|
263
|
+
if (!["activating", "active", "rolled-back", "failed"].includes(receipt.outcome ?? "")) {
|
|
264
|
+
throw new Error("Agent update outcome is invalid");
|
|
265
|
+
}
|
|
266
|
+
if (!validTime(receipt.startedAtTs) || !validTime(receipt.updatedAtTs)) {
|
|
267
|
+
throw new Error("Agent update timestamps are invalid");
|
|
268
|
+
}
|
|
269
|
+
if (receipt.retryAfterTs !== undefined && !validTime(receipt.retryAfterTs)) {
|
|
270
|
+
throw new Error("Agent update retry timestamp is invalid");
|
|
271
|
+
}
|
|
272
|
+
if (receipt.rollbackHealthy !== undefined && typeof receipt.rollbackHealthy !== "boolean") {
|
|
273
|
+
throw new Error("Agent update rollback health is invalid");
|
|
274
|
+
}
|
|
275
|
+
if (receipt.reason && (!REASON_CODE.test(receipt.reason.code) || typeof receipt.reason.message !== "string" || Buffer.byteLength(receipt.reason.message, "utf8") > MAX_REASON_BYTES))
|
|
276
|
+
throw new Error("Agent update failure reason is invalid");
|
|
277
|
+
return {
|
|
278
|
+
attemptId: receipt.attemptId,
|
|
279
|
+
fromVersion: receipt.fromVersion,
|
|
280
|
+
targetVersion: receipt.targetVersion,
|
|
281
|
+
outcome: receipt.outcome,
|
|
282
|
+
startedAtTs: receipt.startedAtTs,
|
|
283
|
+
updatedAtTs: receipt.updatedAtTs,
|
|
284
|
+
...receipt.retryAfterTs === undefined ? {} : { retryAfterTs: receipt.retryAfterTs },
|
|
285
|
+
...receipt.rollbackHealthy === undefined ? {} : { rollbackHealthy: receipt.rollbackHealthy },
|
|
286
|
+
...receipt.reason === undefined ? {} : { reason: receipt.reason }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function validateJournal(value, root) {
|
|
290
|
+
if (!value || typeof value !== "object")
|
|
291
|
+
throw new Error("Agent update journal is malformed");
|
|
292
|
+
const legacy = value;
|
|
293
|
+
if (legacy.schemaVersion === undefined) {
|
|
294
|
+
if (typeof legacy.version === "string" && VERSION2.test(legacy.version) && legacy.outcome === "active" && validTime(legacy.updatedAtTs) && Object.keys(value).every((key) => ["version", "outcome", "updatedAtTs"].includes(key)))
|
|
295
|
+
return;
|
|
296
|
+
throw new Error("Agent update legacy receipt is malformed");
|
|
297
|
+
}
|
|
298
|
+
const journal = value;
|
|
299
|
+
const receipt = validateReceipt(journal);
|
|
300
|
+
if (journal.schemaVersion !== 1)
|
|
301
|
+
throw new Error("Agent update journal schema is unsupported");
|
|
302
|
+
if (journal.target !== "compute" && journal.target !== "metal")
|
|
303
|
+
throw new Error("Agent update target is invalid");
|
|
304
|
+
if (!Number.isSafeInteger(journal.failureCount) || (journal.failureCount ?? -1) < 0) {
|
|
305
|
+
throw new Error("Agent update failure count is invalid");
|
|
306
|
+
}
|
|
307
|
+
const releaseRoot = resolve2(root);
|
|
308
|
+
if (journal.currentLink !== join2(releaseRoot, "current"))
|
|
309
|
+
throw new Error("Agent update current link is invalid");
|
|
310
|
+
if (journal.previousTarget !== join2("versions", receipt.fromVersion)) {
|
|
311
|
+
throw new Error("Agent update rollback target is invalid");
|
|
312
|
+
}
|
|
313
|
+
if (journal.nextTarget !== join2("versions", receipt.targetVersion)) {
|
|
314
|
+
throw new Error("Agent update next target is invalid");
|
|
315
|
+
}
|
|
316
|
+
return journal;
|
|
317
|
+
}
|
|
318
|
+
function readJournal(path, root) {
|
|
319
|
+
if (!existsSync2(path))
|
|
320
|
+
return;
|
|
321
|
+
return validateJournal(JSON.parse(readFileSync2(path, "utf8")), root);
|
|
322
|
+
}
|
|
323
|
+
function writeAtomic(path, value, mode) {
|
|
324
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
|
|
325
|
+
const next = `${path}.${randomUUID2()}.next`;
|
|
326
|
+
let file;
|
|
327
|
+
try {
|
|
328
|
+
file = openSync2(next, "wx", mode);
|
|
329
|
+
writeFileSync2(file, `${JSON.stringify(value)}
|
|
330
|
+
`);
|
|
331
|
+
fsyncSync2(file);
|
|
332
|
+
closeSync2(file);
|
|
333
|
+
file = undefined;
|
|
334
|
+
renameSync2(next, path);
|
|
335
|
+
const directory = openSync2(dirname2(path), "r");
|
|
336
|
+
try {
|
|
337
|
+
fsyncSync2(directory);
|
|
338
|
+
} finally {
|
|
339
|
+
closeSync2(directory);
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
if (file !== undefined)
|
|
343
|
+
closeSync2(file);
|
|
344
|
+
rmSync2(next, { force: true });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
var publicReceipt = (journal) => {
|
|
348
|
+
const {
|
|
349
|
+
attemptId,
|
|
350
|
+
fromVersion,
|
|
351
|
+
targetVersion,
|
|
352
|
+
outcome,
|
|
353
|
+
startedAtTs,
|
|
354
|
+
updatedAtTs,
|
|
355
|
+
retryAfterTs,
|
|
356
|
+
rollbackHealthy,
|
|
357
|
+
reason: failureReason
|
|
358
|
+
} = journal;
|
|
359
|
+
return {
|
|
360
|
+
attemptId,
|
|
361
|
+
fromVersion,
|
|
362
|
+
targetVersion,
|
|
363
|
+
outcome,
|
|
364
|
+
startedAtTs,
|
|
365
|
+
updatedAtTs,
|
|
366
|
+
...retryAfterTs === undefined ? {} : { retryAfterTs },
|
|
367
|
+
...rollbackHealthy === undefined ? {} : { rollbackHealthy },
|
|
368
|
+
...failureReason === undefined ? {} : { reason: failureReason }
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
function writeUpdateState(journalPath, receiptPath, journal) {
|
|
372
|
+
writeAtomic(journalPath, journal, 384);
|
|
373
|
+
writeAtomic(receiptPath, publicReceipt(journal), 416);
|
|
374
|
+
}
|
|
375
|
+
function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
|
|
376
|
+
try {
|
|
377
|
+
if (!existsSync2(path))
|
|
378
|
+
return;
|
|
379
|
+
return validateReceipt(JSON.parse(readFileSync2(path, "utf8")));
|
|
380
|
+
} catch {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
190
384
|
var runCommand = async (input) => {
|
|
191
385
|
const child = Bun.spawn([input.command, ...input.args], {
|
|
192
386
|
cwd: input.cwd,
|
|
@@ -202,8 +396,27 @@ var runCommand = async (input) => {
|
|
|
202
396
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
203
397
|
};
|
|
204
398
|
var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
|
|
399
|
+
var retryAfter = (now, failures) => now + Math.min(UPDATE_RETRY_MAX_MS, UPDATE_RETRY_BASE_MS * 2 ** Math.min(16, Math.max(0, failures - 1)));
|
|
400
|
+
var stagedFromJournal = (journal, root) => ({
|
|
401
|
+
version: journal.targetVersion,
|
|
402
|
+
fromVersion: journal.fromVersion,
|
|
403
|
+
directory: join2(resolve2(root), journal.nextTarget),
|
|
404
|
+
previousTarget: journal.previousTarget,
|
|
405
|
+
nextTarget: journal.nextTarget,
|
|
406
|
+
currentLink: journal.currentLink
|
|
407
|
+
});
|
|
408
|
+
var restartAgent = async (target, run) => {
|
|
409
|
+
const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
|
|
410
|
+
for (const unit of helpers)
|
|
411
|
+
await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
|
|
412
|
+
const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
|
|
413
|
+
if (!await runOk(run, "/usr/bin/systemctl", ["restart", service])) {
|
|
414
|
+
throw new Error(`systemd could not restart ${service}`);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
|
|
205
418
|
function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
206
|
-
return new Promise((
|
|
419
|
+
return new Promise((resolve3) => {
|
|
207
420
|
const socket = connect(socketPath);
|
|
208
421
|
let settled = false;
|
|
209
422
|
let buffer = "";
|
|
@@ -213,7 +426,7 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
|
213
426
|
settled = true;
|
|
214
427
|
clearTimeout(timer);
|
|
215
428
|
socket.destroy();
|
|
216
|
-
|
|
429
|
+
resolve3(value);
|
|
217
430
|
};
|
|
218
431
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
219
432
|
socket.on("connect", () => socket.write(`{"op":"identity"}
|
|
@@ -237,55 +450,136 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
|
237
450
|
async function activateAgentRelease(staged, options = {}) {
|
|
238
451
|
const run = options.run ?? runCommand;
|
|
239
452
|
const target = options.target ?? "compute";
|
|
240
|
-
const probe = options.probe ?? (target
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
453
|
+
const probe = options.probe ?? targetProbe(target, run);
|
|
454
|
+
const now = options.now ?? Date.now;
|
|
455
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
456
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
457
|
+
const previous = readJournal(journalPath, dirname2(staged.currentLink));
|
|
458
|
+
const attemptId = options.attemptId ?? randomUUID2();
|
|
459
|
+
if (!ATTEMPT_ID.test(attemptId))
|
|
460
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
461
|
+
const startedAtTs = now();
|
|
462
|
+
const failureCount = previous?.targetVersion === staged.version ? previous.failureCount : 0;
|
|
463
|
+
let journal = {
|
|
464
|
+
schemaVersion: 1,
|
|
465
|
+
attemptId,
|
|
466
|
+
target,
|
|
467
|
+
fromVersion: staged.fromVersion,
|
|
468
|
+
targetVersion: staged.version,
|
|
469
|
+
outcome: "activating",
|
|
470
|
+
startedAtTs,
|
|
471
|
+
updatedAtTs: startedAtTs,
|
|
472
|
+
currentLink: staged.currentLink,
|
|
473
|
+
previousTarget: staged.previousTarget,
|
|
474
|
+
nextTarget: staged.nextTarget,
|
|
475
|
+
failureCount
|
|
250
476
|
};
|
|
251
|
-
|
|
477
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
478
|
+
let selectionAttempted = false;
|
|
252
479
|
try {
|
|
480
|
+
selectionAttempted = true;
|
|
253
481
|
selectAgentRelease(staged);
|
|
254
|
-
|
|
255
|
-
await restart();
|
|
482
|
+
await restartAgent(target, run);
|
|
256
483
|
if (!await probe())
|
|
257
484
|
throw new Error("the replacement Agent did not answer its retained Vault socket");
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const next = `${receipt}.next`;
|
|
261
|
-
writeFileSync2(next, JSON.stringify({
|
|
262
|
-
version: staged.version,
|
|
263
|
-
outcome: "active",
|
|
264
|
-
updatedAtTs: (options.now ?? Date.now)()
|
|
265
|
-
}) + `
|
|
266
|
-
`, { mode: 420 });
|
|
267
|
-
renameSync2(next, receipt);
|
|
485
|
+
journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
|
|
486
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
268
487
|
run({
|
|
269
488
|
command: "/usr/bin/systemctl",
|
|
270
489
|
args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
|
|
271
490
|
});
|
|
272
491
|
return { ok: true, version: staged.version };
|
|
273
492
|
} catch (cause) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
493
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
494
|
+
let rollbackHealthy = false;
|
|
495
|
+
let restored = false;
|
|
496
|
+
if (selectionAttempted) {
|
|
497
|
+
try {
|
|
498
|
+
restoreAgentRelease(staged);
|
|
499
|
+
restored = true;
|
|
500
|
+
await restartAgent(target, run);
|
|
501
|
+
rollbackHealthy = await probe();
|
|
502
|
+
} catch {
|
|
503
|
+
rollbackHealthy = false;
|
|
504
|
+
}
|
|
278
505
|
}
|
|
279
|
-
|
|
506
|
+
const failures = failureCount + 1;
|
|
507
|
+
const updatedAtTs = now();
|
|
508
|
+
journal = {
|
|
509
|
+
...journal,
|
|
510
|
+
outcome: rollbackHealthy ? "rolled-back" : "failed",
|
|
511
|
+
updatedAtTs,
|
|
512
|
+
retryAfterTs: retryAfter(updatedAtTs, failures),
|
|
513
|
+
rollbackHealthy,
|
|
514
|
+
reason: reason(rollbackHealthy ? "REPLACEMENT_UNHEALTHY" : "ROLLBACK_UNHEALTHY", rollbackHealthy ? message : `${message}; the restored Agent did not pass its health probe`),
|
|
515
|
+
failureCount: failures
|
|
516
|
+
};
|
|
517
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
518
|
+
return { ok: false, rolledBack: restored, rollbackHealthy, reason: message };
|
|
280
519
|
}
|
|
281
520
|
}
|
|
521
|
+
async function recoverInterruptedAgentUpdate(options = {}) {
|
|
522
|
+
const root = resolve2(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
|
|
523
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
524
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
525
|
+
const journal = readJournal(journalPath, root);
|
|
526
|
+
if (!journal)
|
|
527
|
+
return;
|
|
528
|
+
if (journal.outcome !== "activating") {
|
|
529
|
+
writeAtomic(receiptPath, publicReceipt(journal), 416);
|
|
530
|
+
return publicReceipt(journal);
|
|
531
|
+
}
|
|
532
|
+
const staged = stagedFromJournal(journal, root);
|
|
533
|
+
if (!existsSync2(join2(root, journal.previousTarget))) {
|
|
534
|
+
throw new Error("Agent update rollback release is missing");
|
|
535
|
+
}
|
|
536
|
+
const run = options.run ?? runCommand;
|
|
537
|
+
const probe = options.probe ?? targetProbe(journal.target, run);
|
|
538
|
+
restoreAgentRelease(staged);
|
|
539
|
+
let rollbackHealthy = false;
|
|
540
|
+
let failureMessage = "activation was interrupted before its health verdict became durable";
|
|
541
|
+
try {
|
|
542
|
+
await restartAgent(journal.target, run);
|
|
543
|
+
rollbackHealthy = await probe();
|
|
544
|
+
} catch (cause) {
|
|
545
|
+
failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
546
|
+
}
|
|
547
|
+
const failures = journal.failureCount + 1;
|
|
548
|
+
const updatedAtTs = (options.now ?? Date.now)();
|
|
549
|
+
const recovered = {
|
|
550
|
+
...journal,
|
|
551
|
+
outcome: rollbackHealthy ? "rolled-back" : "failed",
|
|
552
|
+
updatedAtTs,
|
|
553
|
+
retryAfterTs: retryAfter(updatedAtTs, failures),
|
|
554
|
+
rollbackHealthy,
|
|
555
|
+
reason: reason(rollbackHealthy ? "ACTIVATION_INTERRUPTED" : "ROLLBACK_UNHEALTHY", failureMessage),
|
|
556
|
+
failureCount: failures
|
|
557
|
+
};
|
|
558
|
+
writeUpdateState(journalPath, receiptPath, recovered);
|
|
559
|
+
return readAgentUpdateReceipt(receiptPath);
|
|
560
|
+
}
|
|
282
561
|
function startAgentUpdateHelper(options = {}) {
|
|
283
562
|
const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
|
|
284
563
|
if (existsSync2(socketPath))
|
|
285
564
|
unlinkSync(socketPath);
|
|
286
565
|
mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
|
|
287
566
|
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
288
|
-
const
|
|
567
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
568
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
569
|
+
const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
|
|
570
|
+
const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
|
|
571
|
+
let busy = true;
|
|
572
|
+
let blocked;
|
|
573
|
+
(options.recover ?? (() => recoverInterruptedAgentUpdate({
|
|
574
|
+
root: releaseRoot,
|
|
575
|
+
journalPath,
|
|
576
|
+
receiptPath,
|
|
577
|
+
now: options.now
|
|
578
|
+
})))().catch((cause) => {
|
|
579
|
+
blocked = cause instanceof Error ? cause.message : String(cause);
|
|
580
|
+
}).finally(() => {
|
|
581
|
+
busy = false;
|
|
582
|
+
});
|
|
289
583
|
const server = createServer((socket) => {
|
|
290
584
|
let buffer = "";
|
|
291
585
|
socket.on("data", (chunk) => {
|
|
@@ -301,22 +595,42 @@ function startAgentUpdateHelper(options = {}) {
|
|
|
301
595
|
return;
|
|
302
596
|
const line = buffer.slice(0, newline);
|
|
303
597
|
buffer = "";
|
|
598
|
+
let ownsBusy = false;
|
|
304
599
|
Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
|
|
600
|
+
if (blocked)
|
|
601
|
+
throw new Error(`update journal needs operator recovery: ${blocked}`);
|
|
602
|
+
if (busy)
|
|
603
|
+
throw new Error("another Agent update or recovery is already active");
|
|
305
604
|
if (request.op !== "apply")
|
|
306
605
|
throw new Error("unknown update operation");
|
|
307
606
|
if (request.target !== "compute" && request.target !== "metal") {
|
|
308
607
|
throw new Error("agent update target is invalid");
|
|
309
608
|
}
|
|
609
|
+
const attemptId = request.attemptId ?? randomUUID2();
|
|
610
|
+
if (!ATTEMPT_ID.test(attemptId))
|
|
611
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
612
|
+
const prior = readJournal(journalPath, releaseRoot);
|
|
613
|
+
const now = (options.now ?? Date.now)();
|
|
614
|
+
if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
|
|
615
|
+
throw new Error(`Agent update ${request.release.version} is quarantined until ${prior.retryAfterTs}`);
|
|
616
|
+
busy = true;
|
|
617
|
+
ownsBusy = true;
|
|
310
618
|
const staged = await stageAgentRelease(request.release, {
|
|
311
619
|
currentVersion: request.currentVersion,
|
|
312
|
-
root:
|
|
620
|
+
root: releaseRoot
|
|
313
621
|
});
|
|
314
|
-
const response = { ok: true, status: "staged", version: staged.version };
|
|
622
|
+
const response = { ok: true, status: "staged", version: staged.version, attemptId };
|
|
315
623
|
socket.end(`${JSON.stringify(response)}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
624
|
+
`);
|
|
625
|
+
setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
|
|
626
|
+
blocked = cause instanceof Error ? cause.message : String(cause);
|
|
627
|
+
}).finally(() => {
|
|
628
|
+
busy = false;
|
|
629
|
+
}), 100);
|
|
630
|
+
ownsBusy = false;
|
|
319
631
|
}).catch((cause) => {
|
|
632
|
+
if (ownsBusy)
|
|
633
|
+
busy = false;
|
|
320
634
|
const response = {
|
|
321
635
|
ok: false,
|
|
322
636
|
error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
|
|
@@ -331,7 +645,7 @@ function startAgentUpdateHelper(options = {}) {
|
|
|
331
645
|
return server;
|
|
332
646
|
}
|
|
333
647
|
function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
|
|
334
|
-
return new Promise((
|
|
648
|
+
return new Promise((resolve3, reject) => {
|
|
335
649
|
const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
|
|
336
650
|
`));
|
|
337
651
|
let buffer = "";
|
|
@@ -347,7 +661,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
|
|
|
347
661
|
return;
|
|
348
662
|
socket.end();
|
|
349
663
|
try {
|
|
350
|
-
|
|
664
|
+
resolve3(JSON.parse(buffer.slice(0, newline)));
|
|
351
665
|
} catch (cause) {
|
|
352
666
|
reject(cause);
|
|
353
667
|
}
|
|
@@ -358,9 +672,12 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
|
|
|
358
672
|
export {
|
|
359
673
|
startAgentUpdateHelper,
|
|
360
674
|
requestAgentUpdate,
|
|
675
|
+
recoverInterruptedAgentUpdate,
|
|
676
|
+
readAgentUpdateReceipt,
|
|
361
677
|
probeAgentSocket,
|
|
362
678
|
activateAgentRelease,
|
|
363
679
|
AGENT_UPDATE_RECEIPT,
|
|
680
|
+
AGENT_UPDATE_JOURNAL,
|
|
364
681
|
AGENT_UPDATE_HELPER_UNIT_PATH,
|
|
365
682
|
AGENT_UPDATE_GROUP
|
|
366
683
|
};
|