@forgezero/agent 0.1.25 → 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 +7838 -2595
- package/dist/fz.js +8287 -38
- package/dist/index.d.ts +13 -0
- package/dist/node-vault.js +37 -10
- 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
package/README.md
CHANGED
|
@@ -130,7 +130,8 @@ pipeline result; no polling service or persistent queue is required.
|
|
|
130
130
|
The daemon owns source checkout and command execution. Bootstrap explicitly
|
|
131
131
|
awaits release one because the API does not exist yet, then the Agent consumes a
|
|
132
132
|
one-use platform enrolment capability. There is no branch watcher. Every normal
|
|
133
|
-
platform or tenant release is
|
|
133
|
+
platform or tenant release is one durable API delivery atomically expanded to
|
|
134
|
+
one row for every explicit compute binding:
|
|
134
135
|
`pending` is written before dispatch, `running` and a fenced lease before project
|
|
135
136
|
code, and only an awaited successful pipeline writes `deployed`. An expired
|
|
136
137
|
claim can be recovered; its stale token cannot renew or finish.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { NodeKeyPair } from '@forgezero/runtime/identity';
|
|
2
|
+
import { type AgentRelease } from './agent-update';
|
|
3
|
+
import { type AgentUpdateResponse } from './agent-update-helper';
|
|
4
|
+
export interface AgentObservation {
|
|
5
|
+
version: string;
|
|
6
|
+
os: {
|
|
7
|
+
id: string;
|
|
8
|
+
versionId: string;
|
|
9
|
+
};
|
|
10
|
+
architecture: string;
|
|
11
|
+
mode: 'attested' | 'enrolled';
|
|
12
|
+
}
|
|
13
|
+
export interface AgentHeartbeatResponse {
|
|
14
|
+
ok: true;
|
|
15
|
+
nodeKey: string;
|
|
16
|
+
intervalSeconds: number;
|
|
17
|
+
desiredAgentRelease?: AgentRelease;
|
|
18
|
+
}
|
|
19
|
+
export interface AgentHeartbeatOptions {
|
|
20
|
+
apiUrl: string;
|
|
21
|
+
nodeKey: string;
|
|
22
|
+
keys: NodeKeyPair;
|
|
23
|
+
mode: AgentObservation['mode'];
|
|
24
|
+
updateTarget?: 'compute' | 'metal';
|
|
25
|
+
version?: string;
|
|
26
|
+
fetch?: (input: URL, init: RequestInit) => Promise<Response>;
|
|
27
|
+
requestTimeoutMs?: number;
|
|
28
|
+
observation?: () => AgentObservation;
|
|
29
|
+
/** Stop new lifecycle/deploy claims and await current jobs before replacement. */
|
|
30
|
+
prepareUpdate?: (release: AgentRelease) => Promise<void>;
|
|
31
|
+
/** Restart the drained current process if staging is refused before activation. */
|
|
32
|
+
recoverUpdate?: (cause: unknown) => Promise<void> | void;
|
|
33
|
+
applyUpdate?: (release: AgentRelease, currentVersion: string) => Promise<AgentUpdateResponse>;
|
|
34
|
+
setTimer?: (callback: () => void, ms: number) => unknown;
|
|
35
|
+
clearTimer?: (handle: unknown) => void;
|
|
36
|
+
onEvent?: (event: string, detail?: unknown) => void;
|
|
37
|
+
}
|
|
38
|
+
export declare function observeAgentHost(version?: string, mode?: AgentObservation['mode'], osRelease?: string, architecture?: NodeJS.Architecture): AgentObservation;
|
|
39
|
+
/** One PQ-authenticated observation and optional supervised update decision. */
|
|
40
|
+
export declare function heartbeatAgentOnce(options: AgentHeartbeatOptions): Promise<AgentHeartbeatResponse>;
|
|
41
|
+
/** Server-paced heartbeat; stopping waits for the active PQ request/update handoff. */
|
|
42
|
+
export declare function startAgentHeartbeat(options: AgentHeartbeatOptions): {
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
readonly active: boolean;
|
|
45
|
+
};
|
|
@@ -0,0 +1,498 @@
|
|
|
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
|
+
|
|
359
|
+
// src/agent-heartbeat.ts
|
|
360
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
361
|
+
|
|
362
|
+
// src/signed-node-http.ts
|
|
363
|
+
import {
|
|
364
|
+
encodeSignatureHeader,
|
|
365
|
+
generateResponseRecipient,
|
|
366
|
+
openResponse,
|
|
367
|
+
RESPONSE_KEY_HEADER,
|
|
368
|
+
signRequest
|
|
369
|
+
} from "@forgezero/runtime/identity";
|
|
370
|
+
|
|
371
|
+
class SignedNodeHttpError extends Error {
|
|
372
|
+
status;
|
|
373
|
+
constructor(status, message) {
|
|
374
|
+
super(message);
|
|
375
|
+
this.status = status;
|
|
376
|
+
this.name = "SignedNodeHttpError";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function postSignedNode(options, path, body) {
|
|
380
|
+
const url = new URL(options.apiUrl);
|
|
381
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
382
|
+
url.search = "";
|
|
383
|
+
url.hash = "";
|
|
384
|
+
const raw = JSON.stringify(body);
|
|
385
|
+
const recipient = generateResponseRecipient();
|
|
386
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
path: url.pathname,
|
|
389
|
+
query: "",
|
|
390
|
+
body: raw,
|
|
391
|
+
responseKey: recipient.publicKey
|
|
392
|
+
});
|
|
393
|
+
const signature = encodeSignatureHeader(envelope);
|
|
394
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: {
|
|
397
|
+
"content-type": "application/json",
|
|
398
|
+
"x-fz-node": options.nodeKey,
|
|
399
|
+
"x-fz-signature": signature,
|
|
400
|
+
[RESPONSE_KEY_HEADER]: recipient.publicKey
|
|
401
|
+
},
|
|
402
|
+
body: raw,
|
|
403
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
404
|
+
});
|
|
405
|
+
const payload = await response.json().catch(() => null);
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
const failure = payload;
|
|
408
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
409
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
413
|
+
} catch {
|
|
414
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/version.ts
|
|
419
|
+
var VERSION2 = "0.1.27";
|
|
420
|
+
|
|
421
|
+
// src/agent-heartbeat.ts
|
|
422
|
+
var unquote = (value) => value.replace(/^['"]|['"]$/g, "");
|
|
423
|
+
function observeAgentHost(version = VERSION2, mode = "enrolled", osRelease = readFileSync2("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
424
|
+
const values = Object.fromEntries(osRelease.split(`
|
|
425
|
+
`).flatMap((line) => {
|
|
426
|
+
const separator = line.indexOf("=");
|
|
427
|
+
return separator > 0 ? [[line.slice(0, separator), unquote(line.slice(separator + 1))]] : [];
|
|
428
|
+
}));
|
|
429
|
+
return {
|
|
430
|
+
version,
|
|
431
|
+
os: { id: (values.ID ?? "unknown").toLowerCase(), versionId: values.VERSION_ID ?? "unknown" },
|
|
432
|
+
architecture,
|
|
433
|
+
mode
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
async function heartbeatAgentOnce(options) {
|
|
437
|
+
const observation = (options.observation ?? (() => observeAgentHost(options.version ?? VERSION2, options.mode)))();
|
|
438
|
+
const response = await postSignedNode(options, "v1/node/heartbeat", observation);
|
|
439
|
+
if (response.desiredAgentRelease) {
|
|
440
|
+
const release = validateAgentRelease(response.desiredAgentRelease);
|
|
441
|
+
if (compareVersions(release.version, observation.version) > 0) {
|
|
442
|
+
let prepared = false;
|
|
443
|
+
try {
|
|
444
|
+
await options.prepareUpdate?.(release);
|
|
445
|
+
prepared = true;
|
|
446
|
+
const applied = await (options.applyUpdate ?? ((next, current) => requestAgentUpdate({
|
|
447
|
+
op: "apply",
|
|
448
|
+
target: options.updateTarget ?? "compute",
|
|
449
|
+
release: next,
|
|
450
|
+
currentVersion: current
|
|
451
|
+
})))(release, observation.version);
|
|
452
|
+
if (!applied.ok)
|
|
453
|
+
throw new Error(`agent update refused: ${applied.error.message}`);
|
|
454
|
+
options.onEvent?.("update-staged", { from: observation.version, to: release.version });
|
|
455
|
+
} catch (cause) {
|
|
456
|
+
if (prepared)
|
|
457
|
+
await options.recoverUpdate?.(cause);
|
|
458
|
+
throw cause;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return response;
|
|
463
|
+
}
|
|
464
|
+
function startAgentHeartbeat(options) {
|
|
465
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
466
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
467
|
+
let stopped = false;
|
|
468
|
+
let timer;
|
|
469
|
+
let active = null;
|
|
470
|
+
const tick = () => {
|
|
471
|
+
if (stopped || active)
|
|
472
|
+
return;
|
|
473
|
+
let nextSeconds = 30;
|
|
474
|
+
active = heartbeatAgentOnce(options).then((response) => {
|
|
475
|
+
nextSeconds = Math.max(5, Math.min(response.intervalSeconds, 300));
|
|
476
|
+
}).catch((cause) => options.onEvent?.("heartbeat-failed", cause)).finally(() => {
|
|
477
|
+
active = null;
|
|
478
|
+
if (!stopped)
|
|
479
|
+
timer = setTimer(tick, nextSeconds * 1000);
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
tick();
|
|
483
|
+
return {
|
|
484
|
+
async stop() {
|
|
485
|
+
stopped = true;
|
|
486
|
+
clearTimer(timer);
|
|
487
|
+
await active;
|
|
488
|
+
},
|
|
489
|
+
get active() {
|
|
490
|
+
return !stopped;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
export {
|
|
495
|
+
startAgentHeartbeat,
|
|
496
|
+
observeAgentHost,
|
|
497
|
+
heartbeatAgentOnce
|
|
498
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Server } from 'node:net';
|
|
2
|
+
import { type AgentRelease, type StagedAgentRelease, type UpdateCommand, type UpdateCommandResult } from './agent-update';
|
|
3
|
+
export declare const AGENT_UPDATE_GROUP = "forgezero-update";
|
|
4
|
+
export declare const AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
5
|
+
export declare const AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update.json";
|
|
6
|
+
export type AgentUpdateRequest = {
|
|
7
|
+
op: 'apply';
|
|
8
|
+
target: 'compute' | 'metal';
|
|
9
|
+
currentVersion: string;
|
|
10
|
+
release: AgentRelease;
|
|
11
|
+
};
|
|
12
|
+
export type AgentUpdateResponse = {
|
|
13
|
+
ok: true;
|
|
14
|
+
status: 'staged';
|
|
15
|
+
version: string;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: {
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Prove a newly started Agent process answers, not merely that PID 1 holds its socket. */
|
|
24
|
+
export declare function probeAgentSocket(socketPath?: string, timeoutMs?: number): Promise<boolean>;
|
|
25
|
+
export declare function activateAgentRelease(staged: StagedAgentRelease, options?: {
|
|
26
|
+
target?: AgentUpdateRequest['target'];
|
|
27
|
+
run?: (input: UpdateCommand) => Promise<UpdateCommandResult>;
|
|
28
|
+
probe?: () => Promise<boolean>;
|
|
29
|
+
receiptPath?: string;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
}): Promise<{
|
|
32
|
+
ok: true;
|
|
33
|
+
version: string;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
rolledBack: boolean;
|
|
37
|
+
reason: string;
|
|
38
|
+
}>;
|
|
39
|
+
export declare function startAgentUpdateHelper(options?: {
|
|
40
|
+
socketPath?: string;
|
|
41
|
+
root?: string;
|
|
42
|
+
activate?: (staged: StagedAgentRelease, target: AgentUpdateRequest['target']) => Promise<unknown>;
|
|
43
|
+
setTimer?: (callback: () => void, ms: number) => unknown;
|
|
44
|
+
}): Server;
|
|
45
|
+
export declare function requestAgentUpdate(request: AgentUpdateRequest, socketPath?: string, timeoutMs?: number): Promise<AgentUpdateResponse>;
|