@forgezero/agent 0.1.41 → 0.1.43
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 +299 -86
- package/dist/agent-heartbeat.js +6 -3
- package/dist/agent-update-helper.js +5 -2
- package/dist/agent-update.js +5 -2
- package/dist/bootstrap.d.ts +17 -8
- package/dist/bootstrap.js +1509 -512
- package/dist/cli/agent-install.d.ts +6 -5
- package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
- package/dist/cli/maintenance.d.ts +23 -0
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/session-store.d.ts +5 -0
- package/dist/cloudflare-bootstrap.d.ts +73 -35
- package/dist/cloudflare-bootstrap.js +587 -90
- package/dist/cloudflare-edge.d.ts +64 -12
- package/dist/cloudflare-edge.js +103 -8
- package/dist/community-rehearsal-host.d.ts +51 -0
- package/dist/community-rehearsal-host.js +272 -0
- package/dist/credential-schema.d.ts +54 -0
- package/dist/credential-schema.js +47 -0
- package/dist/definition.d.ts +31 -5
- package/dist/definition.js +271 -44
- package/dist/deploy-file.js +294 -68
- package/dist/deployment-runner.js +18 -5
- package/dist/deployment.d.ts +13 -1
- package/dist/fz-agent.js +3932 -582
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +3636 -1263
- package/dist/git-ssh.d.ts +5 -0
- package/dist/guest-enrolment.d.ts +2 -0
- package/dist/guest-enrolment.js +1 -0
- package/dist/host-maintenance.d.ts +39 -0
- package/dist/host-maintenance.js +135 -0
- package/dist/index.d.ts +4 -2
- package/dist/mesh-connector.d.ts +16 -0
- package/dist/mesh-connector.js +46 -0
- package/dist/metal-bootstrap.js +150 -7
- package/dist/metal-helper-socket.js +61 -31
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +62 -32
- package/dist/operator-bootstrap.d.ts +90 -0
- package/dist/operator-bootstrap.js +5709 -0
- package/dist/otel-collector.d.ts +18 -0
- package/dist/pipeline.d.ts +3 -2
- package/dist/pipeline.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +39 -21
- package/dist/platform-bootstrap-runtime.js +182 -59
- package/dist/platform-fleet-verification.d.ts +19 -0
- package/dist/platform-fleet-verification.js +3873 -0
- package/dist/platform-genesis-config.d.ts +7 -0
- package/dist/platform-genesis.d.ts +17 -0
- package/dist/provision.d.ts +76 -3
- package/dist/provision.js +1061 -229
- package/dist/recovery-host.d.ts +7 -0
- package/dist/recovery-host.js +124 -0
- package/dist/service-supervisor.d.ts +42 -0
- package/dist/software-helper.d.ts +4 -0
- package/dist/software-helper.js +865 -63
- package/dist/software.d.ts +14 -3
- package/dist/software.js +163 -37
- package/dist/ssh-bootstrap.d.ts +97 -0
- package/dist/supervised-app.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/package.json +175 -164
- package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
package/dist/software-helper.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
// src/software.ts
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
copyFileSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
3
18
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
4
|
-
var
|
|
19
|
+
var BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f";
|
|
5
20
|
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
6
21
|
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
7
22
|
var OS_CATALOG = [
|
|
@@ -12,41 +27,151 @@ var SOFTWARE_CATALOG = [
|
|
|
12
27
|
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
13
28
|
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
14
29
|
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
30
|
+
{ id: "cloudflare-warp", version: "2026.6.822.0-min", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
15
31
|
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
16
32
|
{ id: "openssh-client", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
17
33
|
];
|
|
18
34
|
var UBUNTU_2604_X64 = [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
|
|
26
|
-
check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
|
|
27
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
requirement: { id: "arangodb", version: "3.11.14" },
|
|
31
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
|
|
32
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install; ` + `systemctl disable --now arangodb3.service`
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
36
|
-
check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
|
|
37
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
41
|
-
check: "command -v ufw >/dev/null",
|
|
42
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
requirement: { id: "openssh-client", version: "ubuntu-26.04" },
|
|
46
|
-
check: "command -v ssh >/dev/null && command -v scp >/dev/null && command -v ssh-keyscan >/dev/null && command -v ssh-keygen >/dev/null",
|
|
47
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
|
|
48
|
-
}
|
|
35
|
+
{ requirement: { id: "bun", version: "1.3.14" } },
|
|
36
|
+
{ requirement: { id: "nginx", version: "ubuntu-26.04" } },
|
|
37
|
+
{ requirement: { id: "arangodb", version: "3.11.14" } },
|
|
38
|
+
{ requirement: { id: "cloudflared", version: "2026.7.3" } },
|
|
39
|
+
{ requirement: { id: "cloudflare-warp", version: "2026.6.822.0-min" } },
|
|
40
|
+
{ requirement: { id: "ufw", version: "ubuntu-26.04" } },
|
|
41
|
+
{ requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
|
|
49
42
|
];
|
|
43
|
+
var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
44
|
+
var run = async (argv, env = {}) => {
|
|
45
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: path, LANG: "C", LC_ALL: "C", ...env } });
|
|
46
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
47
|
+
new Response(child.stdout).text(),
|
|
48
|
+
new Response(child.stderr).text(),
|
|
49
|
+
child.exited
|
|
50
|
+
]);
|
|
51
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
52
|
+
};
|
|
53
|
+
var download = async (url, destination, sha256) => {
|
|
54
|
+
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120000) });
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new Error(`download failed with HTTP ${response.status}`);
|
|
57
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
58
|
+
if (createHash("sha256").update(bytes).digest("hex") !== sha256)
|
|
59
|
+
throw new Error("download checksum mismatch");
|
|
60
|
+
writeFileSync(destination, bytes, { mode: 384, flag: "wx" });
|
|
61
|
+
};
|
|
62
|
+
var successful = (result, pattern) => result.exitCode === 0 && (!pattern || pattern.test(result.output));
|
|
63
|
+
var aptInstall = async (name) => {
|
|
64
|
+
const environment = { DEBIAN_FRONTEND: "noninteractive" };
|
|
65
|
+
const update = await run(["/usr/bin/apt-get", "update", "-qq"], environment);
|
|
66
|
+
return update.exitCode === 0 ? run(["/usr/bin/apt-get", "install", "-y", name], environment) : update;
|
|
67
|
+
};
|
|
68
|
+
async function executeSoftwareOperation(operation) {
|
|
69
|
+
const { software, version } = operation;
|
|
70
|
+
if (!UBUNTU_2604_X64.some(({ requirement }) => requirement.id === software && requirement.version === version)) {
|
|
71
|
+
return { exitCode: 2, output: "unsupported software operation" };
|
|
72
|
+
}
|
|
73
|
+
if (operation.kind === "check") {
|
|
74
|
+
if (software === "bun")
|
|
75
|
+
return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
|
|
76
|
+
if (software === "nginx") {
|
|
77
|
+
const binary = await run(["/usr/sbin/nginx", "-v"]);
|
|
78
|
+
return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
|
|
79
|
+
}
|
|
80
|
+
if (software === "arangodb") {
|
|
81
|
+
const binary = await run(["/usr/bin/arangod", "--version"]);
|
|
82
|
+
if (!successful(binary, /3\.11\.14/))
|
|
83
|
+
return { ...binary, exitCode: 1 };
|
|
84
|
+
const [active, enabled] = await Promise.all([
|
|
85
|
+
run(["/usr/bin/systemctl", "is-active", "--quiet", "arangodb3.service"]),
|
|
86
|
+
run(["/usr/bin/systemctl", "is-enabled", "--quiet", "arangodb3.service"])
|
|
87
|
+
]);
|
|
88
|
+
return active.exitCode !== 0 && enabled.exitCode !== 0 ? { exitCode: 0, output: binary.output } : { exitCode: 1, output: "vendor standalone unit remains active or enabled" };
|
|
89
|
+
}
|
|
90
|
+
if (software === "cloudflared")
|
|
91
|
+
return run(["/usr/local/bin/cloudflared", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /2026\.7\.3/) ? 0 : 1 }));
|
|
92
|
+
if (software === "cloudflare-warp")
|
|
93
|
+
return run(["/usr/bin/warp-cli", "--version"]).then((result) => {
|
|
94
|
+
const match = result.output.match(/(\d{4})\.(\d+)\.(\d+)\.(\d+)/);
|
|
95
|
+
const observed = match?.slice(1).map(Number);
|
|
96
|
+
const minimum = [2026, 6, 822, 0];
|
|
97
|
+
const supported = observed && observed.some((part, index) => part > minimum[index] && observed.slice(0, index).every((prior, priorIndex) => prior === minimum[priorIndex])) || observed?.every((part, index) => part === minimum[index]);
|
|
98
|
+
return { ...result, exitCode: result.exitCode === 0 && supported ? 0 : 1 };
|
|
99
|
+
});
|
|
100
|
+
const binaries = software === "ufw" ? ["/usr/sbin/ufw"] : ["/usr/bin/ssh", "/usr/bin/scp", "/usr/bin/ssh-keyscan", "/usr/bin/ssh-keygen"];
|
|
101
|
+
try {
|
|
102
|
+
binaries.forEach((binary) => accessSync(binary));
|
|
103
|
+
return { exitCode: 0, output: "" };
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (software === "nginx" || software === "ufw" || software === "openssh-client") {
|
|
109
|
+
const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
|
|
110
|
+
if (installed.exitCode !== 0 || software !== "nginx")
|
|
111
|
+
return installed;
|
|
112
|
+
return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
|
|
113
|
+
}
|
|
114
|
+
const directory = mkdtempSync(join(tmpdir(), "forgezero-software-"));
|
|
115
|
+
try {
|
|
116
|
+
if (software === "cloudflare-warp") {
|
|
117
|
+
const key = join(directory, "cloudflare-warp-key.gpg");
|
|
118
|
+
await download("https://pkg.cloudflareclient.com/pubkey.gpg", key, "0f37fc298c98e88ee3c0ee68c95b69f1dba9eb477abe3167e13982105911264d");
|
|
119
|
+
mkdirSync("/usr/share/keyrings", { recursive: true, mode: 493 });
|
|
120
|
+
const dearmored = await run([
|
|
121
|
+
"/usr/bin/gpg",
|
|
122
|
+
"--batch",
|
|
123
|
+
"--yes",
|
|
124
|
+
"--dearmor",
|
|
125
|
+
"-o",
|
|
126
|
+
"/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg",
|
|
127
|
+
key
|
|
128
|
+
]);
|
|
129
|
+
if (dearmored.exitCode !== 0)
|
|
130
|
+
return dearmored;
|
|
131
|
+
mkdirSync("/etc/apt/sources.list.d", { recursive: true, mode: 493 });
|
|
132
|
+
writeFileSync("/etc/apt/sources.list.d/cloudflare-client.list", `deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ resolute main
|
|
133
|
+
`, { mode: 420 });
|
|
134
|
+
return aptInstall("cloudflare-warp");
|
|
135
|
+
}
|
|
136
|
+
if (software === "bun") {
|
|
137
|
+
const archive = join(directory, "bun.zip");
|
|
138
|
+
await download("https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip", archive, BUN_RELEASE_SHA256);
|
|
139
|
+
const unpacked = join(directory, "unpacked");
|
|
140
|
+
mkdirSync(unpacked, { mode: 448 });
|
|
141
|
+
const unzipped = await run(["/usr/bin/unzip", "-q", archive, "-d", unpacked]);
|
|
142
|
+
if (unzipped.exitCode !== 0)
|
|
143
|
+
return unzipped;
|
|
144
|
+
mkdirSync("/usr/local/lib/forgezero/runtime", { recursive: true, mode: 493 });
|
|
145
|
+
copyFileSync(join(unpacked, "bun-linux-x64", "bun"), "/usr/local/lib/forgezero/runtime/bun.next");
|
|
146
|
+
chmodSync("/usr/local/lib/forgezero/runtime/bun.next", 493);
|
|
147
|
+
renameSync("/usr/local/lib/forgezero/runtime/bun.next", "/usr/local/lib/forgezero/runtime/bun");
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync("/usr/local/bin/bun");
|
|
150
|
+
} catch {}
|
|
151
|
+
symlinkSync("/usr/local/lib/forgezero/runtime/bun", "/usr/local/bin/bun");
|
|
152
|
+
return { exitCode: 0, output: "" };
|
|
153
|
+
}
|
|
154
|
+
if (software === "cloudflared") {
|
|
155
|
+
const binary = join(directory, "cloudflared");
|
|
156
|
+
await download("https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64", binary, CLOUDFLARED_SHA256);
|
|
157
|
+
chmodSync(binary, 493);
|
|
158
|
+
copyFileSync(binary, "/usr/local/bin/cloudflared");
|
|
159
|
+
chmodSync("/usr/local/bin/cloudflared", 493);
|
|
160
|
+
return { exitCode: 0, output: "" };
|
|
161
|
+
}
|
|
162
|
+
const deb = join(directory, "arangodb.deb");
|
|
163
|
+
await download("https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb", deb, ARANGO_SHA256);
|
|
164
|
+
let installed = await run(["/usr/bin/dpkg", "-i", deb], { DEBIAN_FRONTEND: "noninteractive" });
|
|
165
|
+
if (installed.exitCode !== 0)
|
|
166
|
+
installed = await run(["/usr/bin/apt-get", "-y", "-f", "install"], { DEBIAN_FRONTEND: "noninteractive" });
|
|
167
|
+
if (installed.exitCode !== 0)
|
|
168
|
+
return installed;
|
|
169
|
+
await run(["/usr/bin/systemctl", "disable", "--now", "arangodb3.service"]);
|
|
170
|
+
return { exitCode: 0, output: "" };
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(directory, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
50
175
|
function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
51
176
|
const values = Object.fromEntries(osRelease.split(`
|
|
52
177
|
`).flatMap((line) => {
|
|
@@ -69,7 +194,7 @@ function validateSoftwareRequirements(value, _options = {}) {
|
|
|
69
194
|
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
70
195
|
throw new Error("software requirement contains an unknown field");
|
|
71
196
|
}
|
|
72
|
-
if (!["bun", "nginx", "arangodb", "cloudflared", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
197
|
+
if (!["bun", "nginx", "arangodb", "cloudflared", "cloudflare-warp", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
73
198
|
throw new Error("software requirement coordinate is invalid");
|
|
74
199
|
}
|
|
75
200
|
const requirement = { id: row.id, version: row.version };
|
|
@@ -95,15 +220,15 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
95
220
|
const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
96
221
|
if (!strategy)
|
|
97
222
|
throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
|
|
98
|
-
const before = await options.exec(
|
|
223
|
+
const before = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
99
224
|
if (before.exitCode === 0) {
|
|
100
225
|
results.push({ ...requirement, changed: false });
|
|
101
226
|
continue;
|
|
102
227
|
}
|
|
103
|
-
const installed = await options.exec(
|
|
228
|
+
const installed = await options.exec({ kind: "install", software: requirement.id, version: requirement.version });
|
|
104
229
|
if (installed.exitCode !== 0)
|
|
105
230
|
throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
|
|
106
|
-
const after = await options.exec(
|
|
231
|
+
const after = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
107
232
|
if (after.exitCode !== 0)
|
|
108
233
|
throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
|
|
109
234
|
results.push({ ...requirement, changed: true });
|
|
@@ -111,34 +236,671 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
111
236
|
return results;
|
|
112
237
|
}
|
|
113
238
|
|
|
239
|
+
// src/capacity-calibration.ts
|
|
240
|
+
var percentile95 = (values) => {
|
|
241
|
+
if (values.length === 0)
|
|
242
|
+
return Number.POSITIVE_INFINITY;
|
|
243
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
244
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
|
|
245
|
+
};
|
|
246
|
+
function localCalibrationEndpoint(value) {
|
|
247
|
+
const endpoint = new URL(value);
|
|
248
|
+
if (endpoint.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || !endpoint.port || endpoint.username || endpoint.password || endpoint.hash) {
|
|
249
|
+
throw new Error("Capacity calibration requires an explicit loopback HTTP endpoint and port.");
|
|
250
|
+
}
|
|
251
|
+
return endpoint;
|
|
252
|
+
}
|
|
253
|
+
function validateCapacityCalibrationOptions(options) {
|
|
254
|
+
const endpoint = localCalibrationEndpoint(options.endpoint);
|
|
255
|
+
const maxConcurrency = options.maxConcurrency ?? 256;
|
|
256
|
+
const requestsPerWorker = options.requestsPerWorker ?? 8;
|
|
257
|
+
const maxP95Ms = options.maxP95Ms ?? 250;
|
|
258
|
+
const maxErrorRate = options.maxErrorRate ?? 0.01;
|
|
259
|
+
const headroomRatio = options.headroomRatio ?? 0.8;
|
|
260
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
|
|
261
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4096 || !Number.isSafeInteger(requestsPerWorker) || requestsPerWorker < 2 || requestsPerWorker > 100 || !Number.isFinite(maxP95Ms) || maxP95Ms < 1 || !Number.isFinite(maxErrorRate) || maxErrorRate < 0 || maxErrorRate > 0.2 || !Number.isFinite(headroomRatio) || headroomRatio < 0.25 || headroomRatio > 0.95 || !Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 100 || requestTimeoutMs > 30000) {
|
|
262
|
+
throw new Error("Capacity calibration bounds are invalid.");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
endpoint,
|
|
266
|
+
maxConcurrency,
|
|
267
|
+
requestsPerWorker,
|
|
268
|
+
maxP95Ms,
|
|
269
|
+
maxErrorRate,
|
|
270
|
+
headroomRatio,
|
|
271
|
+
requestTimeoutMs
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
|
|
275
|
+
const latencies = [];
|
|
276
|
+
let succeeded = 0;
|
|
277
|
+
let failed = 0;
|
|
278
|
+
let overloaded = 0;
|
|
279
|
+
const started = performance.now();
|
|
280
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
281
|
+
for (let request = 0;request < requestsPerWorker; request += 1) {
|
|
282
|
+
const requestStarted = performance.now();
|
|
283
|
+
try {
|
|
284
|
+
const response = await fetcher(endpoint, {
|
|
285
|
+
method: "GET",
|
|
286
|
+
headers: { accept: "application/json", "user-agent": "forgezero-capacity-calibration/1" },
|
|
287
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
288
|
+
redirect: "error"
|
|
289
|
+
});
|
|
290
|
+
await response.body?.cancel();
|
|
291
|
+
if (response.ok)
|
|
292
|
+
succeeded += 1;
|
|
293
|
+
else {
|
|
294
|
+
failed += 1;
|
|
295
|
+
if (response.status === 503)
|
|
296
|
+
overloaded += 1;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
failed += 1;
|
|
300
|
+
} finally {
|
|
301
|
+
latencies.push(performance.now() - requestStarted);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}));
|
|
305
|
+
const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
|
|
306
|
+
return {
|
|
307
|
+
concurrency,
|
|
308
|
+
requests: concurrency * requestsPerWorker,
|
|
309
|
+
succeeded,
|
|
310
|
+
failed,
|
|
311
|
+
overloaded,
|
|
312
|
+
throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
|
|
313
|
+
p95Ms: Number(percentile95(latencies).toFixed(2))
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
317
|
+
const {
|
|
318
|
+
endpoint,
|
|
319
|
+
maxConcurrency,
|
|
320
|
+
requestsPerWorker,
|
|
321
|
+
maxP95Ms,
|
|
322
|
+
maxErrorRate,
|
|
323
|
+
headroomRatio,
|
|
324
|
+
requestTimeoutMs: timeoutMs
|
|
325
|
+
} = validateCapacityCalibrationOptions(options);
|
|
326
|
+
const stages = [];
|
|
327
|
+
let lastSafe = 1;
|
|
328
|
+
let stopReason = "maximum-tested";
|
|
329
|
+
for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
|
|
330
|
+
const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
|
|
331
|
+
stages.push(measured);
|
|
332
|
+
const errorRate = measured.failed / measured.requests;
|
|
333
|
+
const previous = stages.at(-2);
|
|
334
|
+
const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
|
|
335
|
+
if (measured.overloaded > 0 || errorRate > maxErrorRate)
|
|
336
|
+
stopReason = "errors";
|
|
337
|
+
else if (measured.p95Ms > maxP95Ms)
|
|
338
|
+
stopReason = "latency";
|
|
339
|
+
else if (throughputRegressed)
|
|
340
|
+
stopReason = "throughput-regression";
|
|
341
|
+
else
|
|
342
|
+
lastSafe = concurrency;
|
|
343
|
+
if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
endpoint: endpoint.toString(),
|
|
348
|
+
recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
|
|
349
|
+
stopReason,
|
|
350
|
+
stages
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/definition.ts
|
|
355
|
+
var PIPELINE_VERSION = 3;
|
|
356
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
|
|
357
|
+
|
|
358
|
+
class DefinitionError extends Error {
|
|
359
|
+
constructor(message) {
|
|
360
|
+
super(message);
|
|
361
|
+
this.name = "DefinitionError";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
var record = (value, where) => {
|
|
365
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
366
|
+
throw new DefinitionError(`${where} must be an object.`);
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
};
|
|
370
|
+
var text = (value, where) => {
|
|
371
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
372
|
+
throw new DefinitionError(`${where} must be a non-empty string.`);
|
|
373
|
+
}
|
|
374
|
+
return value;
|
|
375
|
+
};
|
|
376
|
+
var argv = (value, where) => {
|
|
377
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 256) {
|
|
378
|
+
throw new DefinitionError(`${where} must contain from 1 to 256 arguments.`);
|
|
379
|
+
}
|
|
380
|
+
let bytes = 0;
|
|
381
|
+
const parsed = value.map((argument, index) => {
|
|
382
|
+
if (typeof argument !== "string" || argument.length === 0 || argument.length > 16384 || argument.includes("\x00")) {
|
|
383
|
+
throw new DefinitionError(`${where}[${index}] must be a non-empty bounded string without NUL.`);
|
|
384
|
+
}
|
|
385
|
+
if (argument.includes("${") && !/^\$\{FZ_[A-Z0-9_]+\}$/.test(argument)) {
|
|
386
|
+
throw new DefinitionError(`${where}[${index}] contains unsupported interpolation; only one exact FZ coordinate is allowed.`);
|
|
387
|
+
}
|
|
388
|
+
bytes += Buffer.byteLength(argument);
|
|
389
|
+
if (bytes > 64 * 1024)
|
|
390
|
+
throw new DefinitionError(`${where} is larger than 64 KiB.`);
|
|
391
|
+
return argument;
|
|
392
|
+
});
|
|
393
|
+
const executable = parsed[0].split("/").at(-1).toLowerCase();
|
|
394
|
+
if (new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"]).has(executable)) {
|
|
395
|
+
throw new DefinitionError(`${where}[0] may not invoke a shell or command dispatcher.`);
|
|
396
|
+
}
|
|
397
|
+
return parsed;
|
|
398
|
+
};
|
|
399
|
+
var exactKeys = (value, allowed, where) => {
|
|
400
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
401
|
+
if (unknown.length > 0)
|
|
402
|
+
throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
|
|
403
|
+
};
|
|
404
|
+
var NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
405
|
+
var RESERVED_STEP_ENV = new Set([
|
|
406
|
+
"PATH",
|
|
407
|
+
"HOME",
|
|
408
|
+
"SHELL",
|
|
409
|
+
"PWD",
|
|
410
|
+
"BUN_INSTALL",
|
|
411
|
+
"NODE_OPTIONS",
|
|
412
|
+
"LD_PRELOAD",
|
|
413
|
+
"LD_LIBRARY_PATH",
|
|
414
|
+
"GIT_SSH",
|
|
415
|
+
"GIT_SSH_COMMAND"
|
|
416
|
+
]);
|
|
417
|
+
function capacityCalibration(value, where) {
|
|
418
|
+
const calibration = record(value, where);
|
|
419
|
+
exactKeys(calibration, [
|
|
420
|
+
"endpoint",
|
|
421
|
+
"maxConcurrency",
|
|
422
|
+
"requestsPerWorker",
|
|
423
|
+
"maxP95Ms",
|
|
424
|
+
"maxErrorRate",
|
|
425
|
+
"headroomRatio",
|
|
426
|
+
"requestTimeoutMs"
|
|
427
|
+
], where);
|
|
428
|
+
const endpoint = text(calibration.endpoint, `${where}.endpoint`);
|
|
429
|
+
try {
|
|
430
|
+
localCalibrationEndpoint(endpoint);
|
|
431
|
+
} catch (cause) {
|
|
432
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where}.endpoint is invalid.`);
|
|
433
|
+
}
|
|
434
|
+
const optionalNumber = (name) => {
|
|
435
|
+
const raw = calibration[name];
|
|
436
|
+
if (raw === undefined)
|
|
437
|
+
return;
|
|
438
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
439
|
+
throw new DefinitionError(`${where}.${name} must be a finite number.`);
|
|
440
|
+
}
|
|
441
|
+
return raw;
|
|
442
|
+
};
|
|
443
|
+
const parsed = {
|
|
444
|
+
endpoint,
|
|
445
|
+
...Object.fromEntries([
|
|
446
|
+
"maxConcurrency",
|
|
447
|
+
"requestsPerWorker",
|
|
448
|
+
"maxP95Ms",
|
|
449
|
+
"maxErrorRate",
|
|
450
|
+
"headroomRatio",
|
|
451
|
+
"requestTimeoutMs"
|
|
452
|
+
].flatMap((name) => {
|
|
453
|
+
const found = optionalNumber(name);
|
|
454
|
+
return found === undefined ? [] : [[name, found]];
|
|
455
|
+
}))
|
|
456
|
+
};
|
|
457
|
+
try {
|
|
458
|
+
validateCapacityCalibrationOptions(parsed);
|
|
459
|
+
} catch (cause) {
|
|
460
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where} bounds are invalid.`);
|
|
461
|
+
}
|
|
462
|
+
return parsed;
|
|
463
|
+
}
|
|
464
|
+
function validateDeploymentService(value, where = "service") {
|
|
465
|
+
const service = record(value, where);
|
|
466
|
+
exactKeys(service, [
|
|
467
|
+
"strategy",
|
|
468
|
+
"publicPort",
|
|
469
|
+
"applicationPorts",
|
|
470
|
+
"command",
|
|
471
|
+
"healthPath",
|
|
472
|
+
"maxConnections",
|
|
473
|
+
"websocket",
|
|
474
|
+
"drainMs"
|
|
475
|
+
], where);
|
|
476
|
+
if (service.strategy !== "direct" && service.strategy !== "blue-green") {
|
|
477
|
+
throw new DefinitionError(`${where}.strategy must be direct or blue-green.`);
|
|
478
|
+
}
|
|
479
|
+
const publicPort = Number(service.publicPort);
|
|
480
|
+
if (!Number.isSafeInteger(publicPort) || publicPort < 1024 || publicPort > 65535) {
|
|
481
|
+
throw new DefinitionError(`${where}.publicPort must be an unprivileged TCP port.`);
|
|
482
|
+
}
|
|
483
|
+
const allocation = record(service.applicationPorts, `${where}.applicationPorts`);
|
|
484
|
+
let applicationPorts;
|
|
485
|
+
const required = service.strategy === "blue-green" ? 2 : 1;
|
|
486
|
+
if (allocation.mode === "fixed") {
|
|
487
|
+
exactKeys(allocation, ["mode", "ports"], `${where}.applicationPorts`);
|
|
488
|
+
if (!Array.isArray(allocation.ports) || allocation.ports.length !== required || allocation.ports.some((port) => !Number.isSafeInteger(port) || Number(port) < 1024 || Number(port) > 65535) || new Set(allocation.ports).size !== allocation.ports.length || allocation.ports.includes(publicPort)) {
|
|
489
|
+
throw new DefinitionError(`${where}.applicationPorts needs ${required} distinct unprivileged port(s), disjoint from publicPort.`);
|
|
490
|
+
}
|
|
491
|
+
applicationPorts = { mode: "fixed", ports: allocation.ports };
|
|
492
|
+
} else if (allocation.mode === "dynamic") {
|
|
493
|
+
exactKeys(allocation, ["mode", "from", "to"], `${where}.applicationPorts`);
|
|
494
|
+
const from = Number(allocation.from);
|
|
495
|
+
const to = Number(allocation.to);
|
|
496
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 1024 || to > 65535 || to - from + 1 < required || publicPort >= from && publicPort <= to) {
|
|
497
|
+
throw new DefinitionError(`${where}.applicationPorts dynamic range is invalid or includes publicPort.`);
|
|
498
|
+
}
|
|
499
|
+
applicationPorts = { mode: "dynamic", from, to };
|
|
500
|
+
} else
|
|
501
|
+
throw new DefinitionError(`${where}.applicationPorts.mode must be fixed or dynamic.`);
|
|
502
|
+
const command = argv(service.command, `${where}.command`);
|
|
503
|
+
for (const argument of command) {
|
|
504
|
+
const coordinate = argument.match(/^\$\{(FZ_[A-Z0-9_]+)\}$/)?.[1];
|
|
505
|
+
if (coordinate && !["FZ_APP_PORT", "FZ_RELEASE"].includes(coordinate)) {
|
|
506
|
+
throw new DefinitionError(`${where}.command uses unsupported coordinate ${coordinate}.`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (typeof service.healthPath !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,255}$/.test(service.healthPath) || service.healthPath.includes("..") || service.healthPath.includes("//")) {
|
|
510
|
+
throw new DefinitionError(`${where}.healthPath must be one bounded absolute path.`);
|
|
511
|
+
}
|
|
512
|
+
if (service.websocket !== undefined && typeof service.websocket !== "boolean") {
|
|
513
|
+
throw new DefinitionError(`${where}.websocket must be a boolean.`);
|
|
514
|
+
}
|
|
515
|
+
const maxConnections = service.maxConnections === undefined ? undefined : Number(service.maxConnections);
|
|
516
|
+
if (maxConnections !== undefined && (!Number.isSafeInteger(maxConnections) || maxConnections < 1 || maxConnections > 1e6)) {
|
|
517
|
+
throw new DefinitionError(`${where}.maxConnections must be from 1 to 1000000.`);
|
|
518
|
+
}
|
|
519
|
+
const drainMs = service.drainMs === undefined ? 30000 : Number(service.drainMs);
|
|
520
|
+
if (!Number.isSafeInteger(drainMs) || drainMs < 0 || drainMs > 300000) {
|
|
521
|
+
throw new DefinitionError(`${where}.drainMs must be from 0 to 300000.`);
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
strategy: service.strategy,
|
|
525
|
+
publicPort,
|
|
526
|
+
applicationPorts,
|
|
527
|
+
command,
|
|
528
|
+
healthPath: service.healthPath,
|
|
529
|
+
...maxConnections === undefined ? {} : { maxConnections },
|
|
530
|
+
websocket: service.websocket === true,
|
|
531
|
+
drainMs
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function parseDeployDefinition(value, options = {}) {
|
|
535
|
+
const root = record(value, "pipeline");
|
|
536
|
+
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
537
|
+
if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
|
|
538
|
+
throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
|
|
539
|
+
}
|
|
540
|
+
if (root.version !== PIPELINE_VERSION) {
|
|
541
|
+
throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
|
|
542
|
+
}
|
|
543
|
+
if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
|
|
544
|
+
throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
|
|
545
|
+
}
|
|
546
|
+
const rawProfiles = record(root.profiles, "pipeline.profiles");
|
|
547
|
+
const profileEntries = Object.entries(rawProfiles);
|
|
548
|
+
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
549
|
+
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
550
|
+
}
|
|
551
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0 || root.steps.length > 256) {
|
|
552
|
+
throw new DefinitionError("pipeline.steps must contain from 1 to 256 steps.");
|
|
553
|
+
}
|
|
554
|
+
const profiles = {};
|
|
555
|
+
for (const [name2, raw] of profileEntries) {
|
|
556
|
+
if (!NAME.test(name2))
|
|
557
|
+
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
558
|
+
const profile = record(raw, `profiles.${name2}`);
|
|
559
|
+
exactKeys(profile, ["software", "service", "capacityCalibration"], `profiles.${name2}`);
|
|
560
|
+
if (!Array.isArray(profile.software)) {
|
|
561
|
+
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
562
|
+
}
|
|
563
|
+
const software = validateSoftwareRequirements(profile.software, options);
|
|
564
|
+
const service = profile.service === undefined ? undefined : validateDeploymentService(profile.service, `profiles.${name2}.service`);
|
|
565
|
+
if (service && !software.some(({ id }) => id === "nginx")) {
|
|
566
|
+
throw new DefinitionError(`profiles.${name2}.service requires the reviewed nginx software strategy.`);
|
|
567
|
+
}
|
|
568
|
+
profiles[name2] = {
|
|
569
|
+
software,
|
|
570
|
+
...profile.service === undefined ? {} : {
|
|
571
|
+
service
|
|
572
|
+
},
|
|
573
|
+
...profile.capacityCalibration === undefined ? {} : {
|
|
574
|
+
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
579
|
+
const steps = root.steps.map((raw, index) => {
|
|
580
|
+
const step = record(raw, `steps[${index}]`);
|
|
581
|
+
exactKeys(step, ["name", "exec", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
582
|
+
const phase = text(step.phase, `steps[${index}].phase`);
|
|
583
|
+
if (!phases.has(phase))
|
|
584
|
+
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
585
|
+
if (step.scope !== "target" && step.scope !== "release") {
|
|
586
|
+
throw new DefinitionError(`steps[${index}].scope must be target or release.`);
|
|
587
|
+
}
|
|
588
|
+
if (step.always !== undefined && typeof step.always !== "boolean") {
|
|
589
|
+
throw new DefinitionError(`steps[${index}].always must be a boolean.`);
|
|
590
|
+
}
|
|
591
|
+
let selectedProfiles;
|
|
592
|
+
if (step.profiles !== undefined) {
|
|
593
|
+
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
|
|
594
|
+
throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
|
|
595
|
+
}
|
|
596
|
+
selectedProfiles = [...step.profiles];
|
|
597
|
+
if (new Set(selectedProfiles).size !== selectedProfiles.length) {
|
|
598
|
+
throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
|
|
602
|
+
throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
|
|
603
|
+
}
|
|
604
|
+
if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
|
|
605
|
+
throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
|
|
606
|
+
}
|
|
607
|
+
if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
|
|
608
|
+
throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
|
|
609
|
+
}
|
|
610
|
+
const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
|
|
611
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
|
|
612
|
+
throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
|
|
613
|
+
}
|
|
614
|
+
let when;
|
|
615
|
+
if (step.when !== undefined) {
|
|
616
|
+
const conditions = record(step.when, `steps[${index}].when`);
|
|
617
|
+
when = {};
|
|
618
|
+
for (const [name2, expected] of Object.entries(conditions)) {
|
|
619
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
|
|
620
|
+
throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
|
|
621
|
+
}
|
|
622
|
+
when[name2] = expected;
|
|
623
|
+
}
|
|
624
|
+
if (Object.keys(when).length === 0)
|
|
625
|
+
throw new DefinitionError(`steps[${index}].when must not be empty.`);
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
name: text(step.name, `steps[${index}].name`),
|
|
629
|
+
exec: argv(step.exec, `steps[${index}].exec`),
|
|
630
|
+
phase,
|
|
631
|
+
scope: step.scope,
|
|
632
|
+
profiles: selectedProfiles,
|
|
633
|
+
secrets: step.secrets,
|
|
634
|
+
always: step.always === true,
|
|
635
|
+
timeoutMs,
|
|
636
|
+
when
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
640
|
+
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
641
|
+
}
|
|
642
|
+
for (const [profile, selected] of Object.entries(profiles)) {
|
|
643
|
+
if (selected.capacityCalibration && !steps.some((step) => step.phase === "health" && step.scope === "target" && (!step.profiles || step.profiles.includes(profile)))) {
|
|
644
|
+
throw new DefinitionError(`profiles.${profile}.capacityCalibration requires a target-scoped health step.`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const name = text(root.name, "pipeline.name");
|
|
648
|
+
if (name.length > 120)
|
|
649
|
+
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
|
650
|
+
return {
|
|
651
|
+
version: PIPELINE_VERSION,
|
|
652
|
+
name,
|
|
653
|
+
requireAttestation: root.requireAttestation === true,
|
|
654
|
+
profiles,
|
|
655
|
+
steps
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
659
|
+
if (!Object.hasOwn(definition.profiles, profile)) {
|
|
660
|
+
throw new DefinitionError(`pipeline profile does not exist: ${profile}.`);
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
name: `${definition.name}:${phase}`,
|
|
664
|
+
requireAttestation: definition.requireAttestation,
|
|
665
|
+
steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
114
669
|
// src/software-helper.ts
|
|
115
|
-
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
670
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2 } from "node:fs";
|
|
116
671
|
import { connect, createServer } from "node:net";
|
|
117
|
-
import { dirname } from "node:path";
|
|
672
|
+
import { dirname as dirname2 } from "node:path";
|
|
673
|
+
|
|
674
|
+
// src/service-supervisor.ts
|
|
675
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
676
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
677
|
+
import { dirname, join as join2, resolve, sep } from "node:path";
|
|
678
|
+
var SERVICE_STATE_DIRECTORY = "/var/lib/forgezero/services";
|
|
679
|
+
var SERVICE_CONFIG_DIRECTORY = "/etc/forgezero/services";
|
|
680
|
+
var SERVICE_UNIT_DIRECTORY = "/etc/systemd/system";
|
|
681
|
+
var SERVICE_NGINX_DIRECTORY = "/etc/nginx/conf.d";
|
|
682
|
+
var idFor = (key) => createHash2("sha256").update(key).digest("hex").slice(0, 24);
|
|
683
|
+
var statePath = (id) => `${SERVICE_STATE_DIRECTORY}/${id}.json`;
|
|
684
|
+
var within = (root, path2) => path2 === root || path2.startsWith(`${root}${sep}`);
|
|
685
|
+
var defaultHost = {
|
|
686
|
+
write(path2, content, mode) {
|
|
687
|
+
mkdirSync2(dirname(path2), { recursive: true, mode: 493 });
|
|
688
|
+
const next = `${path2}.next`;
|
|
689
|
+
writeFileSync2(next, content, { mode });
|
|
690
|
+
renameSync2(next, path2);
|
|
691
|
+
},
|
|
692
|
+
read: (path2) => readFileSync2(path2, "utf8"),
|
|
693
|
+
exists: existsSync,
|
|
694
|
+
list: (path2) => existsSync(path2) ? readdirSync(path2) : [],
|
|
695
|
+
realpath: realpathSync,
|
|
696
|
+
mkdir: (path2, mode) => mkdirSync2(path2, { recursive: true, mode }),
|
|
697
|
+
remove: (path2) => rmSync2(path2, { force: true }),
|
|
698
|
+
async exec(argv2) {
|
|
699
|
+
const child = Bun.spawn([...argv2], { stdout: "pipe", stderr: "pipe", env: {
|
|
700
|
+
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
701
|
+
LANG: "C",
|
|
702
|
+
LC_ALL: "C"
|
|
703
|
+
} });
|
|
704
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
705
|
+
new Response(child.stdout).text(),
|
|
706
|
+
new Response(child.stderr).text(),
|
|
707
|
+
child.exited
|
|
708
|
+
]);
|
|
709
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
710
|
+
},
|
|
711
|
+
async health(port, path2) {
|
|
712
|
+
try {
|
|
713
|
+
const response = await fetch(`http://127.0.0.1:${port}${path2}`, {
|
|
714
|
+
signal: AbortSignal.timeout(2000),
|
|
715
|
+
redirect: "manual"
|
|
716
|
+
});
|
|
717
|
+
return response.status >= 200 && response.status < 300;
|
|
718
|
+
} catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
723
|
+
now: Date.now
|
|
724
|
+
};
|
|
725
|
+
function readState(host, path2) {
|
|
726
|
+
if (!host.exists(path2))
|
|
727
|
+
return null;
|
|
728
|
+
try {
|
|
729
|
+
const value = JSON.parse(host.read(path2));
|
|
730
|
+
return value?.format === 1 && Array.isArray(value.applicationPorts) ? value : null;
|
|
731
|
+
} catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function allStates(host) {
|
|
736
|
+
return host.list(SERVICE_STATE_DIRECTORY).filter((name) => /^[a-f0-9]{24}\.json$/.test(name)).flatMap((name) => {
|
|
737
|
+
const state = readState(host, join2(SERVICE_STATE_DIRECTORY, name));
|
|
738
|
+
return state ? [state] : [];
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
function allocatedPorts(request, id, previous, states) {
|
|
742
|
+
const required = request.service.strategy === "blue-green" ? 2 : 1;
|
|
743
|
+
const occupied = new Set(states.filter((state) => state.id !== id).flatMap((state) => [state.publicPort, ...state.applicationPorts]));
|
|
744
|
+
if (occupied.has(request.service.publicPort))
|
|
745
|
+
throw new Error("SERVICE_PUBLIC_PORT_CONFLICT");
|
|
746
|
+
const allocation = request.service.applicationPorts;
|
|
747
|
+
if (allocation.mode === "fixed") {
|
|
748
|
+
if (allocation.ports.some((port) => occupied.has(port)))
|
|
749
|
+
throw new Error("SERVICE_APPLICATION_PORT_CONFLICT");
|
|
750
|
+
return [...allocation.ports];
|
|
751
|
+
}
|
|
752
|
+
if (previous && previous.applicationPorts.length === required && previous.applicationPorts.every((port) => port >= allocation.from && port <= allocation.to && !occupied.has(port)))
|
|
753
|
+
return [...previous.applicationPorts];
|
|
754
|
+
const width = allocation.to - allocation.from + 1;
|
|
755
|
+
const start = Number.parseInt(createHash2("sha256").update(request.key).digest("hex").slice(0, 8), 16) % width;
|
|
756
|
+
for (let offset = 0;offset < width; offset += 1) {
|
|
757
|
+
const first = allocation.from + (start + offset) % width;
|
|
758
|
+
const candidate = Array.from({ length: required }, (_, index) => first + index);
|
|
759
|
+
if (candidate.at(-1) <= allocation.to && candidate.every((port) => !occupied.has(port) && port !== request.service.publicPort)) {
|
|
760
|
+
return candidate;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
throw new Error("SERVICE_DYNAMIC_PORTS_EXHAUSTED");
|
|
764
|
+
}
|
|
765
|
+
var appConfig = (request, port) => `${JSON.stringify({
|
|
766
|
+
format: 1,
|
|
767
|
+
root: request.root,
|
|
768
|
+
release: request.release,
|
|
769
|
+
home: `${request.root}/app-home`,
|
|
770
|
+
argv: request.service.command.map((argument) => argument === "${FZ_APP_PORT}" ? String(port) : argument === "${FZ_RELEASE}" ? request.release : argument),
|
|
771
|
+
environment: { FZ_APP_PORT: String(port), FZ_RELEASE: request.release }
|
|
772
|
+
}, null, 2)}
|
|
773
|
+
`;
|
|
774
|
+
var unit = (id, slot) => `[Unit]
|
|
775
|
+
Description=ForgeZero supervised tenant application ${id} slot ${slot}
|
|
776
|
+
After=network-online.target
|
|
777
|
+
Wants=network-online.target
|
|
778
|
+
|
|
779
|
+
[Service]
|
|
780
|
+
Type=simple
|
|
781
|
+
User=forgezero-app
|
|
782
|
+
Group=forgezero-vault
|
|
783
|
+
ExecStart=/usr/local/lib/forgezero/agent/fz-agent supervised-app --config=${SERVICE_CONFIG_DIRECTORY}/${id}-slot${slot}.json
|
|
784
|
+
Restart=always
|
|
785
|
+
RestartSec=2
|
|
786
|
+
NoNewPrivileges=true
|
|
787
|
+
PrivateTmp=true
|
|
788
|
+
ProtectSystem=strict
|
|
789
|
+
ProtectHome=true
|
|
790
|
+
LimitCORE=0
|
|
791
|
+
|
|
792
|
+
[Install]
|
|
793
|
+
WantedBy=multi-user.target
|
|
794
|
+
`;
|
|
795
|
+
var nginx = (request, id, port) => `# ForgeZero ${id}
|
|
796
|
+
${request.service.maxConnections ? `limit_conn_zone $server_name zone=fz_${id}:64k;
|
|
797
|
+
` : ""}server {
|
|
798
|
+
listen 127.0.0.1:${request.service.publicPort};
|
|
799
|
+
server_name _;
|
|
800
|
+
${request.service.maxConnections ? ` limit_conn fz_${id} ${request.service.maxConnections};
|
|
801
|
+
limit_conn_status 503;
|
|
802
|
+
` : ""} location / {
|
|
803
|
+
proxy_pass http://127.0.0.1:${port};
|
|
804
|
+
proxy_http_version 1.1;
|
|
805
|
+
${request.service.websocket ? ` proxy_set_header Upgrade $http_upgrade;
|
|
806
|
+
proxy_set_header Connection "upgrade";
|
|
807
|
+
` : ""} proxy_set_header Host $host;
|
|
808
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
809
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
`;
|
|
813
|
+
async function checked(host, argv2, code) {
|
|
814
|
+
const result = await host.exec(argv2);
|
|
815
|
+
if (result.exitCode !== 0)
|
|
816
|
+
throw new Error(`${code}: ${result.output.slice(0, 512)}`);
|
|
817
|
+
}
|
|
818
|
+
async function activateSupervisedService(request, host = defaultHost) {
|
|
819
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/.test(request.key) || !/^[a-f0-9]{40}$/.test(request.revision)) {
|
|
820
|
+
throw new Error("SERVICE_IDENTITY_INVALID");
|
|
821
|
+
}
|
|
822
|
+
const root = host.realpath(resolve(request.root));
|
|
823
|
+
const release = host.realpath(resolve(request.release));
|
|
824
|
+
if (!within(root, release))
|
|
825
|
+
throw new Error("SERVICE_RELEASE_OUTSIDE_ROOT");
|
|
826
|
+
request = { ...request, root, release, service: validateDeploymentService(request.service) };
|
|
827
|
+
const id = idFor(request.key);
|
|
828
|
+
host.mkdir(SERVICE_STATE_DIRECTORY, 448);
|
|
829
|
+
host.mkdir(SERVICE_CONFIG_DIRECTORY, 457);
|
|
830
|
+
const previous = readState(host, statePath(id));
|
|
831
|
+
const ports = allocatedPorts(request, id, previous, allStates(host));
|
|
832
|
+
const nextSlot = request.service.strategy === "blue-green" ? previous?.activeSlot === 0 ? 1 : 0 : 0;
|
|
833
|
+
const port = ports[nextSlot] ?? ports[0];
|
|
834
|
+
const configPath = `${SERVICE_CONFIG_DIRECTORY}/${id}-slot${nextSlot}.json`;
|
|
835
|
+
const unitName = `forgezero-app-${id}-slot${nextSlot}.service`;
|
|
836
|
+
host.write(configPath, appConfig({ ...request, release }, port), 292);
|
|
837
|
+
host.write(`${SERVICE_UNIT_DIRECTORY}/${unitName}`, unit(id, nextSlot), 420);
|
|
838
|
+
await checked(host, ["/usr/bin/systemctl", "daemon-reload"], "SERVICE_UNIT_RELOAD_FAILED");
|
|
839
|
+
if (request.service.strategy === "direct" && previous) {
|
|
840
|
+
await host.exec(["/usr/bin/systemctl", "stop", `forgezero-app-${id}-slot${previous.activeSlot}.service`]);
|
|
841
|
+
}
|
|
842
|
+
await checked(host, ["/usr/bin/systemctl", "restart", unitName], "SERVICE_START_FAILED");
|
|
843
|
+
let healthy = false;
|
|
844
|
+
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
845
|
+
const active = await host.exec(["/usr/bin/systemctl", "is-active", "--quiet", unitName]);
|
|
846
|
+
if (active.exitCode === 0 && await host.health(port, request.service.healthPath)) {
|
|
847
|
+
healthy = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
await host.sleep(1000);
|
|
851
|
+
}
|
|
852
|
+
if (!healthy) {
|
|
853
|
+
await host.exec(["/usr/bin/systemctl", "stop", unitName]);
|
|
854
|
+
throw new Error("SERVICE_HEALTH_FAILED");
|
|
855
|
+
}
|
|
856
|
+
const nginxPath = `${SERVICE_NGINX_DIRECTORY}/forgezero-${id}.conf`;
|
|
857
|
+
const oldNginx = host.exists(nginxPath) ? host.read(nginxPath) : null;
|
|
858
|
+
try {
|
|
859
|
+
host.write(nginxPath, nginx(request, id, port), 420);
|
|
860
|
+
await checked(host, ["/usr/sbin/nginx", "-t"], "SERVICE_NGINX_INVALID");
|
|
861
|
+
await checked(host, ["/usr/bin/systemctl", "reload", "nginx.service"], "SERVICE_NGINX_RELOAD_FAILED");
|
|
862
|
+
} catch (cause) {
|
|
863
|
+
if (oldNginx === null)
|
|
864
|
+
host.remove(nginxPath);
|
|
865
|
+
else
|
|
866
|
+
host.write(nginxPath, oldNginx, 420);
|
|
867
|
+
await host.exec(["/usr/bin/systemctl", "stop", unitName]);
|
|
868
|
+
throw cause;
|
|
869
|
+
}
|
|
870
|
+
const state = {
|
|
871
|
+
format: 1,
|
|
872
|
+
id,
|
|
873
|
+
key: request.key,
|
|
874
|
+
revision: request.revision,
|
|
875
|
+
strategy: request.service.strategy,
|
|
876
|
+
publicPort: request.service.publicPort,
|
|
877
|
+
applicationPorts: ports,
|
|
878
|
+
activeSlot: nextSlot,
|
|
879
|
+
healthPath: request.service.healthPath,
|
|
880
|
+
updatedAtTs: host.now()
|
|
881
|
+
};
|
|
882
|
+
host.write(statePath(id), `${JSON.stringify(state, null, 2)}
|
|
883
|
+
`, 384);
|
|
884
|
+
if (request.service.strategy === "blue-green" && previous && previous.activeSlot !== nextSlot) {
|
|
885
|
+
await host.sleep(request.service.drainMs ?? 30000);
|
|
886
|
+
await host.exec(["/usr/bin/systemctl", "stop", `forgezero-app-${id}-slot${previous.activeSlot}.service`]);
|
|
887
|
+
}
|
|
888
|
+
return state;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/software-helper.ts
|
|
118
892
|
var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
|
|
119
893
|
var SOFTWARE_HELPER_GROUP = "forgezero-software";
|
|
120
894
|
var SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
|
|
121
|
-
var MAX_REQUEST_BYTES =
|
|
895
|
+
var MAX_REQUEST_BYTES = 128 * 1024;
|
|
122
896
|
var MAX_PENDING_REQUESTS = 128;
|
|
123
|
-
var execute = async (command) => {
|
|
124
|
-
const child = Bun.spawn(["/bin/bash", "-Eeuo", "pipefail", "-c", command], {
|
|
125
|
-
stdout: "pipe",
|
|
126
|
-
stderr: "pipe",
|
|
127
|
-
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
128
|
-
});
|
|
129
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
130
|
-
new Response(child.stdout).text(),
|
|
131
|
-
new Response(child.stderr).text(),
|
|
132
|
-
child.exited
|
|
133
|
-
]);
|
|
134
|
-
return { exitCode, output: `${stdout}${stderr}` };
|
|
135
|
-
};
|
|
136
897
|
function startSoftwareHelper(options = {}) {
|
|
137
898
|
const socketPath = options.socketPath ?? DEFAULT_SOFTWARE_HELPER_SOCKET;
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
899
|
+
if (existsSync2(socketPath))
|
|
900
|
+
unlinkSync2(socketPath);
|
|
901
|
+
mkdirSync3(dirname2(socketPath), { recursive: true, mode: 488 });
|
|
141
902
|
const ensure = options.ensure ?? ensureSoftwareRequirements;
|
|
903
|
+
const activate = options.activate ?? activateSupervisedService;
|
|
142
904
|
let tail = Promise.resolve();
|
|
143
905
|
let pending = 0;
|
|
144
906
|
const server = createServer((socket) => {
|
|
@@ -163,12 +925,23 @@ function startSoftwareHelper(options = {}) {
|
|
|
163
925
|
}
|
|
164
926
|
pending += 1;
|
|
165
927
|
const work = tail.then(() => Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
|
|
166
|
-
if (request.op
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
928
|
+
if (request.op === "ensure") {
|
|
929
|
+
const requirements = validateSoftwareRequirements(request.requirements);
|
|
930
|
+
const results = await ensure(requirements, { exec: executeSoftwareOperation });
|
|
931
|
+
socket.end(`${JSON.stringify({ ok: true, results })}
|
|
932
|
+
`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (request.op === "activate-service" && request.request) {
|
|
936
|
+
if (!options.allowedRoot || request.request.root !== options.allowedRoot) {
|
|
937
|
+
throw new Error("service deployment root is not owned by this helper");
|
|
938
|
+
}
|
|
939
|
+
const state = await activate(request.request);
|
|
940
|
+
socket.end(`${JSON.stringify({ ok: true, state })}
|
|
171
941
|
`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
throw new Error("unknown software helper operation");
|
|
172
945
|
}).catch((cause) => socket.end(`${JSON.stringify({
|
|
173
946
|
ok: false,
|
|
174
947
|
error: { code: "SOFTWARE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
|
|
@@ -184,12 +957,40 @@ function startSoftwareHelper(options = {}) {
|
|
|
184
957
|
});
|
|
185
958
|
socket.on("error", () => socket.destroy());
|
|
186
959
|
});
|
|
187
|
-
server.listen(socketPath, () =>
|
|
960
|
+
server.listen(socketPath, () => chmodSync2(socketPath, 432));
|
|
188
961
|
return server;
|
|
189
962
|
}
|
|
963
|
+
function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 10 * 60000) {
|
|
964
|
+
return new Promise((resolve2, reject) => {
|
|
965
|
+
const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "activate-service", request })}
|
|
966
|
+
`));
|
|
967
|
+
let buffer = "";
|
|
968
|
+
socket.setTimeout(timeoutMs, () => {
|
|
969
|
+
socket.destroy();
|
|
970
|
+
reject(new Error("service helper timed out"));
|
|
971
|
+
});
|
|
972
|
+
socket.on("data", (chunk) => {
|
|
973
|
+
buffer += chunk.toString("utf8");
|
|
974
|
+
const newline = buffer.indexOf(`
|
|
975
|
+
`);
|
|
976
|
+
if (newline < 0)
|
|
977
|
+
return;
|
|
978
|
+
socket.end();
|
|
979
|
+
try {
|
|
980
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
981
|
+
if (!response.ok || !response.state)
|
|
982
|
+
throw new Error(response.error?.message ?? "service helper refused activation");
|
|
983
|
+
resolve2(response.state);
|
|
984
|
+
} catch (cause) {
|
|
985
|
+
reject(cause);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
socket.on("error", reject);
|
|
989
|
+
});
|
|
990
|
+
}
|
|
190
991
|
function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
|
|
191
992
|
validateSoftwareRequirements(requirements);
|
|
192
|
-
return new Promise((
|
|
993
|
+
return new Promise((resolve2, reject) => {
|
|
193
994
|
const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
|
|
194
995
|
`));
|
|
195
996
|
let buffer = "";
|
|
@@ -208,7 +1009,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
208
1009
|
const response = JSON.parse(buffer.slice(0, newline));
|
|
209
1010
|
if (!response.ok || !response.results)
|
|
210
1011
|
throw new Error(response.error?.message ?? "software helper refused the request");
|
|
211
|
-
|
|
1012
|
+
resolve2(response.results);
|
|
212
1013
|
} catch (cause) {
|
|
213
1014
|
reject(cause);
|
|
214
1015
|
}
|
|
@@ -219,6 +1020,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
219
1020
|
export {
|
|
220
1021
|
startSoftwareHelper,
|
|
221
1022
|
requestSoftware,
|
|
1023
|
+
requestServiceActivation,
|
|
222
1024
|
SOFTWARE_HELPER_UNIT_PATH,
|
|
223
1025
|
SOFTWARE_HELPER_GROUP,
|
|
224
1026
|
DEFAULT_SOFTWARE_HELPER_SOCKET
|