forgedock 1.32.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/pool/cli.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { startAgent } from "./agent.mjs";
|
|
10
|
+
import { startRouter } from "./router.mjs";
|
|
11
|
+
import { dockerCli } from "./docker.mjs";
|
|
12
|
+
import { readSecret } from "./store.mjs";
|
|
13
|
+
import { call as httpCall } from "./http.mjs";
|
|
14
|
+
|
|
15
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
const loadJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
17
|
+
|
|
18
|
+
/** The `forgedock pool` verbs (and the `computeshare` script). Throws on usage or router errors; `deps` injects the HTTP client and config. */
|
|
19
|
+
export async function runPoolCli(argv, deps = {}) {
|
|
20
|
+
const [cmd, ...rest] = argv;
|
|
21
|
+
const call = deps.call ?? httpCall;
|
|
22
|
+
const flag = (name, dflt) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : dflt; };
|
|
23
|
+
|
|
24
|
+
function routerConfig() {
|
|
25
|
+
const path = flag("config", process.env.COMPUTESHARE_CONFIG ?? join(homedir(), ".config/computeshare/router.json"));
|
|
26
|
+
const c = loadJson(path);
|
|
27
|
+
const base = dirname(path);
|
|
28
|
+
return { ...c, repo_root: c.repo_root ?? repoRoot, state_dir: c.state_dir ?? join(base, "state"), forgedock_token: readSecret(c.forgedock_token_file ?? join(base, "forgedock.token")), enroll_token: readSecret(c.enroll_token_file ?? join(base, "enroll.token")) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function agentConfig() {
|
|
32
|
+
const path = flag("config", process.env.COMPUTESHARE_CONFIG ?? "/etc/computeshare/agent.json");
|
|
33
|
+
const c = loadJson(path);
|
|
34
|
+
return { ...c, config_path: path, token: readSecret(c.token_file ?? join(dirname(path), "agent.token")) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
switch (cmd) {
|
|
38
|
+
case "router": {
|
|
39
|
+
if (rest[0] === "install") {
|
|
40
|
+
// The systemd --user unit (Restart=always), same as install-router.sh; the foreground form is its ExecStart.
|
|
41
|
+
const r = (deps.spawn ?? spawnSync)("bash", [join(repoRoot, "pool", "install", "install-router.sh")], { stdio: "inherit" });
|
|
42
|
+
if (r.status !== 0) throw new Error(`install-router.sh exited ${r.status}`);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const cfg = routerConfig();
|
|
46
|
+
(deps.startRouter ?? startRouter)(cfg);
|
|
47
|
+
console.log(`computeshare router on ${cfg.listen.host}:${cfg.listen.port} (public ${cfg.public_url})`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "agent": {
|
|
51
|
+
const cfg = agentConfig();
|
|
52
|
+
startAgent(cfg, { docker: dockerCli });
|
|
53
|
+
console.log(`computeshare agent ${cfg.name} on ${cfg.listen.host}:${cfg.listen.port}: ${cfg.allow_cpus} cpus, ${cfg.allow_mem_gb} GB reserved for lanes`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case "status": {
|
|
57
|
+
const cfg = routerConfig();
|
|
58
|
+
const r = await call(`http://${cfg.listen.host}:${cfg.listen.port}`, "GET", "/agents", { token: cfg.forgedock_token });
|
|
59
|
+
console.log(JSON.stringify(r.data, null, 2));
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "enroll":
|
|
63
|
+
case "install-command": {
|
|
64
|
+
// Prints a command carrying a one-time code (single use, expires in 30 min), never the long-lived enroll token.
|
|
65
|
+
const cfg = routerConfig();
|
|
66
|
+
const r = await call(`http://${cfg.listen.host}:${cfg.listen.port}`, "POST", "/codes", { token: cfg.forgedock_token });
|
|
67
|
+
if (!r.ok) throw new Error(`router refused: HTTP ${r.status}`);
|
|
68
|
+
const size = `${flag("cpus") ? `&cpus=${Number(flag("cpus"))}` : ""}${flag("mem") ? `&mem=${Number(flag("mem"))}` : ""}`;
|
|
69
|
+
console.log(`powershell -NoProfile -ExecutionPolicy Bypass -Command "irm '${cfg.public_url}/bootstrap/install.ps1?token=${r.data.code}${size}' | iex"`);
|
|
70
|
+
console.error(`(one-time code, expires ${r.data.expires_at})`);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "resize": {
|
|
74
|
+
// computeshare resize <agent> --cpus N --mem GB [--vm-cpus N --vm-mem GB]: change a host's lane allowance remotely.
|
|
75
|
+
const cfg = routerConfig();
|
|
76
|
+
const name = rest.find((x) => !x.startsWith("--") && !/^\d+$/.test(x));
|
|
77
|
+
const body = { cpus: Number(flag("cpus")), mem_gb: Number(flag("mem")), vm_cpus: Number(flag("vm-cpus", flag("cpus"))), vm_mem_gb: Number(flag("vm-mem", Number(flag("mem")) + 2)), drain: rest.includes("--drain"), ...(flag("vm-swap") ? { vm_swap_gb: Number(flag("vm-swap")) } : {}) };
|
|
78
|
+
const r = await call(`http://${cfg.listen.host}:${cfg.listen.port}`, "POST", `/agents/${encodeURIComponent(name)}/resize`, { body, token: cfg.forgedock_token, timeoutMs: 30000 });
|
|
79
|
+
console.log(`${name}: HTTP ${r.status} ${JSON.stringify(r.data)}`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "upgrade": {
|
|
83
|
+
// Push the current code (and lane image, when its recipe changed) to one agent or all of them.
|
|
84
|
+
const cfg = routerConfig();
|
|
85
|
+
const base = `http://${cfg.listen.host}:${cfg.listen.port}`;
|
|
86
|
+
const only = rest.find((x) => !x.startsWith("--") && x !== flag("config"));
|
|
87
|
+
const list = (await call(base, "GET", "/agents", { token: cfg.forgedock_token })).data?.agents ?? [];
|
|
88
|
+
for (const a of list.filter((x) => !only || x.name === only)) {
|
|
89
|
+
const r = await call(base, "POST", `/agents/${encodeURIComponent(a.name)}/upgrade`, { token: cfg.forgedock_token, timeoutMs: 30000 });
|
|
90
|
+
console.log(`${a.name}: HTTP ${r.status} ${JSON.stringify(r.data)}`);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "remove": {
|
|
95
|
+
const name = rest.find((x) => !x.startsWith("--") && x !== flag("config"));
|
|
96
|
+
if (!name) throw new Error("usage: forgedock pool remove <agent>");
|
|
97
|
+
const cfg = routerConfig();
|
|
98
|
+
const r = await call(`http://${cfg.listen.host}:${cfg.listen.port}`, "DELETE", `/agents/${encodeURIComponent(name)}`, { token: cfg.forgedock_token, timeoutMs: 30000 });
|
|
99
|
+
if (!r.ok) throw new Error(`${name}: HTTP ${r.status} ${JSON.stringify(r.data)}`);
|
|
100
|
+
console.log(`${name}: removed`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
throw new Error("usage: forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent> [--config path]");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
109
|
+
runPoolCli(process.argv.slice(2)).catch((err) => { console.error(err.message); process.exit(String(err.message).startsWith("usage:") ? 2 : 1); });
|
|
110
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
function docker(args, { timeoutMs = 120000 } = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
execFile("docker", args, { timeout: timeoutMs, maxBuffer: 16 << 20 }, (err, stdout, stderr) => {
|
|
8
|
+
if (err) { err.stderr = String(stderr ?? "").slice(0, 2000); reject(err); } else resolve(String(stdout).trim());
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The docker CLI operations the agent needs; swapped for a fake in tests. */
|
|
14
|
+
export const dockerCli = {
|
|
15
|
+
async run(args) { return docker(["run", "-d", ...args]); },
|
|
16
|
+
async inspect(name) {
|
|
17
|
+
try {
|
|
18
|
+
const out = await docker(["inspect", "--format", "{{json .State}}", name], { timeoutMs: 20000 });
|
|
19
|
+
const s = JSON.parse(out);
|
|
20
|
+
return { running: Boolean(s.Running), exitCode: s.ExitCode, startedAt: s.StartedAt, finishedAt: s.FinishedAt };
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (/No such (object|container)/i.test(e.stderr ?? "")) return null;
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async remove(name) { try { await docker(["rm", "-f", "-v", name], { timeoutMs: 60000 }); } catch (e) { if (!/No such container/i.test(e.stderr ?? "")) throw e; } },
|
|
27
|
+
async listLabelled(label) {
|
|
28
|
+
const out = await docker(["ps", "-a", "--filter", `label=${label}`, "--format", "{{.Names}}"], { timeoutMs: 20000 });
|
|
29
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
30
|
+
},
|
|
31
|
+
async removeVolume(name) { try { await docker(["volume", "rm", "-f", name], { timeoutMs: 120000 }); } catch (e) { if (!/no such volume/i.test(e.stderr ?? "")) throw e; } },
|
|
32
|
+
async listVolumes(prefix) {
|
|
33
|
+
const out = await docker(["volume", "ls", "-q", "--filter", `name=${prefix}`], { timeoutMs: 20000 });
|
|
34
|
+
return out ? out.split("\n").filter((v) => v.startsWith(prefix)) : [];
|
|
35
|
+
},
|
|
36
|
+
async pull(image) { return docker(["pull", "-q", image], { timeoutMs: 30 * 60000 }); },
|
|
37
|
+
async load(file) { return docker(["load", "-q", "-i", file], { timeoutMs: 30 * 60000 }); },
|
|
38
|
+
async hasImage(ref) { try { await docker(["image", "inspect", ref], { timeoutMs: 20000 }); return true; } catch { return false; } },
|
|
39
|
+
/** {name: {cpu (cores), mem_gb}} for running containers with the label. */
|
|
40
|
+
async stats(label) {
|
|
41
|
+
const names = await this.listLabelled(label);
|
|
42
|
+
if (!names.length) return {};
|
|
43
|
+
const out = await docker(["stats", "--no-stream", "--format", "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}", ...names], { timeoutMs: 60000 });
|
|
44
|
+
const unit = { b: 1 / 2 ** 30, kib: 1 / 2 ** 20, mib: 1 / 2 ** 10, gib: 1, kb: 1e3 / 2 ** 30, mb: 1e6 / 2 ** 30, gb: 1e9 / 2 ** 30 };
|
|
45
|
+
const res = {};
|
|
46
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
47
|
+
const [name, cpu, mem] = line.split("\t");
|
|
48
|
+
const m = /([\d.]+)\s*([a-zA-Z]+)/.exec(mem ?? "");
|
|
49
|
+
res[name] = { cpu: (parseFloat(cpu) || 0) / 100, mem_gb: m ? parseFloat(m[1]) * (unit[m[2].toLowerCase()] ?? 0) : 0 };
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
},
|
|
53
|
+
async build(dir, tag, buildArgs = {}) { return docker(["build", "-q", "-t", tag, ...Object.entries(buildArgs).flatMap(([k, v]) => ["--build-arg", `${k}=${v}`]), dir], { timeoutMs: 45 * 60000 }); },
|
|
54
|
+
/** Removes files the root-owned lane container wrote into a bind mount, using the lane image itself. */
|
|
55
|
+
async wipe(dir, image) { await docker(["run", "--rm", "--network", "none", "-v", `${dir}:/d`, "--entrypoint", "sh", image, "-c", "rm -rf /d/* /d/.[!.]* 2>/dev/null; true"], { timeoutMs: 120000 }); },
|
|
56
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export async function readJson(req, limit = 1 << 20) {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let size = 0;
|
|
8
|
+
for await (const c of req) {
|
|
9
|
+
size += c.length;
|
|
10
|
+
if (size > limit) throw Object.assign(new Error("body too large"), { status: 413 });
|
|
11
|
+
chunks.push(c);
|
|
12
|
+
}
|
|
13
|
+
if (!size) return null;
|
|
14
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw Object.assign(new Error("invalid json"), { status: 400 }); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function send(res, status, data, headers = {}) {
|
|
18
|
+
const body = data === undefined ? "" : typeof data === "string" ? data : JSON.stringify(data);
|
|
19
|
+
res.writeHead(status, { "content-type": typeof data === "string" ? "text/plain; charset=utf-8" : "application/json", ...headers });
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sameSecret(a, b) {
|
|
24
|
+
const x = Buffer.from(String(a ?? "")), y = Buffer.from(String(b ?? ""));
|
|
25
|
+
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the request carries `Authorization: Bearer <token>`. */
|
|
29
|
+
export function bearerOk(req, token) {
|
|
30
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
31
|
+
return Boolean(m) && sameSecret(m[1].trim(), token);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** JSON call to another computeshare process; never throws on HTTP status, only on transport failure. */
|
|
35
|
+
export async function call(base, method, path, { body, token, timeoutMs = 15000, fetchFn = globalThis.fetch } = {}) {
|
|
36
|
+
const res = await fetchFn(`${String(base).replace(/\/+$/, "")}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
39
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
40
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
41
|
+
});
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
let data = null;
|
|
44
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
45
|
+
return { status: res.status, ok: res.ok, data, retryAfter: res.headers.get("retry-after") };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function route(method, pattern) {
|
|
49
|
+
const re = new RegExp(`^${pattern.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`);
|
|
50
|
+
return (req, url) => {
|
|
51
|
+
if (req.method !== method) return null;
|
|
52
|
+
const m = re.exec(url.pathname);
|
|
53
|
+
return m ? Object.fromEntries(Object.entries(m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)])) : null;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { bearerOk, call, readJson, route, sameSecret, send } from "./http.mjs";
|
|
11
|
+
import { jsonStore } from "./store.mjs";
|
|
12
|
+
import { versionOf } from "./version.mjs";
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Router: the endpoint ForgeDock's remote provider talks to (POST /sandboxes, GET /sandboxes/:id[/logs|/result|/usage],
|
|
18
|
+
* DELETE /sandboxes/:id). It places each sandbox on the enrolled agent with the most free reserved CPU, forwards every
|
|
19
|
+
* later call to that agent, renews the agent lease while ForgeDock keeps polling, and destroys sandboxes ForgeDock
|
|
20
|
+
* has abandoned. It also serves the agent installer and enrollment.
|
|
21
|
+
*/
|
|
22
|
+
export function createRouter(cfg, { fetchFn = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
|
|
23
|
+
const store = jsonStore(join(cfg.state_dir, "router-state.json"), { agents: {}, sandboxes: {} });
|
|
24
|
+
const agents = () => store.data.agents;
|
|
25
|
+
const sbx = () => store.data.sandboxes;
|
|
26
|
+
const lane = { cpus: cfg.lane?.cpus ?? 2, mem_gb: cfg.lane?.mem_gb ?? 3 };
|
|
27
|
+
const abandonMs = (cfg.abandon_after_s ?? 1800) * 1000;
|
|
28
|
+
const codes = () => (store.data.codes ??= {});
|
|
29
|
+
// State maps are keyed by caller input (install codes, agent names, sandbox ids): read own keys only, so a key like
|
|
30
|
+
// "__proto__" or "constructor" never resolves to an inherited member.
|
|
31
|
+
const own = (map, key) => (typeof key === "string" && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
32
|
+
const codeTtlMs = (cfg.code_ttl_s ?? 1800) * 1000;
|
|
33
|
+
/** The long-lived enroll token, or an unexpired one-time install code (consumed by a successful enrollment). */
|
|
34
|
+
function enrollOk(token, consume = false) {
|
|
35
|
+
if (sameSecret(token, cfg.enroll_token)) return true;
|
|
36
|
+
const c = own(codes(), token);
|
|
37
|
+
if (!c || c.used || c.expires <= now()) return false;
|
|
38
|
+
if (consume) { c.used = true; store.save(); }
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function issueCode() {
|
|
42
|
+
const code = randomBytes(9).toString("base64url");
|
|
43
|
+
codes()[code] = { expires: now() + codeTtlMs, used: false };
|
|
44
|
+
store.save();
|
|
45
|
+
return { code, expires_at: new Date(now() + codeTtlMs).toISOString() };
|
|
46
|
+
}
|
|
47
|
+
/** The enrolled agent whose token the request carries, or null. */
|
|
48
|
+
function agentFor(req) {
|
|
49
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
return Object.entries(agents()).find(([, a]) => sameSecret(m[1].trim(), a.token))?.[0] ?? null;
|
|
52
|
+
}
|
|
53
|
+
// Fresh GitHub token for sandboxes, read from this host's gh (kept fresh by its own refresh timer).
|
|
54
|
+
let gh = { token: null, at: 0 };
|
|
55
|
+
async function githubToken() {
|
|
56
|
+
if (!gh.token || now() - gh.at > 60000) {
|
|
57
|
+
const { stdout } = await run(cfg.gh_bin ?? "gh", ["auth", "token"], { timeout: 20000 });
|
|
58
|
+
gh = { token: stdout.trim(), at: now() };
|
|
59
|
+
}
|
|
60
|
+
return gh.token;
|
|
61
|
+
}
|
|
62
|
+
// Claude access token for sandboxes: the logged-in account on this host with the most validity left. Only the
|
|
63
|
+
// short-lived access token is shared; refresh tokens stay here, so this host keeps refreshing its own sessions.
|
|
64
|
+
function claudeToken() {
|
|
65
|
+
const dirs = cfg.claude_config_dirs ?? readdirSync(homedir()).filter((d) => /^\.claude(-\d+)?$/.test(d)).map((d) => join(homedir(), d));
|
|
66
|
+
let best = null;
|
|
67
|
+
for (const d of dirs) {
|
|
68
|
+
try {
|
|
69
|
+
const o = JSON.parse(readFileSync(join(d, ".credentials.json"), "utf8")).claudeAiOauth;
|
|
70
|
+
if (o?.accessToken && o.expiresAt > now() + 30 * 60000 && (!best || o.expiresAt > best.expiresAt)) best = o;
|
|
71
|
+
} catch { /* no login in this dir */ }
|
|
72
|
+
}
|
|
73
|
+
if (!best) throw Object.assign(new Error("no Claude login on the router host with 30+ minutes left"), { status: 503 });
|
|
74
|
+
return { token: best.accessToken, expires_at: new Date(best.expiresAt).toISOString() };
|
|
75
|
+
}
|
|
76
|
+
const agentCall = (a, method, path, body, timeoutMs) => call(a.url, method, path, { body, token: a.token, timeoutMs, fetchFn });
|
|
77
|
+
|
|
78
|
+
async function capacities() {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const [name, a] of Object.entries(agents())) {
|
|
81
|
+
try {
|
|
82
|
+
const r = await agentCall(a, "GET", "/capacity", undefined, 5000);
|
|
83
|
+
out.push(r.ok ? { name, ok: true, ...r.data } : { name, ok: false, error: `HTTP ${r.status}` });
|
|
84
|
+
} catch (e) { out.push({ name, ok: false, error: String(e.message ?? e) }); }
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Agent liveness for the scheduler (#4547): an agent is online when it answers and is not paused. */
|
|
90
|
+
async function status() {
|
|
91
|
+
const caps = await capacities();
|
|
92
|
+
const agentsView = caps.map((c) => ({ name: c.name, ok: c.ok, paused: Boolean(c.paused) }));
|
|
93
|
+
return { online: agentsView.filter((a) => a.ok && !a.paused).length, total: agentsView.length, agents: agentsView };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Per-repo extras: the gitignored forge.yaml, and a private Docker daemon (with a bigger reservation) when enabled. */
|
|
97
|
+
const repoCfg = (spec) => cfg.repos?.[spec?.repo?.slug] ?? {};
|
|
98
|
+
// Placement counts the (small) reservation; `limit` is each container's hard burst cap (burst model, when configured).
|
|
99
|
+
const sizeFor = (spec) => {
|
|
100
|
+
const docker = Boolean(repoCfg(spec).docker), dind = cfg.dind ?? { cpus: 1, mem_gb: 2 };
|
|
101
|
+
const size = docker ? { cpus: lane.cpus + dind.cpus, mem_gb: lane.mem_gb + dind.mem_gb } : { cpus: lane.cpus, mem_gb: lane.mem_gb };
|
|
102
|
+
if (cfg.lane?.limit) {
|
|
103
|
+
size.limit = { lane_cpus: cfg.lane.limit.cpus, lane_mem_gb: cfg.lane.limit.mem_gb, lane_swap_gb: cfg.lane.limit.swap_gb ?? 0,
|
|
104
|
+
...(docker ? { dind_cpus: dind.limit?.cpus ?? 2, dind_mem_gb: dind.limit?.mem_gb ?? 3, dind_swap_gb: dind.limit?.swap_gb ?? 0 } : {}) };
|
|
105
|
+
}
|
|
106
|
+
return size;
|
|
107
|
+
};
|
|
108
|
+
function withForgeYaml(spec0) {
|
|
109
|
+
const spec = repoCfg(spec0).docker ? { ...spec0, docker: true } : spec0;
|
|
110
|
+
const path = repoCfg(spec).forge_yaml;
|
|
111
|
+
if (!path) return spec;
|
|
112
|
+
try { return { ...spec, env: { ...(spec.env ?? {}), CS_FORGE_YAML_B64: readFileSync(path).toString("base64") } }; }
|
|
113
|
+
catch (e) { log.warn?.(`forge.yaml for ${spec.repo.slug} unreadable: ${e.message}`); return spec; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function launch(spec) {
|
|
117
|
+
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id)) return { status: 400, body: { error: "spec.id required ([A-Za-z0-9._-], max 128)" } };
|
|
118
|
+
const known = own(sbx(), spec.id);
|
|
119
|
+
if (known && own(agents(), known.agent)) {
|
|
120
|
+
known.touched = now(); store.save();
|
|
121
|
+
const r = await agentCall(own(agents(), known.agent), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...sizeFor(spec) });
|
|
122
|
+
return { status: r.status, body: r.data, retryAfter: r.retryAfter };
|
|
123
|
+
}
|
|
124
|
+
const size = sizeFor(spec);
|
|
125
|
+
const fits = (await capacities()).filter((c) => c.ok && !c.paused && c.cpus_free >= size.cpus && c.mem_free_gb >= size.mem_gb)
|
|
126
|
+
.sort((a, b) => b.cpus_free - a.cpus_free || b.mem_free_gb - a.mem_free_gb);
|
|
127
|
+
for (const c of fits) {
|
|
128
|
+
const r = await agentCall(own(agents(), c.name), "POST", "/sandboxes", { spec: withForgeYaml(spec), ...size }, 120000);
|
|
129
|
+
if (r.status === 503) continue; // raced with another launch; try the next host
|
|
130
|
+
if (r.ok) { sbx()[spec.id] = { agent: c.name, issue: spec.issue ?? null, batch: spec.batch ?? null, createdAt: now(), touched: now() }; store.save(); }
|
|
131
|
+
return { status: r.status, body: { ...(r.data ?? {}), agent: c.name } };
|
|
132
|
+
}
|
|
133
|
+
return { status: 503, body: { error: "no host has a free reservation", lane: size }, retryAfter: cfg.retry_after_s ?? 60 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function forward(id, method, path) {
|
|
137
|
+
const s = own(sbx(), id);
|
|
138
|
+
const a = s && own(agents(), s.agent);
|
|
139
|
+
if (!a) return { status: 404, body: { state: "gone" } };
|
|
140
|
+
s.touched = now(); store.save();
|
|
141
|
+
try {
|
|
142
|
+
const r = await agentCall(a, method, path);
|
|
143
|
+
if (method === "DELETE" && (r.ok || r.status === 404)) { delete sbx()[id]; store.save(); }
|
|
144
|
+
return { status: r.status, body: r.data };
|
|
145
|
+
} catch (e) { return { status: 502, body: { error: `agent ${s.agent} unreachable: ${e.message}` } }; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Renew leases for sandboxes ForgeDock still polls; destroy the ones it abandoned. */
|
|
149
|
+
async function tick() {
|
|
150
|
+
for (const [k, c] of Object.entries(codes())) if (c.expires <= now()) delete codes()[k];
|
|
151
|
+
for (const [id, s] of Object.entries(sbx())) {
|
|
152
|
+
const a = own(agents(), s.agent);
|
|
153
|
+
if (!a) { delete sbx()[id]; continue; }
|
|
154
|
+
try {
|
|
155
|
+
if (now() - s.touched > abandonMs) {
|
|
156
|
+
const r = await agentCall(a, "DELETE", `/sandboxes/${encodeURIComponent(id)}`);
|
|
157
|
+
if (r.ok || r.status === 404) { delete sbx()[id]; log.info?.(`abandoned ${id} destroyed on ${s.agent}`); }
|
|
158
|
+
} else {
|
|
159
|
+
const r = await agentCall(a, "POST", `/sandboxes/${encodeURIComponent(id)}/lease`);
|
|
160
|
+
if (r.status === 404) { delete sbx()[id]; log.info?.(`${id} gone on ${s.agent}`); }
|
|
161
|
+
}
|
|
162
|
+
} catch (e) { log.warn?.(`lease ${id} on ${s.agent}: ${e.message}`); }
|
|
163
|
+
}
|
|
164
|
+
store.save();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function enroll(body) {
|
|
168
|
+
if (!enrollOk(body?.token)) return { status: 401, body: { error: "bad enrollment token" } };
|
|
169
|
+
const name = String(body.name ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
170
|
+
let url;
|
|
171
|
+
try { url = new URL(String(body.url)); } catch { return { status: 400, body: { error: "url required" } }; }
|
|
172
|
+
if (!name) return { status: 400, body: { error: "name required" } };
|
|
173
|
+
enrollOk(body.token, true);
|
|
174
|
+
const token = randomBytes(32).toString("hex");
|
|
175
|
+
agents()[name] = { url: url.origin, token, enrolledAt: new Date(now()).toISOString() };
|
|
176
|
+
store.save();
|
|
177
|
+
log.info?.(`enrolled agent ${name} at ${url.origin}`);
|
|
178
|
+
return { status: 200, body: { name, agent_token: token } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bootstrapScript(token, url) {
|
|
182
|
+
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
183
|
+
const int = (k) => { const v = Number(url?.searchParams.get(k)); return Number.isInteger(v) && v > 0 ? v : 0; };
|
|
184
|
+
const sizing = `${int("cpus") ? ` -AllowCpus ${int("cpus")}` : ""}${int("mem") ? ` -AllowMemGb ${int("mem")}` : ""}`;
|
|
185
|
+
return [
|
|
186
|
+
"$ErrorActionPreference = 'Stop'",
|
|
187
|
+
`$RouterUrl = ${q(cfg.public_url)}`,
|
|
188
|
+
`$EnrollToken = ${q(token)}`,
|
|
189
|
+
"$tmp = Join-Path $env:TEMP 'computeshare-install'",
|
|
190
|
+
"Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue",
|
|
191
|
+
"New-Item -ItemType Directory -Path $tmp | Out-Null",
|
|
192
|
+
"Invoke-WebRequest -UseBasicParsing \"$RouterUrl/bootstrap/bundle.tar.gz?token=$EnrollToken\" -OutFile (Join-Path $tmp 'bundle.tar.gz')",
|
|
193
|
+
"tar.exe -xzf (Join-Path $tmp 'bundle.tar.gz') -C $tmp",
|
|
194
|
+
`& (Join-Path $tmp 'pool\\install\\install-agent.ps1') -RouterUrl $RouterUrl -EnrollToken $EnrollToken -BundlePath (Join-Path $tmp 'bundle.tar.gz')${sizing}`,
|
|
195
|
+
"",
|
|
196
|
+
].join("\r\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stream an allow-listed image (docker save) to an agent whose daemon cannot reach the registry itself. */
|
|
200
|
+
async function sendImage(res, ref) {
|
|
201
|
+
const allowed = cfg.agent_images ?? ["docker:27.5.1-dind"];
|
|
202
|
+
if (!allowed.includes(ref)) return send(res, 404, { error: "image not offered" });
|
|
203
|
+
try { await run("docker", ["image", "inspect", ref], { timeout: 20000 }); }
|
|
204
|
+
catch { await run("docker", ["pull", "-q", ref], { timeout: 30 * 60000 }); }
|
|
205
|
+
const save = spawn("docker", ["save", ref]);
|
|
206
|
+
res.writeHead(200, { "content-type": "application/x-tar" });
|
|
207
|
+
save.stdout.pipe(res);
|
|
208
|
+
save.on("error", () => res.destroy());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sendBundle(res) {
|
|
212
|
+
const tar = spawn("tar", ["-czf", "-", "-C", cfg.repo_root, "package.json", "bin/pool", "pool"]);
|
|
213
|
+
res.writeHead(200, { "content-type": "application/gzip" });
|
|
214
|
+
tar.stdout.pipe(res);
|
|
215
|
+
tar.on("error", () => res.destroy());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fd = (fn) => ({ fn, auth: "forgedock" });
|
|
219
|
+
const routes = [
|
|
220
|
+
[route("GET", "/health"), { fn: async () => ({ status: 200, body: { ok: true, agents: Object.keys(agents()).length, sandboxes: Object.keys(sbx()).length } }), auth: "none" }],
|
|
221
|
+
[route("POST", "/sandboxes"), fd(async (_p, body) => launch(body))],
|
|
222
|
+
[route("GET", "/sandboxes/:id"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}`))],
|
|
223
|
+
[route("GET", "/sandboxes/:id/logs"), fd(async ({ id }, _b, url) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/logs?cursor=${encodeURIComponent(url.searchParams.get("cursor") ?? "0")}`))],
|
|
224
|
+
[route("GET", "/sandboxes/:id/result"), fd(async ({ id }) => forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/result`))],
|
|
225
|
+
[route("GET", "/sandboxes/:id/usage"), fd(async ({ id }) => { // #4549: the placing agent rides along for per-agent minutes
|
|
226
|
+
const agent = own(sbx(), id)?.agent, r = await forward(id, "GET", `/sandboxes/${encodeURIComponent(id)}/usage`);
|
|
227
|
+
return agent && r.status === 200 && r.body && typeof r.body === "object" ? { ...r, body: { ...r.body, agent } } : r;
|
|
228
|
+
})],
|
|
229
|
+
[route("DELETE", "/sandboxes/:id"), fd(async ({ id }) => forward(id, "DELETE", `/sandboxes/${encodeURIComponent(id)}`))],
|
|
230
|
+
[route("GET", "/status"), fd(async () => ({ status: 200, body: await status() }))],
|
|
231
|
+
[route("GET", "/agents"), fd(async () => ({ status: 200, body: { agents: await capacities(), sandboxes: sbx() } }))],
|
|
232
|
+
[route("POST", "/codes"), fd(async () => ({ status: 200, body: issueCode() }))],
|
|
233
|
+
[route("GET", "/agent/creds/github"), { fn: async () => ({ status: 200, body: { token: await githubToken() } }), auth: "agent" }],
|
|
234
|
+
[route("GET", "/agent/creds/claude"), { fn: async () => ({ status: 200, body: claudeToken() }), auth: "agent" }],
|
|
235
|
+
[route("GET", "/agent/bundle"), { fn: async () => ({ bundle: true }), auth: "agent" }],
|
|
236
|
+
[route("GET", "/agent/images/:ref"), { fn: async ({ ref }) => ({ image: ref }), auth: "agent" }],
|
|
237
|
+
[route("POST", "/agents/:name/upgrade"), fd(async ({ name }) => {
|
|
238
|
+
const a = own(agents(), name);
|
|
239
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
240
|
+
const r = await agentCall(a, "POST", "/admin/update", {});
|
|
241
|
+
return { status: r.status, body: { ...(r.data ?? {}), target: versionOf(cfg.repo_root) } };
|
|
242
|
+
})],
|
|
243
|
+
[route("DELETE", "/agents/:name"), fd(async ({ name }) => {
|
|
244
|
+
if (!own(agents(), name)) return { status: 404, body: { error: "unknown agent" } };
|
|
245
|
+
for (const [id, x] of Object.entries(sbx())) if (x.agent === name) delete sbx()[id];
|
|
246
|
+
delete agents()[name]; store.save();
|
|
247
|
+
return { status: 200, body: { ok: true } };
|
|
248
|
+
})],
|
|
249
|
+
[route("POST", "/agents/:name/resize"), fd(async ({ name }, body) => {
|
|
250
|
+
const a = own(agents(), name);
|
|
251
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
252
|
+
const r = await agentCall(a, "POST", "/admin/resize", body ?? {});
|
|
253
|
+
return { status: r.status, body: r.data };
|
|
254
|
+
})],
|
|
255
|
+
[route("GET", "/agents/:name/usage"), fd(async ({ name }) => {
|
|
256
|
+
const a = own(agents(), name);
|
|
257
|
+
if (!a) return { status: 404, body: { error: "unknown agent" } };
|
|
258
|
+
const r = await agentCall(a, "GET", "/usage-history");
|
|
259
|
+
return { status: r.status, body: r.data };
|
|
260
|
+
})],
|
|
261
|
+
[route("GET", "/version"), fd(async () => ({ status: 200, body: versionOf(cfg.repo_root) }))],
|
|
262
|
+
[route("POST", "/enroll"), { fn: async (_p, body) => enroll(body), auth: "none" }],
|
|
263
|
+
[route("GET", "/bootstrap/install.ps1"), { fn: async (_p, _b, url) => ({ status: 200, body: bootstrapScript(url.searchParams.get("token"), url) }), auth: "enroll" }],
|
|
264
|
+
[route("GET", "/bootstrap/bundle.tar.gz"), { fn: async () => ({ bundle: true }), auth: "enroll" }],
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
async function handle(req, res) {
|
|
268
|
+
const url = new URL(req.url, "http://router");
|
|
269
|
+
if (!url.pathname.startsWith("/health")) log.info?.(`${req.socket.remoteAddress} ${req.method} ${url.pathname}`); // query (tokens) never logged
|
|
270
|
+
try {
|
|
271
|
+
for (const [match, r] of routes) {
|
|
272
|
+
const p = match(req, url);
|
|
273
|
+
if (!p) continue;
|
|
274
|
+
if (r.auth === "forgedock" && !bearerOk(req, cfg.forgedock_token)) return send(res, 401, { error: "unauthorized" });
|
|
275
|
+
if (r.auth === "agent" && !agentFor(req)) return send(res, 401, { error: "unauthorized" });
|
|
276
|
+
if (r.auth === "enroll" && !enrollOk(url.searchParams.get("token"))) return send(res, 401, "unauthorized\n");
|
|
277
|
+
const body = req.method === "POST" ? await readJson(req) : null;
|
|
278
|
+
const out = await r.fn(p, body, url);
|
|
279
|
+
if (out.bundle) return sendBundle(res);
|
|
280
|
+
if (out.image) return sendImage(res, out.image);
|
|
281
|
+
return send(res, out.status, out.body, out.retryAfter ? { "retry-after": String(out.retryAfter) } : {});
|
|
282
|
+
}
|
|
283
|
+
send(res, 404, { error: "not found" });
|
|
284
|
+
} catch (e) {
|
|
285
|
+
log.error?.(e);
|
|
286
|
+
send(res, e.status ?? 500, { error: e.status ? e.message : "internal error" });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { handle, launch, tick, enroll, issueCode, capacities, status, store };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function startRouter(cfg, deps) {
|
|
294
|
+
const router = createRouter(cfg, deps);
|
|
295
|
+
const server = createServer(router.handle);
|
|
296
|
+
server.listen(cfg.listen.port, cfg.listen.host);
|
|
297
|
+
const timer = setInterval(() => router.tick().catch((e) => console.error("tick failed", e)), (cfg.lease_interval_s ?? 60) * 1000);
|
|
298
|
+
return { router, server, close: () => { clearInterval(timer); server.close(); } };
|
|
299
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Small JSON state file written atomically (temp file + rename), so a crash never leaves it half-written. */
|
|
7
|
+
export function jsonStore(path, initial) {
|
|
8
|
+
let data;
|
|
9
|
+
try { data = JSON.parse(readFileSync(path, "utf8")); } catch { data = structuredClone(initial); }
|
|
10
|
+
return {
|
|
11
|
+
get data() { return data; },
|
|
12
|
+
save() {
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
14
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
15
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
16
|
+
renameSync(tmp, path);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readSecret(path) {
|
|
22
|
+
const v = readFileSync(path, "utf8").trim();
|
|
23
|
+
if (!v) throw new Error(`empty secret file ${path}`);
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
|
|
11
|
+
/** Content hash of a directory's files (sorted, non-recursive is enough for pool/image/ and bin/pool/). */
|
|
12
|
+
function dirHash(dir) {
|
|
13
|
+
const h = createHash("sha256");
|
|
14
|
+
for (const f of readdirSync(dir).sort()) {
|
|
15
|
+
try { h.update(f); h.update(readFileSync(join(dir, f))); } catch { /* subdirectory */ }
|
|
16
|
+
}
|
|
17
|
+
return h.digest("hex").slice(0, 12);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What an agent is running: package version, code hash and lane-image recipe hash. Agents and router compare these. */
|
|
21
|
+
export function versionOf(root = repoRoot) {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
23
|
+
return { version: pkg.version, code: dirHash(join(root, "bin", "pool")), image: dirHash(join(root, "pool", "image")) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The lane image is tagged and built from the ForgeDock package version (no separate pin). */
|
|
27
|
+
export const laneImageTag = (version) => `forgedock-lane:${version}`;
|
|
28
|
+
export const laneBuildArgs = (version) => ({ FORGEDOCK_VERSION: version });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `forgedock update` hook: push the new code and lane image to every enrolled agent. A no-op on hosts without a
|
|
32
|
+
* pool router config; never throws (a failed push must not fail the update). Returns "skipped" | "upgraded" | "failed".
|
|
33
|
+
*/
|
|
34
|
+
export async function upgradeAgentsAfterUpdate({ configPath = process.env.COMPUTESHARE_CONFIG ?? join(homedir(), ".config/computeshare/router.json"), runPoolCli, warn = console.warn } = {}) {
|
|
35
|
+
if (!existsSync(configPath)) return "skipped";
|
|
36
|
+
try {
|
|
37
|
+
const run = runPoolCli ?? (await import("./cli.mjs")).runPoolCli;
|
|
38
|
+
await run(["upgrade", "--config", configPath]);
|
|
39
|
+
return "upgraded";
|
|
40
|
+
} catch (e) {
|
|
41
|
+
warn(`pool upgrade after update failed: ${e.message}`);
|
|
42
|
+
return "failed";
|
|
43
|
+
}
|
|
44
|
+
}
|
package/bin/runner.mjs
CHANGED
|
@@ -1809,6 +1809,7 @@ export function runCliBackend({
|
|
|
1809
1809
|
` Invocation: ${bin} ${argvSummary} (cwd: ${cwd})`,
|
|
1810
1810
|
);
|
|
1811
1811
|
err.code = "CLI_BACKEND_FAILED";
|
|
1812
|
+
err.status = result.status ?? null; err.signal = result.signal ?? null; // #4518: lets bin/lean/hostkill.mjs tell an external kill
|
|
1812
1813
|
// forge#2241: attach the CLI's reported session-limit reset time (when
|
|
1813
1814
|
// present) so callers (bin/engine.mjs) can surface *when* a
|
|
1814
1815
|
// quota-exhaustion failure will clear without reading raw logs. Only
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -295,6 +295,12 @@ export function computeAlerts(lanes = [], opts = {}) {
|
|
|
295
295
|
const ex = failed.flatMap((l) => l.deps).find((d) => (d.eco ?? "unknown") === eco && d.status === "failed");
|
|
296
296
|
add(`DEPS_PREPARED failed (${eco})`, failed.length, used.length, c.depsFailedPct / 100, ex?.reason ?? null);
|
|
297
297
|
}
|
|
298
|
+
// #4522: a broken snapshot falls back to a fresh install; the lane has dependencies, but the cache still needs a look.
|
|
299
|
+
const fell = used.filter((l) => l.deps.some((d) => (d.eco ?? "unknown") === eco && d.status === "materialize-fallback"));
|
|
300
|
+
if (fell.length / used.length > c.depsFailedPct / 100) {
|
|
301
|
+
const ex = fell.flatMap((l) => l.deps).find((d) => (d.eco ?? "unknown") === eco && d.status === "materialize-fallback");
|
|
302
|
+
add(`DEPS_PREPARED materialize-fallback (${eco})`, fell.length, used.length, c.depsFailedPct / 100, ex?.reason ?? null);
|
|
303
|
+
}
|
|
298
304
|
}
|
|
299
305
|
const deferred = win.filter((l) => l.deferred.length);
|
|
300
306
|
if (deferred.length / win.length > c.deferredPct / 100) add("deferred-to-ci", deferred.length, win.length, c.deferredPct / 100, deferred[deferred.length - 1].deferred[0]);
|