@forgezero/agent 0.1.26 → 0.1.27
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/README.md +2 -1
- package/dist/agent-heartbeat.d.ts +45 -0
- package/dist/agent-heartbeat.js +498 -0
- package/dist/agent-update-helper.d.ts +45 -0
- package/dist/agent-update-helper.js +366 -0
- package/dist/agent-update.d.ts +46 -0
- package/dist/agent-update.js +184 -0
- package/dist/definition.d.ts +1 -7
- package/dist/definition.js +92 -23
- package/dist/deployment-pull.d.ts +2 -0
- package/dist/deployment.d.ts +3 -0
- package/dist/fz-agent.js +7834 -2618
- package/dist/fz.js +8287 -38
- package/dist/index.d.ts +13 -0
- package/dist/provision.d.ts +13 -0
- package/dist/provision.js +685 -6
- package/dist/socket.d.ts +2 -0
- package/dist/software-helper.d.ts +14 -0
- package/dist/software-helper.js +203 -0
- package/dist/software.d.ts +27 -0
- package/dist/software.js +95 -0
- package/dist/version.d.ts +1 -1
- package/package.json +24 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// src/agent-update.ts
|
|
2
|
+
import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readlinkSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
|
|
16
|
+
var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
17
|
+
var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
|
|
18
|
+
var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
19
|
+
var REGISTRY = "registry.npmjs.org";
|
|
20
|
+
function validateAgentRelease(release) {
|
|
21
|
+
if (release?.package !== "@forgezero/agent")
|
|
22
|
+
throw new Error("agent update package is fixed");
|
|
23
|
+
if (!VERSION.test(release.version))
|
|
24
|
+
throw new Error("agent update version must be exact semver");
|
|
25
|
+
const expectedTarball = `/@forgezero/agent/-/agent-${release.version}.tgz`;
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(release.tarball);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("agent update tarball URL is malformed");
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== "https:" || url.hostname !== REGISTRY || url.port || url.username || url.password || url.search || url.hash || url.pathname !== expectedTarball)
|
|
33
|
+
throw new Error("agent update tarball must be the exact official npm artifact");
|
|
34
|
+
const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(release.integrity);
|
|
35
|
+
if (!match || Buffer.from(match[1], "base64").length !== 64) {
|
|
36
|
+
throw new Error("agent update requires one sha512 npm integrity");
|
|
37
|
+
}
|
|
38
|
+
return release;
|
|
39
|
+
}
|
|
40
|
+
function compareVersions(left, right) {
|
|
41
|
+
if (!VERSION.test(left) || !VERSION.test(right))
|
|
42
|
+
throw new Error("agent version must be exact semver");
|
|
43
|
+
const a = left.split(".").map(Number);
|
|
44
|
+
const b = right.split(".").map(Number);
|
|
45
|
+
for (let index = 0;index < 3; index += 1) {
|
|
46
|
+
if (a[index] > b[index])
|
|
47
|
+
return 1;
|
|
48
|
+
if (a[index] < b[index])
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
var command = async (input) => {
|
|
54
|
+
const child = Bun.spawn([input.command, ...input.args], {
|
|
55
|
+
cwd: input.cwd,
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
59
|
+
});
|
|
60
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
61
|
+
new Response(child.stdout).text(),
|
|
62
|
+
new Response(child.stderr).text(),
|
|
63
|
+
child.exited
|
|
64
|
+
]);
|
|
65
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
66
|
+
};
|
|
67
|
+
var checked = async (run, input, label) => {
|
|
68
|
+
const result = await run(input);
|
|
69
|
+
if (result.exitCode !== 0)
|
|
70
|
+
throw new Error(`${label} failed: ${result.output.trim()}`);
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
async function validateReleaseDirectory(directory, release, run) {
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
75
|
+
if (manifest.name !== release.package || manifest.version !== release.version) {
|
|
76
|
+
throw new Error("agent update manifest does not match the selected release");
|
|
77
|
+
}
|
|
78
|
+
const agent = join(directory, "dist", "fz-agent.js");
|
|
79
|
+
const cli = join(directory, "dist", "fz.js");
|
|
80
|
+
for (const binary of [agent, cli]) {
|
|
81
|
+
if (!readFileSync(binary, "utf8").startsWith(`#!/usr/bin/env bun
|
|
82
|
+
`)) {
|
|
83
|
+
throw new Error("agent update artifact is not a self-contained Bun executable");
|
|
84
|
+
}
|
|
85
|
+
chmodSync(binary, 493);
|
|
86
|
+
}
|
|
87
|
+
const version = (await checked(run, { command: agent, args: ["--version"] }, "agent update smoke test")).output.trim();
|
|
88
|
+
if (version !== release.version)
|
|
89
|
+
throw new Error(`agent update binary reports ${version}`);
|
|
90
|
+
}
|
|
91
|
+
async function stageAgentRelease(releaseInput, options) {
|
|
92
|
+
const release = validateAgentRelease(releaseInput);
|
|
93
|
+
if (compareVersions(release.version, options.currentVersion) <= 0) {
|
|
94
|
+
throw new Error(`agent update ${release.version} is not newer than ${options.currentVersion}`);
|
|
95
|
+
}
|
|
96
|
+
const root = resolve(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
|
|
97
|
+
const versions = join(root, "versions");
|
|
98
|
+
const finalDirectory = join(versions, release.version);
|
|
99
|
+
const currentLink = join(root, "current");
|
|
100
|
+
const stage = join(versions, `.${release.version}.${randomUUID()}.staging`);
|
|
101
|
+
const archive = join(stage, "agent.tgz");
|
|
102
|
+
const unpacked = join(stage, "unpacked");
|
|
103
|
+
const run = options.run ?? command;
|
|
104
|
+
mkdirSync(unpacked, { recursive: true, mode: 448 });
|
|
105
|
+
try {
|
|
106
|
+
const response = await (options.fetch ?? globalThis.fetch)(release.tarball, {
|
|
107
|
+
redirect: "error",
|
|
108
|
+
signal: AbortSignal.timeout(30000)
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
throw new Error(`npm returned HTTP ${response.status}`);
|
|
112
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
113
|
+
if (declared > MAX_AGENT_TARBALL_BYTES)
|
|
114
|
+
throw new Error("agent update tarball exceeds the size limit");
|
|
115
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
116
|
+
if (bytes.length === 0 || bytes.length > MAX_AGENT_TARBALL_BYTES) {
|
|
117
|
+
throw new Error("agent update tarball is empty or exceeds the size limit");
|
|
118
|
+
}
|
|
119
|
+
const expected = Buffer.from(release.integrity.slice("sha512-".length), "base64");
|
|
120
|
+
const actual = createHash("sha512").update(bytes).digest();
|
|
121
|
+
if (!timingSafeEqual(actual, expected))
|
|
122
|
+
throw new Error("agent update integrity mismatch");
|
|
123
|
+
writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
|
|
124
|
+
await checked(run, {
|
|
125
|
+
command: "/usr/bin/tar",
|
|
126
|
+
args: [
|
|
127
|
+
"-xzf",
|
|
128
|
+
archive,
|
|
129
|
+
"-C",
|
|
130
|
+
unpacked,
|
|
131
|
+
"--strip-components=1",
|
|
132
|
+
"package/package.json",
|
|
133
|
+
"package/dist/fz-agent.js",
|
|
134
|
+
"package/dist/fz.js"
|
|
135
|
+
]
|
|
136
|
+
}, "agent update extraction");
|
|
137
|
+
await validateReleaseDirectory(unpacked, release, run);
|
|
138
|
+
if (!existsSync(finalDirectory))
|
|
139
|
+
renameSync(unpacked, finalDirectory);
|
|
140
|
+
else
|
|
141
|
+
await validateReleaseDirectory(finalDirectory, release, run);
|
|
142
|
+
if (!existsSync(currentLink)) {
|
|
143
|
+
throw new Error("agent update requires an active immutable release to roll back to");
|
|
144
|
+
}
|
|
145
|
+
const previousTarget = readlinkSync(currentLink);
|
|
146
|
+
return {
|
|
147
|
+
version: release.version,
|
|
148
|
+
directory: finalDirectory,
|
|
149
|
+
previousTarget,
|
|
150
|
+
nextTarget: join("versions", release.version),
|
|
151
|
+
currentLink
|
|
152
|
+
};
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(stage, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function selectAgentRelease(staged) {
|
|
158
|
+
const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.next`);
|
|
159
|
+
try {
|
|
160
|
+
symlinkSync(staged.nextTarget, next);
|
|
161
|
+
renameSync(next, staged.currentLink);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(next, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function restoreAgentRelease(staged) {
|
|
167
|
+
const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.rollback`);
|
|
168
|
+
try {
|
|
169
|
+
symlinkSync(staged.previousTarget, next);
|
|
170
|
+
renameSync(next, staged.currentLink);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(next, { force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/agent-update-helper.ts
|
|
177
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
178
|
+
import { connect, createServer } from "node:net";
|
|
179
|
+
import { dirname as dirname2 } from "node:path";
|
|
180
|
+
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
181
|
+
var AGENT_UPDATE_GROUP = "forgezero-update";
|
|
182
|
+
var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
183
|
+
var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update.json";
|
|
184
|
+
var MAX_REQUEST_BYTES = 8 * 1024;
|
|
185
|
+
var COMPUTE_HELPER_UNITS = [
|
|
186
|
+
"forgezero-deploy-runner.service",
|
|
187
|
+
"forgezero-lifecycle-helper.service",
|
|
188
|
+
"forgezero-software-helper.service"
|
|
189
|
+
];
|
|
190
|
+
var runCommand = async (input) => {
|
|
191
|
+
const child = Bun.spawn([input.command, ...input.args], {
|
|
192
|
+
cwd: input.cwd,
|
|
193
|
+
stdout: "pipe",
|
|
194
|
+
stderr: "pipe",
|
|
195
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
196
|
+
});
|
|
197
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
198
|
+
new Response(child.stdout).text(),
|
|
199
|
+
new Response(child.stderr).text(),
|
|
200
|
+
child.exited
|
|
201
|
+
]);
|
|
202
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
203
|
+
};
|
|
204
|
+
var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
|
|
205
|
+
function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
206
|
+
return new Promise((resolve2) => {
|
|
207
|
+
const socket = connect(socketPath);
|
|
208
|
+
let settled = false;
|
|
209
|
+
let buffer = "";
|
|
210
|
+
const finish = (value) => {
|
|
211
|
+
if (settled)
|
|
212
|
+
return;
|
|
213
|
+
settled = true;
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
socket.destroy();
|
|
216
|
+
resolve2(value);
|
|
217
|
+
};
|
|
218
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
219
|
+
socket.on("connect", () => socket.write(`{"op":"identity"}
|
|
220
|
+
`));
|
|
221
|
+
socket.on("data", (chunk) => {
|
|
222
|
+
buffer += chunk.toString("utf8");
|
|
223
|
+
const newline = buffer.indexOf(`
|
|
224
|
+
`);
|
|
225
|
+
if (newline < 0)
|
|
226
|
+
return;
|
|
227
|
+
try {
|
|
228
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
229
|
+
finish(response.ok === false && response.error?.code === "APP_OPERATION_REFUSED");
|
|
230
|
+
} catch {
|
|
231
|
+
finish(false);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
socket.on("error", () => finish(false));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function activateAgentRelease(staged, options = {}) {
|
|
238
|
+
const run = options.run ?? runCommand;
|
|
239
|
+
const target = options.target ?? "compute";
|
|
240
|
+
const probe = options.probe ?? (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"]));
|
|
241
|
+
const restart = async () => {
|
|
242
|
+
const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
|
|
243
|
+
for (const unit of helpers) {
|
|
244
|
+
await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
|
|
245
|
+
}
|
|
246
|
+
const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
|
|
247
|
+
const restarted = await runOk(run, "/usr/bin/systemctl", ["restart", service]);
|
|
248
|
+
if (!restarted)
|
|
249
|
+
throw new Error(`systemd could not restart ${service}`);
|
|
250
|
+
};
|
|
251
|
+
let selected = false;
|
|
252
|
+
try {
|
|
253
|
+
selectAgentRelease(staged);
|
|
254
|
+
selected = true;
|
|
255
|
+
await restart();
|
|
256
|
+
if (!await probe())
|
|
257
|
+
throw new Error("the replacement Agent did not answer its retained Vault socket");
|
|
258
|
+
const receipt = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
259
|
+
mkdirSync2(dirname2(receipt), { recursive: true, mode: 493 });
|
|
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);
|
|
268
|
+
run({
|
|
269
|
+
command: "/usr/bin/systemctl",
|
|
270
|
+
args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
|
|
271
|
+
});
|
|
272
|
+
return { ok: true, version: staged.version };
|
|
273
|
+
} catch (cause) {
|
|
274
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
275
|
+
if (selected) {
|
|
276
|
+
restoreAgentRelease(staged);
|
|
277
|
+
await restart();
|
|
278
|
+
}
|
|
279
|
+
return { ok: false, rolledBack: selected, reason };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function startAgentUpdateHelper(options = {}) {
|
|
283
|
+
const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
|
|
284
|
+
if (existsSync2(socketPath))
|
|
285
|
+
unlinkSync(socketPath);
|
|
286
|
+
mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
|
|
287
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
288
|
+
const activate = options.activate ?? ((staged, target) => activateAgentRelease(staged, { target }));
|
|
289
|
+
const server = createServer((socket) => {
|
|
290
|
+
let buffer = "";
|
|
291
|
+
socket.on("data", (chunk) => {
|
|
292
|
+
buffer += chunk.toString("utf8");
|
|
293
|
+
if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES) {
|
|
294
|
+
socket.end(`${JSON.stringify({ ok: false, error: { code: "TOO_LARGE", message: "update request too large" } })}
|
|
295
|
+
`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const newline = buffer.indexOf(`
|
|
299
|
+
`);
|
|
300
|
+
if (newline < 0)
|
|
301
|
+
return;
|
|
302
|
+
const line = buffer.slice(0, newline);
|
|
303
|
+
buffer = "";
|
|
304
|
+
Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
|
|
305
|
+
if (request.op !== "apply")
|
|
306
|
+
throw new Error("unknown update operation");
|
|
307
|
+
if (request.target !== "compute" && request.target !== "metal") {
|
|
308
|
+
throw new Error("agent update target is invalid");
|
|
309
|
+
}
|
|
310
|
+
const staged = await stageAgentRelease(request.release, {
|
|
311
|
+
currentVersion: request.currentVersion,
|
|
312
|
+
root: options.root ?? DEFAULT_AGENT_RELEASE_ROOT
|
|
313
|
+
});
|
|
314
|
+
const response = { ok: true, status: "staged", version: staged.version };
|
|
315
|
+
socket.end(`${JSON.stringify(response)}
|
|
316
|
+
`, () => {
|
|
317
|
+
setTimer(() => void activate(staged, request.target), 100);
|
|
318
|
+
});
|
|
319
|
+
}).catch((cause) => {
|
|
320
|
+
const response = {
|
|
321
|
+
ok: false,
|
|
322
|
+
error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
|
|
323
|
+
};
|
|
324
|
+
socket.end(`${JSON.stringify(response)}
|
|
325
|
+
`);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
socket.on("error", () => socket.destroy());
|
|
329
|
+
});
|
|
330
|
+
server.listen(socketPath, () => chmodSync2(socketPath, 432));
|
|
331
|
+
return server;
|
|
332
|
+
}
|
|
333
|
+
function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
|
|
334
|
+
return new Promise((resolve2, reject) => {
|
|
335
|
+
const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
|
|
336
|
+
`));
|
|
337
|
+
let buffer = "";
|
|
338
|
+
socket.setTimeout(timeoutMs, () => {
|
|
339
|
+
socket.destroy();
|
|
340
|
+
reject(new Error("agent update helper did not answer before its deadline"));
|
|
341
|
+
});
|
|
342
|
+
socket.on("data", (chunk) => {
|
|
343
|
+
buffer += chunk.toString("utf8");
|
|
344
|
+
const newline = buffer.indexOf(`
|
|
345
|
+
`);
|
|
346
|
+
if (newline < 0)
|
|
347
|
+
return;
|
|
348
|
+
socket.end();
|
|
349
|
+
try {
|
|
350
|
+
resolve2(JSON.parse(buffer.slice(0, newline)));
|
|
351
|
+
} catch (cause) {
|
|
352
|
+
reject(cause);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
socket.on("error", reject);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
export {
|
|
359
|
+
startAgentUpdateHelper,
|
|
360
|
+
requestAgentUpdate,
|
|
361
|
+
probeAgentSocket,
|
|
362
|
+
activateAgentRelease,
|
|
363
|
+
AGENT_UPDATE_RECEIPT,
|
|
364
|
+
AGENT_UPDATE_HELPER_UNIT_PATH,
|
|
365
|
+
AGENT_UPDATE_GROUP
|
|
366
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** A release selected by the API and delivered inside the PQ-sealed response. */
|
|
2
|
+
export interface AgentRelease {
|
|
3
|
+
package: '@forgezero/agent';
|
|
4
|
+
version: string;
|
|
5
|
+
integrity: string;
|
|
6
|
+
tarball: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UpdateCommand {
|
|
9
|
+
command: string;
|
|
10
|
+
args: readonly string[];
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpdateCommandResult {
|
|
14
|
+
exitCode: number;
|
|
15
|
+
output: string;
|
|
16
|
+
}
|
|
17
|
+
export interface StagedAgentRelease {
|
|
18
|
+
version: string;
|
|
19
|
+
directory: string;
|
|
20
|
+
previousTarget: string;
|
|
21
|
+
nextTarget: string;
|
|
22
|
+
currentLink: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
|
|
25
|
+
export declare const DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
26
|
+
export declare const MAX_AGENT_TARBALL_BYTES: number;
|
|
27
|
+
/** Root helper input is data, never a command or filesystem path. */
|
|
28
|
+
export declare function validateAgentRelease(release: AgentRelease): AgentRelease;
|
|
29
|
+
export declare function compareVersions(left: string, right: string): number;
|
|
30
|
+
/**
|
|
31
|
+
* Download, verify and atomically select one self-contained Agent release.
|
|
32
|
+
*
|
|
33
|
+
* The published `fz-agent.js` and `fz.js` are dependency-bundled. No package
|
|
34
|
+
* manager executes lifecycle scripts on a compute, and no mutable global
|
|
35
|
+
* node_modules tree sits underneath a running daemon.
|
|
36
|
+
*/
|
|
37
|
+
export declare function stageAgentRelease(releaseInput: AgentRelease, options: {
|
|
38
|
+
currentVersion: string;
|
|
39
|
+
root?: string;
|
|
40
|
+
fetch?: typeof globalThis.fetch;
|
|
41
|
+
run?: (input: UpdateCommand) => Promise<UpdateCommandResult>;
|
|
42
|
+
}): Promise<StagedAgentRelease>;
|
|
43
|
+
/** Select the already-verified immutable directory immediately before restart. */
|
|
44
|
+
export declare function selectAgentRelease(staged: StagedAgentRelease): void;
|
|
45
|
+
/** Roll back only to the exact link target captured before activation. */
|
|
46
|
+
export declare function restoreAgentRelease(staged: StagedAgentRelease): void;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/agent-update.ts
|
|
2
|
+
import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readlinkSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
|
|
16
|
+
var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
17
|
+
var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
|
|
18
|
+
var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
19
|
+
var REGISTRY = "registry.npmjs.org";
|
|
20
|
+
function validateAgentRelease(release) {
|
|
21
|
+
if (release?.package !== "@forgezero/agent")
|
|
22
|
+
throw new Error("agent update package is fixed");
|
|
23
|
+
if (!VERSION.test(release.version))
|
|
24
|
+
throw new Error("agent update version must be exact semver");
|
|
25
|
+
const expectedTarball = `/@forgezero/agent/-/agent-${release.version}.tgz`;
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(release.tarball);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("agent update tarball URL is malformed");
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== "https:" || url.hostname !== REGISTRY || url.port || url.username || url.password || url.search || url.hash || url.pathname !== expectedTarball)
|
|
33
|
+
throw new Error("agent update tarball must be the exact official npm artifact");
|
|
34
|
+
const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(release.integrity);
|
|
35
|
+
if (!match || Buffer.from(match[1], "base64").length !== 64) {
|
|
36
|
+
throw new Error("agent update requires one sha512 npm integrity");
|
|
37
|
+
}
|
|
38
|
+
return release;
|
|
39
|
+
}
|
|
40
|
+
function compareVersions(left, right) {
|
|
41
|
+
if (!VERSION.test(left) || !VERSION.test(right))
|
|
42
|
+
throw new Error("agent version must be exact semver");
|
|
43
|
+
const a = left.split(".").map(Number);
|
|
44
|
+
const b = right.split(".").map(Number);
|
|
45
|
+
for (let index = 0;index < 3; index += 1) {
|
|
46
|
+
if (a[index] > b[index])
|
|
47
|
+
return 1;
|
|
48
|
+
if (a[index] < b[index])
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
var command = async (input) => {
|
|
54
|
+
const child = Bun.spawn([input.command, ...input.args], {
|
|
55
|
+
cwd: input.cwd,
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
59
|
+
});
|
|
60
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
61
|
+
new Response(child.stdout).text(),
|
|
62
|
+
new Response(child.stderr).text(),
|
|
63
|
+
child.exited
|
|
64
|
+
]);
|
|
65
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
66
|
+
};
|
|
67
|
+
var checked = async (run, input, label) => {
|
|
68
|
+
const result = await run(input);
|
|
69
|
+
if (result.exitCode !== 0)
|
|
70
|
+
throw new Error(`${label} failed: ${result.output.trim()}`);
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
async function validateReleaseDirectory(directory, release, run) {
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
75
|
+
if (manifest.name !== release.package || manifest.version !== release.version) {
|
|
76
|
+
throw new Error("agent update manifest does not match the selected release");
|
|
77
|
+
}
|
|
78
|
+
const agent = join(directory, "dist", "fz-agent.js");
|
|
79
|
+
const cli = join(directory, "dist", "fz.js");
|
|
80
|
+
for (const binary of [agent, cli]) {
|
|
81
|
+
if (!readFileSync(binary, "utf8").startsWith(`#!/usr/bin/env bun
|
|
82
|
+
`)) {
|
|
83
|
+
throw new Error("agent update artifact is not a self-contained Bun executable");
|
|
84
|
+
}
|
|
85
|
+
chmodSync(binary, 493);
|
|
86
|
+
}
|
|
87
|
+
const version = (await checked(run, { command: agent, args: ["--version"] }, "agent update smoke test")).output.trim();
|
|
88
|
+
if (version !== release.version)
|
|
89
|
+
throw new Error(`agent update binary reports ${version}`);
|
|
90
|
+
}
|
|
91
|
+
async function stageAgentRelease(releaseInput, options) {
|
|
92
|
+
const release = validateAgentRelease(releaseInput);
|
|
93
|
+
if (compareVersions(release.version, options.currentVersion) <= 0) {
|
|
94
|
+
throw new Error(`agent update ${release.version} is not newer than ${options.currentVersion}`);
|
|
95
|
+
}
|
|
96
|
+
const root = resolve(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
|
|
97
|
+
const versions = join(root, "versions");
|
|
98
|
+
const finalDirectory = join(versions, release.version);
|
|
99
|
+
const currentLink = join(root, "current");
|
|
100
|
+
const stage = join(versions, `.${release.version}.${randomUUID()}.staging`);
|
|
101
|
+
const archive = join(stage, "agent.tgz");
|
|
102
|
+
const unpacked = join(stage, "unpacked");
|
|
103
|
+
const run = options.run ?? command;
|
|
104
|
+
mkdirSync(unpacked, { recursive: true, mode: 448 });
|
|
105
|
+
try {
|
|
106
|
+
const response = await (options.fetch ?? globalThis.fetch)(release.tarball, {
|
|
107
|
+
redirect: "error",
|
|
108
|
+
signal: AbortSignal.timeout(30000)
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
throw new Error(`npm returned HTTP ${response.status}`);
|
|
112
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
113
|
+
if (declared > MAX_AGENT_TARBALL_BYTES)
|
|
114
|
+
throw new Error("agent update tarball exceeds the size limit");
|
|
115
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
116
|
+
if (bytes.length === 0 || bytes.length > MAX_AGENT_TARBALL_BYTES) {
|
|
117
|
+
throw new Error("agent update tarball is empty or exceeds the size limit");
|
|
118
|
+
}
|
|
119
|
+
const expected = Buffer.from(release.integrity.slice("sha512-".length), "base64");
|
|
120
|
+
const actual = createHash("sha512").update(bytes).digest();
|
|
121
|
+
if (!timingSafeEqual(actual, expected))
|
|
122
|
+
throw new Error("agent update integrity mismatch");
|
|
123
|
+
writeFileSync(archive, bytes, { mode: 384, flag: "wx" });
|
|
124
|
+
await checked(run, {
|
|
125
|
+
command: "/usr/bin/tar",
|
|
126
|
+
args: [
|
|
127
|
+
"-xzf",
|
|
128
|
+
archive,
|
|
129
|
+
"-C",
|
|
130
|
+
unpacked,
|
|
131
|
+
"--strip-components=1",
|
|
132
|
+
"package/package.json",
|
|
133
|
+
"package/dist/fz-agent.js",
|
|
134
|
+
"package/dist/fz.js"
|
|
135
|
+
]
|
|
136
|
+
}, "agent update extraction");
|
|
137
|
+
await validateReleaseDirectory(unpacked, release, run);
|
|
138
|
+
if (!existsSync(finalDirectory))
|
|
139
|
+
renameSync(unpacked, finalDirectory);
|
|
140
|
+
else
|
|
141
|
+
await validateReleaseDirectory(finalDirectory, release, run);
|
|
142
|
+
if (!existsSync(currentLink)) {
|
|
143
|
+
throw new Error("agent update requires an active immutable release to roll back to");
|
|
144
|
+
}
|
|
145
|
+
const previousTarget = readlinkSync(currentLink);
|
|
146
|
+
return {
|
|
147
|
+
version: release.version,
|
|
148
|
+
directory: finalDirectory,
|
|
149
|
+
previousTarget,
|
|
150
|
+
nextTarget: join("versions", release.version),
|
|
151
|
+
currentLink
|
|
152
|
+
};
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(stage, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function selectAgentRelease(staged) {
|
|
158
|
+
const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.next`);
|
|
159
|
+
try {
|
|
160
|
+
symlinkSync(staged.nextTarget, next);
|
|
161
|
+
renameSync(next, staged.currentLink);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(next, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function restoreAgentRelease(staged) {
|
|
167
|
+
const next = join(dirname(staged.currentLink), `.current.${randomUUID()}.rollback`);
|
|
168
|
+
try {
|
|
169
|
+
symlinkSync(staged.previousTarget, next);
|
|
170
|
+
renameSync(next, staged.currentLink);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(next, { force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export {
|
|
176
|
+
validateAgentRelease,
|
|
177
|
+
stageAgentRelease,
|
|
178
|
+
selectAgentRelease,
|
|
179
|
+
restoreAgentRelease,
|
|
180
|
+
compareVersions,
|
|
181
|
+
MAX_AGENT_TARBALL_BYTES,
|
|
182
|
+
DEFAULT_AGENT_UPDATE_SOCKET,
|
|
183
|
+
DEFAULT_AGENT_RELEASE_ROOT
|
|
184
|
+
};
|
package/dist/definition.d.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { Pipeline, PipelineStep } from './pipeline';
|
|
2
|
+
import { type SoftwareRequirement } from './software';
|
|
2
3
|
export declare const PIPELINE_VERSION: 1;
|
|
3
|
-
export interface SoftwareRequirement {
|
|
4
|
-
name: string;
|
|
5
|
-
check: string;
|
|
6
|
-
install: string;
|
|
7
|
-
}
|
|
8
4
|
export interface PipelineRole {
|
|
9
5
|
name: string;
|
|
10
6
|
software: readonly SoftwareRequirement[];
|
|
@@ -29,5 +25,3 @@ export declare class DefinitionError extends Error {
|
|
|
29
25
|
/** Validate parsed YAML before any command from it is allowed to run. */
|
|
30
26
|
export declare function parseDeployDefinition(value: unknown): DeployDefinition;
|
|
31
27
|
export declare function phasePipeline(definition: DeployDefinition, phase: DeployStep['phase']): Pipeline;
|
|
32
|
-
/** Turn one role's declared software checks into the same executable pipeline shape. */
|
|
33
|
-
export declare function prerequisitePipeline(definition: DeployDefinition, roleName: string): Pipeline;
|