forgedock 1.32.1 → 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 +3 -3
- package/bin/engine-cli.mjs +56 -11
- 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.test.mjs +103 -2
- 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 +1 -1
- package/bin/lean/execution.mjs +102 -12
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +49 -21
- package/bin/lean/lean.test.mjs +133 -7
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +63 -17
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- 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
|
@@ -0,0 +1,435 @@
|
|
|
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 { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
import { cpus, hostname } from "node:os";
|
|
10
|
+
import { bearerOk, call, readJson, route, sameSecret, send } from "./http.mjs";
|
|
11
|
+
import { repoRoot, versionOf, laneImageTag, laneBuildArgs } from "./version.mjs";
|
|
12
|
+
import { jsonStore } from "./store.mjs";
|
|
13
|
+
|
|
14
|
+
const LABEL = "computeshare=1";
|
|
15
|
+
const LOG_CHUNK = 256 * 1024;
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
/** What this machine/VM actually has (on WSL: the VM caps from .wslconfig). */
|
|
19
|
+
export function vmSize() {
|
|
20
|
+
let mem = 0, swap = 0;
|
|
21
|
+
try {
|
|
22
|
+
const mi = readFileSync("/proc/meminfo", "utf8");
|
|
23
|
+
mem = Number(/MemTotal:\s+(\d+)/.exec(mi)[1]) / 1048576;
|
|
24
|
+
swap = Number(/SwapTotal:\s+(\d+)/.exec(mi)?.[1] ?? 0) / 1048576;
|
|
25
|
+
} catch { /* not linux */ }
|
|
26
|
+
return { cpus: cpus().length, mem_gb: Math.round(mem * 10) / 10, swap_gb: Math.round(swap * 10) / 10 };
|
|
27
|
+
}
|
|
28
|
+
/** Swap a container may use beyond its RAM cap: never more than the VM actually has. */
|
|
29
|
+
const swapFor = (gb) => { const want = Number(gb) > 0 ? Number(gb) : 0; const have = vmSize().swap_gb; return have ? Math.min(want, have) : 0; };
|
|
30
|
+
const mb = (gb) => `${Math.max(64, Math.round(gb * 1024))}m`;
|
|
31
|
+
/** A burst cap can never exceed the machine/VM itself. */
|
|
32
|
+
function clampToVm(cpusWanted, memWanted) {
|
|
33
|
+
const vm = vmSize();
|
|
34
|
+
return { cpus: vm.cpus ? Math.min(cpusWanted, vm.cpus) : cpusWanted, mem: vm.mem_gb ? Math.min(memWanted, Math.max(1, vm.mem_gb - 1)) : memWanted };
|
|
35
|
+
}
|
|
36
|
+
/** Live headroom: MemAvailable and 1-min load per core, so overlapping bursts slow down instead of OOM-killing lanes. */
|
|
37
|
+
export function liveHeadroom(cfg = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const mi = readFileSync("/proc/meminfo", "utf8");
|
|
40
|
+
const total = Number(/MemTotal:\s+(\d+)/.exec(mi)[1]) / 1048576, avail = Number(/MemAvailable:\s+(\d+)/.exec(mi)[1]) / 1048576;
|
|
41
|
+
const load1 = Number(readFileSync("/proc/loadavg", "utf8").split(" ")[0]), cores = cpus().length || 1;
|
|
42
|
+
const minFree = cfg.min_free_mem_gb ?? Math.max(2, total * 0.1), maxLoad = cfg.max_load_per_core ?? 2;
|
|
43
|
+
const r = { mem_available_gb: Math.round(avail * 10) / 10, load_per_core: Math.round((load1 / cores) * 100) / 100 };
|
|
44
|
+
if (avail < minFree) return { ok: false, reason: `only ${r.mem_available_gb} GB available (< ${minFree.toFixed(1)})`, ...r };
|
|
45
|
+
if (load1 / cores > maxLoad) return { ok: false, reason: `load ${r.load_per_core}/core (> ${maxLoad})`, ...r };
|
|
46
|
+
return { ok: true, ...r };
|
|
47
|
+
} catch { return { ok: true }; }
|
|
48
|
+
}
|
|
49
|
+
export const dindName = (name) => `${name}-dind`;
|
|
50
|
+
export const workVolume = (name) => `cs-work-${name.replace(/^cs-/, "")}`;
|
|
51
|
+
export const safeName = (id) => `cs-${String(id).toLowerCase().replace(/[^a-z0-9_.-]/g, "-").slice(0, 100)}`;
|
|
52
|
+
const envLine = (k, v) => `${k}=${String(v).replace(/\r?\n/g, " ")}`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Host agent: runs each sandbox as one docker container inside a fixed reservation (allow_cpus / allow_mem_gb).
|
|
56
|
+
* A sandbox lives only while its lease is renewed; the reaper removes expired, over-age and orphaned containers.
|
|
57
|
+
*/
|
|
58
|
+
export function createAgent(cfg, { docker, now = () => Date.now(), log = console, live = liveHeadroom } = {}) {
|
|
59
|
+
const stateDir = cfg.state_dir;
|
|
60
|
+
const store = jsonStore(join(stateDir, "state.json"), { sandboxes: {} });
|
|
61
|
+
const sb = () => store.data.sandboxes;
|
|
62
|
+
// Sandbox ids come from requests: read own keys only, so "__proto__" or "constructor" never resolves to an inherited member (#4566).
|
|
63
|
+
const own = (id) => (typeof id === "string" && Object.hasOwn(sb(), id) ? sb()[id] : undefined);
|
|
64
|
+
const leaseTtlMs = (cfg.lease_ttl_s ?? 600) * 1000;
|
|
65
|
+
const maxLifeMs = (cfg.max_lifetime_s ?? 6 * 3600) * 1000;
|
|
66
|
+
const keepExitedMs = (cfg.keep_exited_s ?? 2 * 3600) * 1000;
|
|
67
|
+
const running = versionOf(cfg.code_root ?? repoRoot);
|
|
68
|
+
// The lane image tracks the installed ForgeDock version; only an explicit non-legacy cfg.image overrides it.
|
|
69
|
+
const image = cfg.image && cfg.image !== "computeshare-lane:1" ? cfg.image : laneImageTag(running.version);
|
|
70
|
+
const upgrade = { state: "idle", error: null };
|
|
71
|
+
const prepull = { error: null };
|
|
72
|
+
const dindImage = cfg.dind_image ?? "docker:27.5.1-dind";
|
|
73
|
+
/** Images a sandbox may need are pulled ahead of time, never inside a launch request (which has a short timeout). */
|
|
74
|
+
async function prepullImages() {
|
|
75
|
+
if (await docker.hasImage?.(dindImage)) { prepull.error = null; return; }
|
|
76
|
+
try { await docker.pull(dindImage); prepull.error = null; return; }
|
|
77
|
+
catch (e) { log.warn?.(`pull ${dindImage} failed (${String(e.stderr || e.message).slice(0, 160)}); fetching it from the router`); }
|
|
78
|
+
// Fallback: the router host pulls the image and streams it (some hosts' daemons cannot reach the registry).
|
|
79
|
+
const file = join(stateDir, "image.tar");
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`${String(cfg.router_url).replace(/\/+$/, "")}/agent/images/${encodeURIComponent(dindImage)}`, { headers: { authorization: `Bearer ${cfg.token}` }, signal: AbortSignal.timeout(30 * 60000) });
|
|
82
|
+
if (!res.ok) throw new Error(`router image: HTTP ${res.status}`);
|
|
83
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
84
|
+
await docker.load(file);
|
|
85
|
+
prepull.error = null;
|
|
86
|
+
log.info?.(`loaded ${dindImage} from the router`);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
prepull.error = String(e.stderr || e.message).slice(0, 300);
|
|
89
|
+
log.warn?.(`prepull ${dindImage}: ${prepull.error}`);
|
|
90
|
+
} finally { rmSync(file, { force: true }); }
|
|
91
|
+
}
|
|
92
|
+
const used = () => Object.values(sb()).filter((s) => s.state !== "exited").reduce((a, s) => ({ cpus: a.cpus + s.cpus, mem: a.mem + s.mem_gb }), { cpus: 0, mem: 0 });
|
|
93
|
+
function capacity() {
|
|
94
|
+
const u = used();
|
|
95
|
+
return { host: cfg.name ?? hostname(), ...running, vm: vmSize(), ...(pendingResize ? { pending_resize: { cpus: pendingResize.cpus, mem_gb: pendingResize.mem_gb } } : {}), upgrading: upgrade.state, ...(upgrade.error ? { upgrade_error: upgrade.error } : {}), ...(prepull.error ? { prepull_error: prepull.error } : {}), cpus_total: cfg.allow_cpus, mem_total_gb: cfg.allow_mem_gb, cpus_free: cfg.allow_cpus - u.cpus, mem_free_gb: cfg.allow_mem_gb - u.mem, active: Object.values(sb()).filter((s) => s.state !== "exited").length, paused: Boolean(cfg.paused), live: live(cfg), usage: Object.fromEntries(Object.values(sb()).filter((r) => r.usage?.samples).map((r) => [r.issue ?? r.id, { ...usageView(r), state: r.state }])) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function launch({ spec, cpus, mem_gb, limit = null }) {
|
|
99
|
+
if (typeof spec?.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(spec.id) || !Array.isArray(spec.command) || !spec.command.length) throw Object.assign(new Error("spec.id ([A-Za-z0-9._-], max 128) and spec.command are required"), { status: 400 });
|
|
100
|
+
const existing = own(spec.id);
|
|
101
|
+
if (existing) { existing.lease = now(); store.save(); return { status: 200, body: view(existing) }; }
|
|
102
|
+
const c = capacity();
|
|
103
|
+
if (c.paused || c.cpus_free < cpus || c.mem_free_gb < mem_gb) return { status: 503, body: { error: "no capacity", capacity: c }, retryAfter: 60 };
|
|
104
|
+
// Burst model: reservations are small, so also refuse when the VM is genuinely short of memory or overloaded right now.
|
|
105
|
+
const h = live(cfg);
|
|
106
|
+
if (!h.ok) return { status: 503, body: { error: `host busy: ${h.reason}`, live: h }, retryAfter: 60 };
|
|
107
|
+
const name = safeName(spec.id);
|
|
108
|
+
const dir = join(stateDir, "sandboxes", name);
|
|
109
|
+
mkdirSync(join(dir, "result"), { recursive: true });
|
|
110
|
+
mkdirSync(join(dir, "out"), { recursive: true });
|
|
111
|
+
const envFile = join(dir, "env");
|
|
112
|
+
const credToken = randomBytes(24).toString("hex");
|
|
113
|
+
const withDocker = Boolean(spec.docker);
|
|
114
|
+
const env = { ...(spec.env ?? {}), ...(withDocker ? { DOCKER_HOST: "tcp://127.0.0.1:2375" } : {}), CS_CRED_BASE: `http://host.docker.internal:${cfg.listen.port}/sandboxes/${encodeURIComponent(spec.id)}/creds`, CS_CRED_TOKEN: credToken, CS_SANDBOX_ID: spec.id, CS_REPO_SLUG: spec.repo?.slug ?? "", CS_REPO_URL: spec.repo?.url ?? "", CS_REPO_REF: spec.repo?.ref ?? "", CS_RESULT_FILE: spec.result_file ?? "" };
|
|
115
|
+
writeFileSync(envFile, Object.entries(env).map(([k, v]) => envLine(k, v)).join("\n") + "\n", { mode: 0o600 });
|
|
116
|
+
const rec = { id: spec.id, name, dir, issue: spec.issue ?? null, batch: spec.batch ?? null, cpus, mem_gb, result_file: spec.result_file ?? null, state: "starting", startedAt: now(), finishedAt: null, lease: now(), credToken, docker: withDocker };
|
|
117
|
+
sb()[spec.id] = rec;
|
|
118
|
+
store.save();
|
|
119
|
+
const cacheKey = String(spec.cache?.key ?? spec.repo?.slug ?? "default").toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
|
|
120
|
+
// Public resolvers: host resolvers like Tailscale MagicDNS are not reachable from the container network.
|
|
121
|
+
const netArgs = ["--add-host", "host.docker.internal:host-gateway", ...(cfg.dns ?? ["1.1.1.1", "8.8.8.8"]).flatMap((d) => ["--dns", String(d)])];
|
|
122
|
+
let laneCpus = cpus, laneMem = mem_gb, laneNet = netArgs, work = [];
|
|
123
|
+
try {
|
|
124
|
+
if (withDocker) {
|
|
125
|
+
// Private Docker daemon per sandbox (never the host socket). The lane joins its network namespace, so ports the
|
|
126
|
+
// project's compose stack publishes are on the lane's localhost, and the API listens on loopback only. Both share
|
|
127
|
+
// /work, so compose bind mounts of the checkout resolve inside the daemon too.
|
|
128
|
+
let dCpus, dMem;
|
|
129
|
+
if (limit) { dCpus = limit.dind_cpus; dMem = limit.dind_mem_gb; laneCpus = limit.lane_cpus; laneMem = limit.lane_mem_gb; }
|
|
130
|
+
else { dCpus = Math.min(cfg.dind_cpus ?? 1, cpus - 1); dMem = Math.min(cfg.dind_mem_gb ?? 2, mem_gb - 1); laneCpus = cpus - dCpus; laneMem = mem_gb - dMem; }
|
|
131
|
+
({ cpus: dCpus, mem: dMem } = clampToVm(dCpus, dMem));
|
|
132
|
+
work = ["-v", `${workVolume(name)}:/work`];
|
|
133
|
+
await docker.run([
|
|
134
|
+
"--name", dindName(name), "--label", LABEL, "--label", `computeshare.id=${spec.id}`, "--label", "computeshare.role=dind",
|
|
135
|
+
"--privileged", "--cpus", String(dCpus), "--memory", mb(dMem), "--memory-swap", mb(dMem + swapFor(limit?.dind_swap_gb)),
|
|
136
|
+
...netArgs, ...work,
|
|
137
|
+
// Bypass dockerd-entrypoint: it adds tcp://0.0.0.0:2375 (reachable from other sandboxes on the bridge).
|
|
138
|
+
"--entrypoint", "dind", dindImage, "dockerd", "--host=tcp://127.0.0.1:2375", "--tls=false",
|
|
139
|
+
]);
|
|
140
|
+
laneNet = ["--network", `container:${dindName(name)}`];
|
|
141
|
+
}
|
|
142
|
+
if (limit && !withDocker) { laneCpus = limit.lane_cpus; laneMem = limit.lane_mem_gb; }
|
|
143
|
+
({ cpus: laneCpus, mem: laneMem } = clampToVm(laneCpus, laneMem));
|
|
144
|
+
await docker.run([
|
|
145
|
+
"--name", name, "--label", LABEL, "--label", `computeshare.id=${spec.id}`,
|
|
146
|
+
// Hard burst cap per container; the (smaller) reservation is only what placement counts.
|
|
147
|
+
// --memory-swap above --memory lets a rare spike page out instead of being OOM-killed (swap deal).
|
|
148
|
+
"--cpus", String(laneCpus), "--memory", mb(laneMem), "--memory-swap", mb(laneMem + swapFor(limit?.lane_swap_gb)), "--pids-limit", "4096",
|
|
149
|
+
"--env-file", envFile, ...laneNet, ...work,
|
|
150
|
+
"-v", `cs-cache-${cacheKey}:/cache`, "-v", `${join(dir, "result")}:/result`, "-v", `${join(dir, "out")}:/csout`,
|
|
151
|
+
spec.image ?? image, ...spec.command.map(String),
|
|
152
|
+
]);
|
|
153
|
+
rec.state = "running";
|
|
154
|
+
} catch (e) {
|
|
155
|
+
rec.state = "exited";
|
|
156
|
+
rec.finishedAt = now();
|
|
157
|
+
rec.error = String(e.stderr || e.message).slice(0, 500);
|
|
158
|
+
log.error?.(`launch ${spec.id} failed: ${rec.error}`);
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(envFile, { force: true }); // secrets live only in the container's env
|
|
161
|
+
store.save();
|
|
162
|
+
}
|
|
163
|
+
return { status: 201, body: view(rec) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const view = (r) => ({ id: r.id, state: r.state, issue: r.issue, cpus: r.cpus, mem_gb: r.mem_gb, started_at: new Date(r.startedAt).toISOString(), ...(r.error ? { error: r.error } : {}) });
|
|
167
|
+
|
|
168
|
+
async function refresh(r) {
|
|
169
|
+
if (r.state === "exited") return r;
|
|
170
|
+
const s = await docker.inspect(r.name);
|
|
171
|
+
if (!s) { r.state = "exited"; r.finishedAt ??= now(); }
|
|
172
|
+
else if (!s.running && r.state !== "starting") { r.state = "exited"; r.exitCode = s.exitCode; r.finishedAt = Date.parse(s.finishedAt) || now(); }
|
|
173
|
+
else if (s.running) r.state = "running";
|
|
174
|
+
return r;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readLog(r, cursor) {
|
|
178
|
+
const file = join(r.dir, "out", "lane.log");
|
|
179
|
+
if (!existsSync(file)) return { text: "", cursor };
|
|
180
|
+
const size = statSync(file).size;
|
|
181
|
+
const from = Math.max(0, Math.min(Number(cursor) || 0, size));
|
|
182
|
+
const len = Math.min(LOG_CHUNK, size - from);
|
|
183
|
+
if (len <= 0) return { text: "", cursor: from };
|
|
184
|
+
const buf = Buffer.alloc(len);
|
|
185
|
+
const fd = openSync(file, "r");
|
|
186
|
+
try { readSync(fd, buf, 0, len, from); } finally { closeSync(fd); }
|
|
187
|
+
return { text: buf.toString("utf8"), cursor: from + len };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readResult(r) {
|
|
191
|
+
if (!r.result_file) return null;
|
|
192
|
+
try { return JSON.parse(readFileSync(join(r.dir, "result", basename(r.result_file)), "utf8")); } catch { return null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function destroy(id, why) {
|
|
196
|
+
const r = own(id);
|
|
197
|
+
if (!r) return false;
|
|
198
|
+
const history = r.usage?.samples ? JSON.stringify({ at: new Date(now()).toISOString(), id: r.id, issue: r.issue, docker: Boolean(r.docker), minutes: Math.round((((r.finishedAt ?? now()) - r.startedAt) / 60000) * 10) / 10, ...usageView(r) }) : null;
|
|
199
|
+
await docker.remove(r.name);
|
|
200
|
+
if (r.docker) { await docker.remove(dindName(r.name)); await docker.removeVolume(workVolume(r.name)); }
|
|
201
|
+
try { await docker.wipe(r.dir, image); } catch (e) { log.warn?.(`wipe ${r.name}: ${String(e.stderr || e.message).slice(0, 200)}`); }
|
|
202
|
+
rmSync(r.dir, { recursive: true, force: true });
|
|
203
|
+
delete sb()[id];
|
|
204
|
+
store.save();
|
|
205
|
+
// Written once, after the sandbox is really gone (a teardown that throws and is retried must not log it twice).
|
|
206
|
+
if (history) { try { appendFileSync(join(stateDir, "usage-history.jsonl"), history + "\n"); } catch { /* best effort */ } }
|
|
207
|
+
log.info?.(`destroyed ${id} (${why})`);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function reap() {
|
|
212
|
+
const t = now();
|
|
213
|
+
for (const r of Object.values(sb())) {
|
|
214
|
+
try { await refresh(r); } catch { /* docker hiccup: judge by lease only */ }
|
|
215
|
+
const why = t - r.lease > leaseTtlMs ? "lease expired" : t - r.startedAt > maxLifeMs ? "max lifetime" : r.state === "exited" && r.finishedAt && t - r.finishedAt > keepExitedMs ? "exited retention" : null;
|
|
216
|
+
if (why) await destroy(r.id, why);
|
|
217
|
+
}
|
|
218
|
+
const known = new Set(Object.values(sb()).flatMap((r) => [r.name, dindName(r.name)]));
|
|
219
|
+
for (const name of await docker.listLabelled(LABEL)) if (!known.has(name)) { await docker.remove(name); log.info?.(`removed orphan container ${name}`); }
|
|
220
|
+
await sampleUsage();
|
|
221
|
+
if (pendingResize && capacity().active === 0) {
|
|
222
|
+
const r = await resize(pendingResize);
|
|
223
|
+
log.info?.(`pending resize applied: HTTP ${r.status} ${JSON.stringify(r.body)}`);
|
|
224
|
+
}
|
|
225
|
+
const knownVols = new Set(Object.values(sb()).map((r) => workVolume(r.name)));
|
|
226
|
+
for (const v of await docker.listVolumes("cs-work-")) if (!knownVols.has(v)) { await docker.removeVolume(v); log.info?.(`removed orphan volume ${v}`); }
|
|
227
|
+
store.save();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// GitHub credentials for a running sandbox: fetched from the router on demand (installation tokens expire hourly).
|
|
231
|
+
const credCache = {};
|
|
232
|
+
const CRED_TTL = { github: 120000, claude: 300000 };
|
|
233
|
+
async function sandboxCred(id, kind, req) {
|
|
234
|
+
const r = own(id);
|
|
235
|
+
const m = /^Bearer\s+(\S.*)$/i.exec(req.headers.authorization ?? "");
|
|
236
|
+
if (!r || r.state === "exited" || !m || !sameSecret(m[1].trim(), r.credToken)) return { status: 401, body: { error: "unauthorized" } };
|
|
237
|
+
if (!CRED_TTL[kind]) return { status: 404, body: { error: "unknown credential" } };
|
|
238
|
+
const c = credCache[kind];
|
|
239
|
+
if (!c || now() - c.at > CRED_TTL[kind]) {
|
|
240
|
+
const x = await call(cfg.router_url, "GET", `/agent/creds/${kind}`, { token: cfg.token, timeoutMs: 20000 });
|
|
241
|
+
if (!x.ok || !x.data?.token) return { status: 502, body: { error: `router credentials (${kind}): HTTP ${x.status}` } };
|
|
242
|
+
credCache[kind] = { token: x.data.token, at: now() };
|
|
243
|
+
}
|
|
244
|
+
return { status: 200, body: { token: credCache[kind].token } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Pull the current bundle from the router, rebuild the lane image if its recipe changed, then restart (systemd). */
|
|
248
|
+
async function selfUpdate() {
|
|
249
|
+
if (upgrade.state === "running") return;
|
|
250
|
+
upgrade.state = "running"; upgrade.error = null;
|
|
251
|
+
const root = cfg.code_root ?? repoRoot;
|
|
252
|
+
const next = `${root}.new`, tgz = join(stateDir, "bundle.tar.gz");
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(`${String(cfg.router_url).replace(/\/+$/, "")}/agent/bundle`, { headers: { authorization: `Bearer ${cfg.token}` }, signal: AbortSignal.timeout(300000) });
|
|
255
|
+
if (!res.ok) throw new Error(`bundle download: HTTP ${res.status}`);
|
|
256
|
+
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
|
|
257
|
+
rmSync(next, { recursive: true, force: true }); mkdirSync(next, { recursive: true });
|
|
258
|
+
await run("tar", ["-xzf", tgz, "-C", next]);
|
|
259
|
+
const want = versionOf(next);
|
|
260
|
+
if (want.image !== running.image || want.version !== running.version) {
|
|
261
|
+
const nextImage = cfg.image && cfg.image !== "computeshare-lane:1" ? cfg.image : laneImageTag(want.version);
|
|
262
|
+
log.info?.(`rebuilding ${nextImage} (${running.version}/${running.image} -> ${want.version}/${want.image})`);
|
|
263
|
+
await docker.build(join(next, "pool", "image"), nextImage, laneBuildArgs(want.version));
|
|
264
|
+
}
|
|
265
|
+
await prepullImages();
|
|
266
|
+
rmSync(`${root}.old`, { recursive: true, force: true });
|
|
267
|
+
await run("mv", [root, `${root}.old`]);
|
|
268
|
+
await run("mv", [next, root]);
|
|
269
|
+
log.info?.(`updated to ${JSON.stringify(want)}; restarting`);
|
|
270
|
+
upgrade.state = "restarting";
|
|
271
|
+
setTimeout(() => process.exit(0), 500);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
upgrade.state = "failed"; upgrade.error = String(e.stderr || e.message).slice(0, 500);
|
|
274
|
+
log.error?.(`self-update failed: ${upgrade.error}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Change this host's lane allowance. On WSL also rewrite the Windows-side .wslconfig VM caps (processors/memory) and
|
|
280
|
+
* restart WSL (the keep-alive task brings the distro back); only while no sandbox is active.
|
|
281
|
+
*/
|
|
282
|
+
/** Peak and average CPU (cores) and memory (GB) per sandbox, lane + docker sidecar, against what it reserved. */
|
|
283
|
+
async function sampleUsage() {
|
|
284
|
+
let st;
|
|
285
|
+
try { st = await docker.stats?.(LABEL); } catch { return; }
|
|
286
|
+
if (!st) return;
|
|
287
|
+
for (const r of Object.values(sb())) {
|
|
288
|
+
if (r.state === "exited") continue;
|
|
289
|
+
const parts = [st[r.name], r.docker ? st[dindName(r.name)] : null].filter(Boolean);
|
|
290
|
+
if (!parts.length) continue;
|
|
291
|
+
const cpu = parts.reduce((a, x) => a + x.cpu, 0), mem = parts.reduce((a, x) => a + x.mem_gb, 0);
|
|
292
|
+
const u = (r.usage ??= { samples: 0, cpu_sum: 0, cpu_peak: 0, mem_sum: 0, mem_peak: 0 });
|
|
293
|
+
u.samples++; u.cpu_sum += cpu; u.mem_sum += mem; u.cpu_peak = Math.max(u.cpu_peak, cpu); u.mem_peak = Math.max(u.mem_peak, mem);
|
|
294
|
+
}
|
|
295
|
+
store.save();
|
|
296
|
+
}
|
|
297
|
+
const usageView = (r) => {
|
|
298
|
+
const u = r.usage; const f = (x) => Math.round(x * 100) / 100;
|
|
299
|
+
return u?.samples ? { reserved: { cpus: r.cpus, mem_gb: r.mem_gb }, cpu_avg: f(u.cpu_sum / u.samples), cpu_peak: f(u.cpu_peak), mem_avg_gb: f(u.mem_sum / u.samples), mem_peak_gb: f(u.mem_peak), samples: u.samples } : null;
|
|
300
|
+
};
|
|
301
|
+
let pendingResize = null;
|
|
302
|
+
async function resize(body) {
|
|
303
|
+
const int = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi ? v : null);
|
|
304
|
+
const cpus = int(body.cpus, 1, 256), mem = int(body.mem_gb, 1, 1024), vmCpus = int(body.vm_cpus ?? body.cpus, 1, 256), vmMem = int(body.vm_mem_gb ?? (body.mem_gb + 2), 2, 1024);
|
|
305
|
+
if (!cpus || !mem || !vmCpus || !vmMem || vmCpus < cpus || vmMem <= mem) return { status: 400, body: { error: "need integers cpus, mem_gb (and vm_cpus >= cpus, vm_mem_gb > mem_gb)" } };
|
|
306
|
+
if (capacity().active > 0) {
|
|
307
|
+
if (!body.drain) return { status: 409, body: { error: "sandboxes are running; retry when idle, or pass drain: true", active: capacity().active } };
|
|
308
|
+
// Drain: take no new sandboxes, let the running ones finish, then apply (the reaper loop checks).
|
|
309
|
+
pendingResize = { ...body, drain: false };
|
|
310
|
+
cfg.paused = true;
|
|
311
|
+
log.info?.(`resize pending: draining ${capacity().active} sandbox(es) first`);
|
|
312
|
+
return { status: 202, body: { ok: true, draining: capacity().active, pending: { cpus, mem_gb: mem, vm_cpus: vmCpus, vm_mem_gb: vmMem } } };
|
|
313
|
+
}
|
|
314
|
+
pendingResize = null;
|
|
315
|
+
cfg.paused = false;
|
|
316
|
+
let wsl = null;
|
|
317
|
+
let release = ""; try { release = readFileSync("/proc/sys/kernel/osrelease", "utf8"); } catch { /* not linux */ }
|
|
318
|
+
if (/microsoft/i.test(release)) {
|
|
319
|
+
const users = (() => { try { return readdirSync("/mnt/c/Users"); } catch { return []; } })();
|
|
320
|
+
const file = users.map((u) => join("/mnt/c/Users", u, ".wslconfig")).find((f) => existsSync(`${f}.computeshare-backup`) || (existsSync(f) && /vmIdleTimeout/i.test(readFileSync(f, "utf8"))));
|
|
321
|
+
if (!file) return { status: 500, body: { error: "could not find the .wslconfig the installer manages" } };
|
|
322
|
+
const want = { processors: String(vmCpus), memory: `${vmMem}GB`, ...(int(body.vm_swap_gb, 0, 512) !== null ? { swap: `${body.vm_swap_gb}GB` } : {}) };
|
|
323
|
+
const out = []; let inWsl2 = false; const seen = {};
|
|
324
|
+
for (const l of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
325
|
+
const sec = /^\s*\[(.+)\]\s*$/.exec(l);
|
|
326
|
+
if (sec) { if (inWsl2) for (const k of Object.keys(want)) if (!seen[k]) { out.push(`${k}=${want[k]}`); seen[k] = true; } inWsl2 = sec[1].trim().toLowerCase() === "wsl2"; out.push(l); continue; }
|
|
327
|
+
const kv = inWsl2 && /^\s*([A-Za-z]+)\s*=/.exec(l);
|
|
328
|
+
const k = kv && Object.keys(want).find((x) => x.toLowerCase() === kv[1].toLowerCase());
|
|
329
|
+
if (k) { out.push(`${k}=${want[k]}`); seen[k] = true; continue; }
|
|
330
|
+
out.push(l);
|
|
331
|
+
}
|
|
332
|
+
if (inWsl2) for (const k of Object.keys(want)) if (!seen[k]) out.push(`${k}=${want[k]}`);
|
|
333
|
+
writeFileSync(file, out.join("\r\n"));
|
|
334
|
+
wsl = file;
|
|
335
|
+
}
|
|
336
|
+
if (cfg.config_path) {
|
|
337
|
+
const c = JSON.parse(readFileSync(cfg.config_path, "utf8"));
|
|
338
|
+
c.allow_cpus = cpus; c.allow_mem_gb = mem;
|
|
339
|
+
writeFileSync(cfg.config_path, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
340
|
+
}
|
|
341
|
+
cfg.allow_cpus = cpus; cfg.allow_mem_gb = mem;
|
|
342
|
+
log.info?.(`resized: lanes ${cpus} CPU / ${mem} GB${wsl ? `; ${wsl} -> ${vmCpus} CPU / ${vmMem}GB, restarting WSL` : ""}`);
|
|
343
|
+
if (wsl) {
|
|
344
|
+
// systemd services lack WSL_INTEROP; point it at a live interop socket so wsl.exe can run.
|
|
345
|
+
const sock = (() => { try { return readdirSync("/run/WSL").filter((f) => f.endsWith("_interop")).map((f) => join("/run/WSL", f)).sort().pop(); } catch { return null; } })();
|
|
346
|
+
setTimeout(() => {
|
|
347
|
+
const child = spawn("/mnt/c/Windows/System32/wsl.exe", ["--shutdown"], { detached: true, stdio: "ignore", env: { ...process.env, ...(sock ? { WSL_INTEROP: sock } : {}) } });
|
|
348
|
+
child.unref();
|
|
349
|
+
}, 1500);
|
|
350
|
+
}
|
|
351
|
+
return { status: 202, body: { ok: true, lanes: { cpus, mem_gb: mem }, ...(wsl ? { vm: { cpus: vmCpus, mem_gb: vmMem }, wsl_restart: true } : {}) } };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const routes = [
|
|
355
|
+
[route("GET", "/health"), async () => ({ status: 200, body: { ok: true, ...capacity() } })],
|
|
356
|
+
[route("GET", "/capacity"), async () => ({ status: 200, body: capacity() })],
|
|
357
|
+
[route("POST", "/sandboxes"), async (_p, body) => launch(body ?? {})],
|
|
358
|
+
[route("GET", "/sandboxes/:id"), async ({ id }) => { const r = own(id); if (!r) return { status: 404, body: { state: "gone" } }; await refresh(r); store.save(); return { status: 200, body: view(r) }; }],
|
|
359
|
+
[route("GET", "/sandboxes/:id/logs"), async ({ id }, _b, url) => { const r = own(id); return r ? { status: 200, body: readLog(r, url.searchParams.get("cursor")) } : { status: 404, body: { text: "", cursor: 0 } }; }],
|
|
360
|
+
[route("GET", "/sandboxes/:id/result"), async ({ id }) => { const r = own(id); const x = r && readResult(r); return x ? { status: 200, body: x } : { status: 404, body: { error: "no result" } }; }],
|
|
361
|
+
[route("GET", "/sandboxes/:id/usage"), async ({ id }) => { const r = own(id); return r ? { status: 200, body: { minutes: Math.round((((r.finishedAt ?? now()) - r.startedAt) / 60000) * 10) / 10 } } : { status: 404, body: {} }; }],
|
|
362
|
+
[route("POST", "/sandboxes/:id/lease"), async ({ id }) => { const r = own(id); if (!r) return { status: 404, body: { state: "gone" } }; r.lease = now(); store.save(); return { status: 200, body: { ok: true } }; }],
|
|
363
|
+
[route("GET", "/usage-history"), async () => {
|
|
364
|
+
let rows = [];
|
|
365
|
+
try { rows = readFileSync(join(stateDir, "usage-history.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch { /* none yet */ }
|
|
366
|
+
return { status: 200, body: { rows: rows.slice(-500) } };
|
|
367
|
+
}],
|
|
368
|
+
[route("POST", "/admin/resize"), async (_p, body) => resize(body ?? {})],
|
|
369
|
+
[route("POST", "/admin/update"), async () => { selfUpdate(); return { status: 202, body: { ok: true, from: running } }; }],
|
|
370
|
+
[route("DELETE", "/sandboxes/:id"), async ({ id }) => ((await destroy(id, "requested")) ? { status: 200, body: { ok: true } } : { status: 404, body: { state: "gone" } })],
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
async function handle(req, res) {
|
|
374
|
+
const url = new URL(req.url, "http://agent");
|
|
375
|
+
try {
|
|
376
|
+
const cred = req.method === "GET" && /^\/sandboxes\/([^/]+)\/creds\/([a-z]+)$/.exec(url.pathname);
|
|
377
|
+
if (cred) { const r = await sandboxCred(decodeURIComponent(cred[1]), cred[2], req); return send(res, r.status, r.body); }
|
|
378
|
+
if (url.pathname !== "/health" && !bearerOk(req, cfg.token)) return send(res, 401, { error: "unauthorized" });
|
|
379
|
+
for (const [match, fn] of routes) {
|
|
380
|
+
const p = match(req, url);
|
|
381
|
+
if (!p) continue;
|
|
382
|
+
const body = req.method === "POST" ? await readJson(req) : null;
|
|
383
|
+
const r = await fn(p, body, url);
|
|
384
|
+
return send(res, r.status, r.body, r.retryAfter ? { "retry-after": String(r.retryAfter) } : {});
|
|
385
|
+
}
|
|
386
|
+
send(res, 404, { error: "not found" });
|
|
387
|
+
} catch (e) {
|
|
388
|
+
log.error?.(e);
|
|
389
|
+
send(res, e.status ?? 500, { error: e.status ? e.message : "internal error" });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Build the lane image for the running ForgeDock version when this host does not have it yet. */
|
|
394
|
+
async function ensureImage() {
|
|
395
|
+
if (await docker.hasImage?.(image)) return;
|
|
396
|
+
log.info?.(`building ${image}`);
|
|
397
|
+
await docker.build(join(cfg.code_root ?? repoRoot, "pool", "image"), image, laneBuildArgs(running.version));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { handle, launch, reap, destroy, capacity, refresh, store, prepullImages, ensureImage };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* WSL with mirrored networking often has no IPv6 default route, yet dockerd still dials registries over IPv6 and fails
|
|
405
|
+
* ("network is unreachable"). Turn IPv6 off inside the distro (persistently) and restart docker while it is idle.
|
|
406
|
+
*/
|
|
407
|
+
export async function wslNetworkFixup({ run: runCmd = run, log = console, idle = () => true } = {}) {
|
|
408
|
+
let release = "";
|
|
409
|
+
try { release = readFileSync("/proc/sys/kernel/osrelease", "utf8"); } catch { return "not linux"; }
|
|
410
|
+
if (!/microsoft/i.test(release)) return "not wsl";
|
|
411
|
+
try {
|
|
412
|
+
const { stdout } = await runCmd("ip", ["-6", "route", "show", "default"]);
|
|
413
|
+
if (String(stdout).trim()) return "ipv6 route present";
|
|
414
|
+
} catch { /* no ip tool or no route: treat as missing */ }
|
|
415
|
+
const conf = "net.ipv6.conf.all.disable_ipv6=1\nnet.ipv6.conf.default.disable_ipv6=1\n";
|
|
416
|
+
try { if (readFileSync("/etc/sysctl.d/90-computeshare-noipv6.conf", "utf8") === conf) return "already applied"; } catch { /* first time */ }
|
|
417
|
+
writeFileSync("/etc/sysctl.d/90-computeshare-noipv6.conf", conf);
|
|
418
|
+
await runCmd("sysctl", ["-p", "/etc/sysctl.d/90-computeshare-noipv6.conf"]);
|
|
419
|
+
if (idle()) await runCmd("systemctl", ["restart", "docker"], { timeout: 120000 });
|
|
420
|
+
log.info?.("WSL: IPv6 disabled in the distro (no IPv6 route); docker restarted for IPv4 registry pulls");
|
|
421
|
+
return "applied";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function startAgent(cfg, deps) {
|
|
425
|
+
const agent = createAgent(cfg, deps);
|
|
426
|
+
const server = createServer(agent.handle);
|
|
427
|
+
server.listen(cfg.listen.port, cfg.listen.host);
|
|
428
|
+
const timer = setInterval(() => agent.reap().catch((e) => console.error("reap failed", e)), (cfg.reap_interval_s ?? 30) * 1000);
|
|
429
|
+
agent.reap().catch((e) => console.error("startup reap failed", e));
|
|
430
|
+
agent.ensureImage().catch((e) => console.error("lane image build failed", e));
|
|
431
|
+
wslNetworkFixup({ idle: () => agent.capacity().active === 0 })
|
|
432
|
+
.catch((e) => console.error("wsl network fixup failed", e))
|
|
433
|
+
.finally(() => agent.prepullImages());
|
|
434
|
+
return { agent, server, close: () => { clearInterval(timer); server.close(); } };
|
|
435
|
+
}
|
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
|
+
};
|