@forgezero/agent 0.1.40 → 0.1.42
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 +573 -424
- 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 +1504 -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 +9162 -7564
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +5679 -5077
- 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 +145 -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 +5704 -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
|
@@ -284,6 +284,51 @@ var AGENT_CREDENTIAL_LOCATIONS = [
|
|
|
284
284
|
"platform-compute",
|
|
285
285
|
"tenant-compute"
|
|
286
286
|
];
|
|
287
|
+
var CLOUDFLARE_CREDENTIAL_NAMES = {
|
|
288
|
+
api: "CF_API_TOKEN",
|
|
289
|
+
tunnel: "CF_TUNNEL_TOKEN",
|
|
290
|
+
connector: "CF_TUNNEL_CONNECTOR_TOKEN",
|
|
291
|
+
realtimePublish: "REALTIME_PUBLISH_SECRET",
|
|
292
|
+
realtimeTicket: "REALTIME_TICKET_SECRET"
|
|
293
|
+
};
|
|
294
|
+
var CLOUDFLARE_CREDENTIAL_SCHEMA = {
|
|
295
|
+
CF_TUNNEL_TOKEN: {
|
|
296
|
+
permissions: [
|
|
297
|
+
"Account:Cloudflare Tunnel Write",
|
|
298
|
+
"Account:Cloudflare One Connector: WARP Write",
|
|
299
|
+
"Account:Cloudflare One Networks Write",
|
|
300
|
+
"Account:Zero Trust Write",
|
|
301
|
+
"Zone:DNS Write"
|
|
302
|
+
],
|
|
303
|
+
platformBootstrap: "attended-file",
|
|
304
|
+
platformRuntime: "vault",
|
|
305
|
+
tenantControl: "vault"
|
|
306
|
+
},
|
|
307
|
+
CF_API_TOKEN: {
|
|
308
|
+
permissions: ["Account:Workers KV Storage Write", "Account:Workers Scripts Write"],
|
|
309
|
+
platformBootstrap: "attended-file",
|
|
310
|
+
platformRuntime: "vault-then-systemd",
|
|
311
|
+
tenantControl: "vault"
|
|
312
|
+
},
|
|
313
|
+
CF_TUNNEL_CONNECTOR_TOKEN: {
|
|
314
|
+
permissions: ["one named Tunnel connector"],
|
|
315
|
+
platformBootstrap: "derived",
|
|
316
|
+
platformRuntime: "systemd",
|
|
317
|
+
tenantControl: "derived"
|
|
318
|
+
},
|
|
319
|
+
REALTIME_PUBLISH_SECRET: {
|
|
320
|
+
permissions: ["Worker realtime publish endpoint"],
|
|
321
|
+
platformBootstrap: "generated-and-installed-worker-secret",
|
|
322
|
+
platformRuntime: "vault-then-systemd",
|
|
323
|
+
tenantControl: "vault"
|
|
324
|
+
},
|
|
325
|
+
REALTIME_TICKET_SECRET: {
|
|
326
|
+
permissions: ["Worker realtime subscription tickets"],
|
|
327
|
+
platformBootstrap: "generated-and-installed-worker-secret",
|
|
328
|
+
platformRuntime: "vault-then-systemd",
|
|
329
|
+
tenantControl: "vault"
|
|
330
|
+
}
|
|
331
|
+
};
|
|
287
332
|
var AGENT_CREDENTIAL_POLICY = {
|
|
288
333
|
operator: { vault: false, systemdFallback: false, attendedFile: true },
|
|
289
334
|
metal: { vault: false, systemdFallback: true, attendedFile: false },
|
|
@@ -330,6 +375,8 @@ export {
|
|
|
330
375
|
deploymentCredentialSchema,
|
|
331
376
|
credentialBinding,
|
|
332
377
|
METAL_SYSTEMD_CREDENTIALS,
|
|
378
|
+
CLOUDFLARE_CREDENTIAL_SCHEMA,
|
|
379
|
+
CLOUDFLARE_CREDENTIAL_NAMES,
|
|
333
380
|
AGENT_CREDENTIAL_POLICY,
|
|
334
381
|
AGENT_CREDENTIAL_LOCATIONS
|
|
335
382
|
};
|
package/dist/definition.d.ts
CHANGED
|
@@ -2,17 +2,42 @@ import type { Pipeline, PipelineStep } from './pipeline';
|
|
|
2
2
|
import { type DeploymentChannel, type SoftwareRequirement } from './software';
|
|
3
3
|
import { type CapacityCalibrationOptions } from './capacity-calibration';
|
|
4
4
|
/**
|
|
5
|
-
* Version
|
|
6
|
-
*
|
|
7
|
-
* counts and cluster leadership never belong in
|
|
5
|
+
* Version three keeps fleet coordinates out of Git and replaces shell command
|
|
6
|
+
* strings with exact argv vectors. A target chooses one named profile in the
|
|
7
|
+
* control plane; compute names, counts and cluster leadership never belong in
|
|
8
|
+
* the repository definition.
|
|
8
9
|
*/
|
|
9
|
-
export declare const PIPELINE_VERSION:
|
|
10
|
-
export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-
|
|
10
|
+
export declare const PIPELINE_VERSION: 3;
|
|
11
|
+
export declare const DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
|
|
11
12
|
export interface PipelineProfile {
|
|
12
13
|
software: readonly SoftwareRequirement[];
|
|
14
|
+
/** Optional Agent-supervised HTTP service behind one stable nginx port. */
|
|
15
|
+
service?: DeploymentService;
|
|
13
16
|
/** Explicit opt-in, provider-neutral loopback GET probe run after target health. */
|
|
14
17
|
capacityCalibration?: CapacityCalibrationOptions;
|
|
15
18
|
}
|
|
19
|
+
export type DeploymentPortAllocation = {
|
|
20
|
+
mode: 'fixed';
|
|
21
|
+
ports: readonly number[];
|
|
22
|
+
} | {
|
|
23
|
+
mode: 'dynamic';
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
};
|
|
27
|
+
export interface DeploymentService {
|
|
28
|
+
strategy: 'direct' | 'blue-green';
|
|
29
|
+
/** Stable loopback ingress used by Tunnel/nginx consumers. */
|
|
30
|
+
publicPort: number;
|
|
31
|
+
/** One application port for direct, two for blue-green. */
|
|
32
|
+
applicationPorts: DeploymentPortAllocation;
|
|
33
|
+
/** Exact argv. `${FZ_APP_PORT}` and `${FZ_RELEASE}` are materialized by the Agent. */
|
|
34
|
+
command: readonly string[];
|
|
35
|
+
healthPath: string;
|
|
36
|
+
/** Optional node-wide active connection ceiling enforced by nginx with HTTP 503. */
|
|
37
|
+
maxConnections?: number;
|
|
38
|
+
websocket?: boolean;
|
|
39
|
+
drainMs?: number;
|
|
40
|
+
}
|
|
16
41
|
export interface DeployStep extends PipelineStep {
|
|
17
42
|
phase: 'build' | 'release' | 'migrate' | 'health';
|
|
18
43
|
/** Run on this target, or on the one deterministic release executor. */
|
|
@@ -32,6 +57,7 @@ export interface DeployDefinition {
|
|
|
32
57
|
export declare class DefinitionError extends Error {
|
|
33
58
|
constructor(message: string);
|
|
34
59
|
}
|
|
60
|
+
export declare function validateDeploymentService(value: unknown, where?: string): DeploymentService;
|
|
35
61
|
/** Validate parsed JSON before any command from it is allowed to run. */
|
|
36
62
|
export declare function parseDeployDefinition(value: unknown, options?: {
|
|
37
63
|
channel?: DeploymentChannel;
|
package/dist/definition.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 });
|
|
@@ -227,8 +352,8 @@ async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
|
227
352
|
}
|
|
228
353
|
|
|
229
354
|
// src/definition.ts
|
|
230
|
-
var PIPELINE_VERSION =
|
|
231
|
-
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-
|
|
355
|
+
var PIPELINE_VERSION = 3;
|
|
356
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
|
|
232
357
|
|
|
233
358
|
class DefinitionError extends Error {
|
|
234
359
|
constructor(message) {
|
|
@@ -248,6 +373,29 @@ var text = (value, where) => {
|
|
|
248
373
|
}
|
|
249
374
|
return value;
|
|
250
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
|
+
};
|
|
251
399
|
var exactKeys = (value, allowed, where) => {
|
|
252
400
|
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
253
401
|
if (unknown.length > 0)
|
|
@@ -313,6 +461,76 @@ function capacityCalibration(value, where) {
|
|
|
313
461
|
}
|
|
314
462
|
return parsed;
|
|
315
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
|
+
}
|
|
316
534
|
function parseDeployDefinition(value, options = {}) {
|
|
317
535
|
const root = record(value, "pipeline");
|
|
318
536
|
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
@@ -330,20 +548,28 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
330
548
|
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
331
549
|
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
332
550
|
}
|
|
333
|
-
if (!Array.isArray(root.steps) || root.steps.length === 0) {
|
|
334
|
-
throw new DefinitionError("pipeline.steps must contain
|
|
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.");
|
|
335
553
|
}
|
|
336
554
|
const profiles = {};
|
|
337
555
|
for (const [name2, raw] of profileEntries) {
|
|
338
556
|
if (!NAME.test(name2))
|
|
339
557
|
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
340
558
|
const profile = record(raw, `profiles.${name2}`);
|
|
341
|
-
exactKeys(profile, ["software", "capacityCalibration"], `profiles.${name2}`);
|
|
559
|
+
exactKeys(profile, ["software", "service", "capacityCalibration"], `profiles.${name2}`);
|
|
342
560
|
if (!Array.isArray(profile.software)) {
|
|
343
561
|
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
344
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
|
+
}
|
|
345
568
|
profiles[name2] = {
|
|
346
|
-
software
|
|
569
|
+
software,
|
|
570
|
+
...profile.service === undefined ? {} : {
|
|
571
|
+
service
|
|
572
|
+
},
|
|
347
573
|
...profile.capacityCalibration === undefined ? {} : {
|
|
348
574
|
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
349
575
|
}
|
|
@@ -352,7 +578,7 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
352
578
|
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
353
579
|
const steps = root.steps.map((raw, index) => {
|
|
354
580
|
const step = record(raw, `steps[${index}]`);
|
|
355
|
-
exactKeys(step, ["name", "
|
|
581
|
+
exactKeys(step, ["name", "exec", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
356
582
|
const phase = text(step.phase, `steps[${index}].phase`);
|
|
357
583
|
if (!phases.has(phase))
|
|
358
584
|
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
@@ -400,7 +626,7 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
400
626
|
}
|
|
401
627
|
return {
|
|
402
628
|
name: text(step.name, `steps[${index}].name`),
|
|
403
|
-
|
|
629
|
+
exec: argv(step.exec, `steps[${index}].exec`),
|
|
404
630
|
phase,
|
|
405
631
|
scope: step.scope,
|
|
406
632
|
profiles: selectedProfiles,
|
|
@@ -440,6 +666,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
|
440
666
|
};
|
|
441
667
|
}
|
|
442
668
|
export {
|
|
669
|
+
validateDeploymentService,
|
|
443
670
|
phasePipeline,
|
|
444
671
|
parseDeployDefinition,
|
|
445
672
|
PIPELINE_VERSION,
|