@forgezero/agent 0.1.41 → 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 +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 +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 +3932 -582
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +3634 -1266
- 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
package/dist/provision.js
CHANGED
|
@@ -33,6 +33,7 @@ var syncReleaseDirectory = (directory) => {
|
|
|
33
33
|
join(directory, "package.json"),
|
|
34
34
|
join(directory, "dist", "fz-agent.js"),
|
|
35
35
|
join(directory, "dist", "fz.js"),
|
|
36
|
+
join(directory, "dist", "fz-git-ssh.js"),
|
|
36
37
|
join(directory, "dist"),
|
|
37
38
|
directory,
|
|
38
39
|
dirname(directory)
|
|
@@ -101,7 +102,8 @@ async function validateReleaseDirectory(directory, release, run) {
|
|
|
101
102
|
}
|
|
102
103
|
const agent = join(directory, "dist", "fz-agent.js");
|
|
103
104
|
const cli = join(directory, "dist", "fz.js");
|
|
104
|
-
|
|
105
|
+
const gitSsh = join(directory, "dist", "fz-git-ssh.js");
|
|
106
|
+
for (const binary of [agent, cli, gitSsh]) {
|
|
105
107
|
if (!readFileSync(binary, "utf8").startsWith(`#!/usr/bin/env bun
|
|
106
108
|
`)) {
|
|
107
109
|
throw new Error("agent update artifact is not a self-contained Bun executable");
|
|
@@ -148,7 +150,8 @@ async function stageAgentRelease(releaseInput, options) {
|
|
|
148
150
|
for (const [member, relative] of [
|
|
149
151
|
["package/package.json", "package.json"],
|
|
150
152
|
["package/dist/fz-agent.js", "dist/fz-agent.js"],
|
|
151
|
-
["package/dist/fz.js", "dist/fz.js"]
|
|
153
|
+
["package/dist/fz.js", "dist/fz.js"],
|
|
154
|
+
["package/dist/fz-git-ssh.js", "dist/fz-git-ssh.js"]
|
|
152
155
|
]) {
|
|
153
156
|
const extracted = await checked(run, {
|
|
154
157
|
command: "/usr/bin/tar",
|
|
@@ -674,9 +677,24 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
|
|
|
674
677
|
}
|
|
675
678
|
|
|
676
679
|
// src/software.ts
|
|
677
|
-
import {
|
|
680
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
681
|
+
import {
|
|
682
|
+
accessSync,
|
|
683
|
+
chmodSync as chmodSync3,
|
|
684
|
+
copyFileSync,
|
|
685
|
+
mkdtempSync,
|
|
686
|
+
mkdirSync as mkdirSync3,
|
|
687
|
+
readFileSync as readFileSync3,
|
|
688
|
+
renameSync as renameSync3,
|
|
689
|
+
rmSync as rmSync3,
|
|
690
|
+
symlinkSync as symlinkSync2,
|
|
691
|
+
unlinkSync as unlinkSync2,
|
|
692
|
+
writeFileSync as writeFileSync3
|
|
693
|
+
} from "node:fs";
|
|
694
|
+
import { tmpdir } from "node:os";
|
|
695
|
+
import { join as join3 } from "node:path";
|
|
678
696
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
679
|
-
var
|
|
697
|
+
var BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f";
|
|
680
698
|
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
681
699
|
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
682
700
|
var OS_CATALOG = [
|
|
@@ -687,41 +705,151 @@ var SOFTWARE_CATALOG = [
|
|
|
687
705
|
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
688
706
|
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
689
707
|
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
708
|
+
{ id: "cloudflare-warp", version: "2026.6.822.0-min", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
690
709
|
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
691
710
|
{ id: "openssh-client", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
692
711
|
];
|
|
693
712
|
var UBUNTU_2604_X64 = [
|
|
694
|
-
{
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
},
|
|
699
|
-
{
|
|
700
|
-
|
|
701
|
-
check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
|
|
702
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
|
|
703
|
-
},
|
|
704
|
-
{
|
|
705
|
-
requirement: { id: "arangodb", version: "3.11.14" },
|
|
706
|
-
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`,
|
|
707
|
-
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`
|
|
708
|
-
},
|
|
709
|
-
{
|
|
710
|
-
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
711
|
-
check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
|
|
712
|
-
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`
|
|
713
|
-
},
|
|
714
|
-
{
|
|
715
|
-
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
716
|
-
check: "command -v ufw >/dev/null",
|
|
717
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
718
|
-
},
|
|
719
|
-
{
|
|
720
|
-
requirement: { id: "openssh-client", version: "ubuntu-26.04" },
|
|
721
|
-
check: "command -v ssh >/dev/null && command -v scp >/dev/null && command -v ssh-keyscan >/dev/null && command -v ssh-keygen >/dev/null",
|
|
722
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
|
|
723
|
-
}
|
|
713
|
+
{ requirement: { id: "bun", version: "1.3.14" } },
|
|
714
|
+
{ requirement: { id: "nginx", version: "ubuntu-26.04" } },
|
|
715
|
+
{ requirement: { id: "arangodb", version: "3.11.14" } },
|
|
716
|
+
{ requirement: { id: "cloudflared", version: "2026.7.3" } },
|
|
717
|
+
{ requirement: { id: "cloudflare-warp", version: "2026.6.822.0-min" } },
|
|
718
|
+
{ requirement: { id: "ufw", version: "ubuntu-26.04" } },
|
|
719
|
+
{ requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
|
|
724
720
|
];
|
|
721
|
+
var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
722
|
+
var run = async (argv, env = {}) => {
|
|
723
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: path, LANG: "C", LC_ALL: "C", ...env } });
|
|
724
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
725
|
+
new Response(child.stdout).text(),
|
|
726
|
+
new Response(child.stderr).text(),
|
|
727
|
+
child.exited
|
|
728
|
+
]);
|
|
729
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
730
|
+
};
|
|
731
|
+
var download = async (url, destination, sha256) => {
|
|
732
|
+
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120000) });
|
|
733
|
+
if (!response.ok)
|
|
734
|
+
throw new Error(`download failed with HTTP ${response.status}`);
|
|
735
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
736
|
+
if (createHash2("sha256").update(bytes).digest("hex") !== sha256)
|
|
737
|
+
throw new Error("download checksum mismatch");
|
|
738
|
+
writeFileSync3(destination, bytes, { mode: 384, flag: "wx" });
|
|
739
|
+
};
|
|
740
|
+
var successful = (result, pattern) => result.exitCode === 0 && (!pattern || pattern.test(result.output));
|
|
741
|
+
var aptInstall = async (name) => {
|
|
742
|
+
const environment = { DEBIAN_FRONTEND: "noninteractive" };
|
|
743
|
+
const update = await run(["/usr/bin/apt-get", "update", "-qq"], environment);
|
|
744
|
+
return update.exitCode === 0 ? run(["/usr/bin/apt-get", "install", "-y", name], environment) : update;
|
|
745
|
+
};
|
|
746
|
+
async function executeSoftwareOperation(operation) {
|
|
747
|
+
const { software, version } = operation;
|
|
748
|
+
if (!UBUNTU_2604_X64.some(({ requirement }) => requirement.id === software && requirement.version === version)) {
|
|
749
|
+
return { exitCode: 2, output: "unsupported software operation" };
|
|
750
|
+
}
|
|
751
|
+
if (operation.kind === "check") {
|
|
752
|
+
if (software === "bun")
|
|
753
|
+
return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
|
|
754
|
+
if (software === "nginx") {
|
|
755
|
+
const binary = await run(["/usr/sbin/nginx", "-v"]);
|
|
756
|
+
return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
|
|
757
|
+
}
|
|
758
|
+
if (software === "arangodb") {
|
|
759
|
+
const binary = await run(["/usr/bin/arangod", "--version"]);
|
|
760
|
+
if (!successful(binary, /3\.11\.14/))
|
|
761
|
+
return { ...binary, exitCode: 1 };
|
|
762
|
+
const [active, enabled] = await Promise.all([
|
|
763
|
+
run(["/usr/bin/systemctl", "is-active", "--quiet", "arangodb3.service"]),
|
|
764
|
+
run(["/usr/bin/systemctl", "is-enabled", "--quiet", "arangodb3.service"])
|
|
765
|
+
]);
|
|
766
|
+
return active.exitCode !== 0 && enabled.exitCode !== 0 ? { exitCode: 0, output: binary.output } : { exitCode: 1, output: "vendor standalone unit remains active or enabled" };
|
|
767
|
+
}
|
|
768
|
+
if (software === "cloudflared")
|
|
769
|
+
return run(["/usr/local/bin/cloudflared", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /2026\.7\.3/) ? 0 : 1 }));
|
|
770
|
+
if (software === "cloudflare-warp")
|
|
771
|
+
return run(["/usr/bin/warp-cli", "--version"]).then((result) => {
|
|
772
|
+
const match = result.output.match(/(\d{4})\.(\d+)\.(\d+)\.(\d+)/);
|
|
773
|
+
const observed = match?.slice(1).map(Number);
|
|
774
|
+
const minimum = [2026, 6, 822, 0];
|
|
775
|
+
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]);
|
|
776
|
+
return { ...result, exitCode: result.exitCode === 0 && supported ? 0 : 1 };
|
|
777
|
+
});
|
|
778
|
+
const binaries = software === "ufw" ? ["/usr/sbin/ufw"] : ["/usr/bin/ssh", "/usr/bin/scp", "/usr/bin/ssh-keyscan", "/usr/bin/ssh-keygen"];
|
|
779
|
+
try {
|
|
780
|
+
binaries.forEach((binary) => accessSync(binary));
|
|
781
|
+
return { exitCode: 0, output: "" };
|
|
782
|
+
} catch (cause) {
|
|
783
|
+
return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (software === "nginx" || software === "ufw" || software === "openssh-client") {
|
|
787
|
+
const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
|
|
788
|
+
if (installed.exitCode !== 0 || software !== "nginx")
|
|
789
|
+
return installed;
|
|
790
|
+
return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
|
|
791
|
+
}
|
|
792
|
+
const directory = mkdtempSync(join3(tmpdir(), "forgezero-software-"));
|
|
793
|
+
try {
|
|
794
|
+
if (software === "cloudflare-warp") {
|
|
795
|
+
const key = join3(directory, "cloudflare-warp-key.gpg");
|
|
796
|
+
await download("https://pkg.cloudflareclient.com/pubkey.gpg", key, "0f37fc298c98e88ee3c0ee68c95b69f1dba9eb477abe3167e13982105911264d");
|
|
797
|
+
mkdirSync3("/usr/share/keyrings", { recursive: true, mode: 493 });
|
|
798
|
+
const dearmored = await run([
|
|
799
|
+
"/usr/bin/gpg",
|
|
800
|
+
"--batch",
|
|
801
|
+
"--yes",
|
|
802
|
+
"--dearmor",
|
|
803
|
+
"-o",
|
|
804
|
+
"/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg",
|
|
805
|
+
key
|
|
806
|
+
]);
|
|
807
|
+
if (dearmored.exitCode !== 0)
|
|
808
|
+
return dearmored;
|
|
809
|
+
mkdirSync3("/etc/apt/sources.list.d", { recursive: true, mode: 493 });
|
|
810
|
+
writeFileSync3("/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
|
|
811
|
+
`, { mode: 420 });
|
|
812
|
+
return aptInstall("cloudflare-warp");
|
|
813
|
+
}
|
|
814
|
+
if (software === "bun") {
|
|
815
|
+
const archive = join3(directory, "bun.zip");
|
|
816
|
+
await download("https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip", archive, BUN_RELEASE_SHA256);
|
|
817
|
+
const unpacked = join3(directory, "unpacked");
|
|
818
|
+
mkdirSync3(unpacked, { mode: 448 });
|
|
819
|
+
const unzipped = await run(["/usr/bin/unzip", "-q", archive, "-d", unpacked]);
|
|
820
|
+
if (unzipped.exitCode !== 0)
|
|
821
|
+
return unzipped;
|
|
822
|
+
mkdirSync3("/usr/local/lib/forgezero/runtime", { recursive: true, mode: 493 });
|
|
823
|
+
copyFileSync(join3(unpacked, "bun-linux-x64", "bun"), "/usr/local/lib/forgezero/runtime/bun.next");
|
|
824
|
+
chmodSync3("/usr/local/lib/forgezero/runtime/bun.next", 493);
|
|
825
|
+
renameSync3("/usr/local/lib/forgezero/runtime/bun.next", "/usr/local/lib/forgezero/runtime/bun");
|
|
826
|
+
try {
|
|
827
|
+
unlinkSync2("/usr/local/bin/bun");
|
|
828
|
+
} catch {}
|
|
829
|
+
symlinkSync2("/usr/local/lib/forgezero/runtime/bun", "/usr/local/bin/bun");
|
|
830
|
+
return { exitCode: 0, output: "" };
|
|
831
|
+
}
|
|
832
|
+
if (software === "cloudflared") {
|
|
833
|
+
const binary = join3(directory, "cloudflared");
|
|
834
|
+
await download("https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64", binary, CLOUDFLARED_SHA256);
|
|
835
|
+
chmodSync3(binary, 493);
|
|
836
|
+
copyFileSync(binary, "/usr/local/bin/cloudflared");
|
|
837
|
+
chmodSync3("/usr/local/bin/cloudflared", 493);
|
|
838
|
+
return { exitCode: 0, output: "" };
|
|
839
|
+
}
|
|
840
|
+
const deb = join3(directory, "arangodb.deb");
|
|
841
|
+
await download("https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb", deb, ARANGO_SHA256);
|
|
842
|
+
let installed = await run(["/usr/bin/dpkg", "-i", deb], { DEBIAN_FRONTEND: "noninteractive" });
|
|
843
|
+
if (installed.exitCode !== 0)
|
|
844
|
+
installed = await run(["/usr/bin/apt-get", "-y", "-f", "install"], { DEBIAN_FRONTEND: "noninteractive" });
|
|
845
|
+
if (installed.exitCode !== 0)
|
|
846
|
+
return installed;
|
|
847
|
+
await run(["/usr/bin/systemctl", "disable", "--now", "arangodb3.service"]);
|
|
848
|
+
return { exitCode: 0, output: "" };
|
|
849
|
+
} finally {
|
|
850
|
+
rmSync3(directory, { recursive: true, force: true });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
725
853
|
function observeSoftwareHost(osRelease = readFileSync3("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
726
854
|
const values = Object.fromEntries(osRelease.split(`
|
|
727
855
|
`).flatMap((line) => {
|
|
@@ -744,7 +872,7 @@ function validateSoftwareRequirements(value, _options = {}) {
|
|
|
744
872
|
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
745
873
|
throw new Error("software requirement contains an unknown field");
|
|
746
874
|
}
|
|
747
|
-
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)) {
|
|
875
|
+
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)) {
|
|
748
876
|
throw new Error("software requirement coordinate is invalid");
|
|
749
877
|
}
|
|
750
878
|
const requirement = { id: row.id, version: row.version };
|
|
@@ -770,15 +898,15 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
770
898
|
const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
771
899
|
if (!strategy)
|
|
772
900
|
throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
|
|
773
|
-
const before = await options.exec(
|
|
901
|
+
const before = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
774
902
|
if (before.exitCode === 0) {
|
|
775
903
|
results.push({ ...requirement, changed: false });
|
|
776
904
|
continue;
|
|
777
905
|
}
|
|
778
|
-
const installed = await options.exec(
|
|
906
|
+
const installed = await options.exec({ kind: "install", software: requirement.id, version: requirement.version });
|
|
779
907
|
if (installed.exitCode !== 0)
|
|
780
908
|
throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
|
|
781
|
-
const after = await options.exec(
|
|
909
|
+
const after = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
782
910
|
if (after.exitCode !== 0)
|
|
783
911
|
throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
|
|
784
912
|
results.push({ ...requirement, changed: true });
|
|
@@ -786,34 +914,671 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
786
914
|
return results;
|
|
787
915
|
}
|
|
788
916
|
|
|
917
|
+
// src/capacity-calibration.ts
|
|
918
|
+
var percentile95 = (values) => {
|
|
919
|
+
if (values.length === 0)
|
|
920
|
+
return Number.POSITIVE_INFINITY;
|
|
921
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
922
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
|
|
923
|
+
};
|
|
924
|
+
function localCalibrationEndpoint(value) {
|
|
925
|
+
const endpoint = new URL(value);
|
|
926
|
+
if (endpoint.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || !endpoint.port || endpoint.username || endpoint.password || endpoint.hash) {
|
|
927
|
+
throw new Error("Capacity calibration requires an explicit loopback HTTP endpoint and port.");
|
|
928
|
+
}
|
|
929
|
+
return endpoint;
|
|
930
|
+
}
|
|
931
|
+
function validateCapacityCalibrationOptions(options) {
|
|
932
|
+
const endpoint = localCalibrationEndpoint(options.endpoint);
|
|
933
|
+
const maxConcurrency = options.maxConcurrency ?? 256;
|
|
934
|
+
const requestsPerWorker = options.requestsPerWorker ?? 8;
|
|
935
|
+
const maxP95Ms = options.maxP95Ms ?? 250;
|
|
936
|
+
const maxErrorRate = options.maxErrorRate ?? 0.01;
|
|
937
|
+
const headroomRatio = options.headroomRatio ?? 0.8;
|
|
938
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 5000;
|
|
939
|
+
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) {
|
|
940
|
+
throw new Error("Capacity calibration bounds are invalid.");
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
endpoint,
|
|
944
|
+
maxConcurrency,
|
|
945
|
+
requestsPerWorker,
|
|
946
|
+
maxP95Ms,
|
|
947
|
+
maxErrorRate,
|
|
948
|
+
headroomRatio,
|
|
949
|
+
requestTimeoutMs
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
async function stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher) {
|
|
953
|
+
const latencies = [];
|
|
954
|
+
let succeeded = 0;
|
|
955
|
+
let failed = 0;
|
|
956
|
+
let overloaded = 0;
|
|
957
|
+
const started = performance.now();
|
|
958
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
959
|
+
for (let request = 0;request < requestsPerWorker; request += 1) {
|
|
960
|
+
const requestStarted = performance.now();
|
|
961
|
+
try {
|
|
962
|
+
const response = await fetcher(endpoint, {
|
|
963
|
+
method: "GET",
|
|
964
|
+
headers: { accept: "application/json", "user-agent": "forgezero-capacity-calibration/1" },
|
|
965
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
966
|
+
redirect: "error"
|
|
967
|
+
});
|
|
968
|
+
await response.body?.cancel();
|
|
969
|
+
if (response.ok)
|
|
970
|
+
succeeded += 1;
|
|
971
|
+
else {
|
|
972
|
+
failed += 1;
|
|
973
|
+
if (response.status === 503)
|
|
974
|
+
overloaded += 1;
|
|
975
|
+
}
|
|
976
|
+
} catch {
|
|
977
|
+
failed += 1;
|
|
978
|
+
} finally {
|
|
979
|
+
latencies.push(performance.now() - requestStarted);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}));
|
|
983
|
+
const elapsedSeconds = Math.max((performance.now() - started) / 1000, 0.001);
|
|
984
|
+
return {
|
|
985
|
+
concurrency,
|
|
986
|
+
requests: concurrency * requestsPerWorker,
|
|
987
|
+
succeeded,
|
|
988
|
+
failed,
|
|
989
|
+
overloaded,
|
|
990
|
+
throughputPerSecond: Number(((succeeded + failed) / elapsedSeconds).toFixed(2)),
|
|
991
|
+
p95Ms: Number(percentile95(latencies).toFixed(2))
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
995
|
+
const {
|
|
996
|
+
endpoint,
|
|
997
|
+
maxConcurrency,
|
|
998
|
+
requestsPerWorker,
|
|
999
|
+
maxP95Ms,
|
|
1000
|
+
maxErrorRate,
|
|
1001
|
+
headroomRatio,
|
|
1002
|
+
requestTimeoutMs: timeoutMs
|
|
1003
|
+
} = validateCapacityCalibrationOptions(options);
|
|
1004
|
+
const stages = [];
|
|
1005
|
+
let lastSafe = 1;
|
|
1006
|
+
let stopReason = "maximum-tested";
|
|
1007
|
+
for (let concurrency = 1;; concurrency = Math.min(maxConcurrency, concurrency * 2)) {
|
|
1008
|
+
const measured = await stage(endpoint, concurrency, requestsPerWorker, timeoutMs, fetcher);
|
|
1009
|
+
stages.push(measured);
|
|
1010
|
+
const errorRate = measured.failed / measured.requests;
|
|
1011
|
+
const previous = stages.at(-2);
|
|
1012
|
+
const throughputRegressed = Boolean(previous && concurrency > 1 && measured.throughputPerSecond < previous.throughputPerSecond * 0.9);
|
|
1013
|
+
if (measured.overloaded > 0 || errorRate > maxErrorRate)
|
|
1014
|
+
stopReason = "errors";
|
|
1015
|
+
else if (measured.p95Ms > maxP95Ms)
|
|
1016
|
+
stopReason = "latency";
|
|
1017
|
+
else if (throughputRegressed)
|
|
1018
|
+
stopReason = "throughput-regression";
|
|
1019
|
+
else
|
|
1020
|
+
lastSafe = concurrency;
|
|
1021
|
+
if (stopReason !== "maximum-tested" || concurrency === maxConcurrency)
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
endpoint: endpoint.toString(),
|
|
1026
|
+
recommendedConcurrency: Math.max(1, Math.floor(lastSafe * headroomRatio)),
|
|
1027
|
+
stopReason,
|
|
1028
|
+
stages
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/definition.ts
|
|
1033
|
+
var PIPELINE_VERSION = 3;
|
|
1034
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
|
|
1035
|
+
|
|
1036
|
+
class DefinitionError extends Error {
|
|
1037
|
+
constructor(message) {
|
|
1038
|
+
super(message);
|
|
1039
|
+
this.name = "DefinitionError";
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
var record = (value, where) => {
|
|
1043
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1044
|
+
throw new DefinitionError(`${where} must be an object.`);
|
|
1045
|
+
}
|
|
1046
|
+
return value;
|
|
1047
|
+
};
|
|
1048
|
+
var text = (value, where) => {
|
|
1049
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1050
|
+
throw new DefinitionError(`${where} must be a non-empty string.`);
|
|
1051
|
+
}
|
|
1052
|
+
return value;
|
|
1053
|
+
};
|
|
1054
|
+
var argv = (value, where) => {
|
|
1055
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 256) {
|
|
1056
|
+
throw new DefinitionError(`${where} must contain from 1 to 256 arguments.`);
|
|
1057
|
+
}
|
|
1058
|
+
let bytes = 0;
|
|
1059
|
+
const parsed = value.map((argument, index) => {
|
|
1060
|
+
if (typeof argument !== "string" || argument.length === 0 || argument.length > 16384 || argument.includes("\x00")) {
|
|
1061
|
+
throw new DefinitionError(`${where}[${index}] must be a non-empty bounded string without NUL.`);
|
|
1062
|
+
}
|
|
1063
|
+
if (argument.includes("${") && !/^\$\{FZ_[A-Z0-9_]+\}$/.test(argument)) {
|
|
1064
|
+
throw new DefinitionError(`${where}[${index}] contains unsupported interpolation; only one exact FZ coordinate is allowed.`);
|
|
1065
|
+
}
|
|
1066
|
+
bytes += Buffer.byteLength(argument);
|
|
1067
|
+
if (bytes > 64 * 1024)
|
|
1068
|
+
throw new DefinitionError(`${where} is larger than 64 KiB.`);
|
|
1069
|
+
return argument;
|
|
1070
|
+
});
|
|
1071
|
+
const executable = parsed[0].split("/").at(-1).toLowerCase();
|
|
1072
|
+
if (new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"]).has(executable)) {
|
|
1073
|
+
throw new DefinitionError(`${where}[0] may not invoke a shell or command dispatcher.`);
|
|
1074
|
+
}
|
|
1075
|
+
return parsed;
|
|
1076
|
+
};
|
|
1077
|
+
var exactKeys = (value, allowed, where) => {
|
|
1078
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
1079
|
+
if (unknown.length > 0)
|
|
1080
|
+
throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
|
|
1081
|
+
};
|
|
1082
|
+
var NAME = /^[a-z][a-z0-9-]{0,62}$/;
|
|
1083
|
+
var RESERVED_STEP_ENV = new Set([
|
|
1084
|
+
"PATH",
|
|
1085
|
+
"HOME",
|
|
1086
|
+
"SHELL",
|
|
1087
|
+
"PWD",
|
|
1088
|
+
"BUN_INSTALL",
|
|
1089
|
+
"NODE_OPTIONS",
|
|
1090
|
+
"LD_PRELOAD",
|
|
1091
|
+
"LD_LIBRARY_PATH",
|
|
1092
|
+
"GIT_SSH",
|
|
1093
|
+
"GIT_SSH_COMMAND"
|
|
1094
|
+
]);
|
|
1095
|
+
function capacityCalibration(value, where) {
|
|
1096
|
+
const calibration = record(value, where);
|
|
1097
|
+
exactKeys(calibration, [
|
|
1098
|
+
"endpoint",
|
|
1099
|
+
"maxConcurrency",
|
|
1100
|
+
"requestsPerWorker",
|
|
1101
|
+
"maxP95Ms",
|
|
1102
|
+
"maxErrorRate",
|
|
1103
|
+
"headroomRatio",
|
|
1104
|
+
"requestTimeoutMs"
|
|
1105
|
+
], where);
|
|
1106
|
+
const endpoint = text(calibration.endpoint, `${where}.endpoint`);
|
|
1107
|
+
try {
|
|
1108
|
+
localCalibrationEndpoint(endpoint);
|
|
1109
|
+
} catch (cause) {
|
|
1110
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where}.endpoint is invalid.`);
|
|
1111
|
+
}
|
|
1112
|
+
const optionalNumber = (name) => {
|
|
1113
|
+
const raw = calibration[name];
|
|
1114
|
+
if (raw === undefined)
|
|
1115
|
+
return;
|
|
1116
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
1117
|
+
throw new DefinitionError(`${where}.${name} must be a finite number.`);
|
|
1118
|
+
}
|
|
1119
|
+
return raw;
|
|
1120
|
+
};
|
|
1121
|
+
const parsed = {
|
|
1122
|
+
endpoint,
|
|
1123
|
+
...Object.fromEntries([
|
|
1124
|
+
"maxConcurrency",
|
|
1125
|
+
"requestsPerWorker",
|
|
1126
|
+
"maxP95Ms",
|
|
1127
|
+
"maxErrorRate",
|
|
1128
|
+
"headroomRatio",
|
|
1129
|
+
"requestTimeoutMs"
|
|
1130
|
+
].flatMap((name) => {
|
|
1131
|
+
const found = optionalNumber(name);
|
|
1132
|
+
return found === undefined ? [] : [[name, found]];
|
|
1133
|
+
}))
|
|
1134
|
+
};
|
|
1135
|
+
try {
|
|
1136
|
+
validateCapacityCalibrationOptions(parsed);
|
|
1137
|
+
} catch (cause) {
|
|
1138
|
+
throw new DefinitionError(cause instanceof Error ? cause.message : `${where} bounds are invalid.`);
|
|
1139
|
+
}
|
|
1140
|
+
return parsed;
|
|
1141
|
+
}
|
|
1142
|
+
function validateDeploymentService(value, where = "service") {
|
|
1143
|
+
const service = record(value, where);
|
|
1144
|
+
exactKeys(service, [
|
|
1145
|
+
"strategy",
|
|
1146
|
+
"publicPort",
|
|
1147
|
+
"applicationPorts",
|
|
1148
|
+
"command",
|
|
1149
|
+
"healthPath",
|
|
1150
|
+
"maxConnections",
|
|
1151
|
+
"websocket",
|
|
1152
|
+
"drainMs"
|
|
1153
|
+
], where);
|
|
1154
|
+
if (service.strategy !== "direct" && service.strategy !== "blue-green") {
|
|
1155
|
+
throw new DefinitionError(`${where}.strategy must be direct or blue-green.`);
|
|
1156
|
+
}
|
|
1157
|
+
const publicPort = Number(service.publicPort);
|
|
1158
|
+
if (!Number.isSafeInteger(publicPort) || publicPort < 1024 || publicPort > 65535) {
|
|
1159
|
+
throw new DefinitionError(`${where}.publicPort must be an unprivileged TCP port.`);
|
|
1160
|
+
}
|
|
1161
|
+
const allocation = record(service.applicationPorts, `${where}.applicationPorts`);
|
|
1162
|
+
let applicationPorts;
|
|
1163
|
+
const required = service.strategy === "blue-green" ? 2 : 1;
|
|
1164
|
+
if (allocation.mode === "fixed") {
|
|
1165
|
+
exactKeys(allocation, ["mode", "ports"], `${where}.applicationPorts`);
|
|
1166
|
+
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)) {
|
|
1167
|
+
throw new DefinitionError(`${where}.applicationPorts needs ${required} distinct unprivileged port(s), disjoint from publicPort.`);
|
|
1168
|
+
}
|
|
1169
|
+
applicationPorts = { mode: "fixed", ports: allocation.ports };
|
|
1170
|
+
} else if (allocation.mode === "dynamic") {
|
|
1171
|
+
exactKeys(allocation, ["mode", "from", "to"], `${where}.applicationPorts`);
|
|
1172
|
+
const from = Number(allocation.from);
|
|
1173
|
+
const to = Number(allocation.to);
|
|
1174
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 1024 || to > 65535 || to - from + 1 < required || publicPort >= from && publicPort <= to) {
|
|
1175
|
+
throw new DefinitionError(`${where}.applicationPorts dynamic range is invalid or includes publicPort.`);
|
|
1176
|
+
}
|
|
1177
|
+
applicationPorts = { mode: "dynamic", from, to };
|
|
1178
|
+
} else
|
|
1179
|
+
throw new DefinitionError(`${where}.applicationPorts.mode must be fixed or dynamic.`);
|
|
1180
|
+
const command2 = argv(service.command, `${where}.command`);
|
|
1181
|
+
for (const argument of command2) {
|
|
1182
|
+
const coordinate = argument.match(/^\$\{(FZ_[A-Z0-9_]+)\}$/)?.[1];
|
|
1183
|
+
if (coordinate && !["FZ_APP_PORT", "FZ_RELEASE"].includes(coordinate)) {
|
|
1184
|
+
throw new DefinitionError(`${where}.command uses unsupported coordinate ${coordinate}.`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (typeof service.healthPath !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,255}$/.test(service.healthPath) || service.healthPath.includes("..") || service.healthPath.includes("//")) {
|
|
1188
|
+
throw new DefinitionError(`${where}.healthPath must be one bounded absolute path.`);
|
|
1189
|
+
}
|
|
1190
|
+
if (service.websocket !== undefined && typeof service.websocket !== "boolean") {
|
|
1191
|
+
throw new DefinitionError(`${where}.websocket must be a boolean.`);
|
|
1192
|
+
}
|
|
1193
|
+
const maxConnections = service.maxConnections === undefined ? undefined : Number(service.maxConnections);
|
|
1194
|
+
if (maxConnections !== undefined && (!Number.isSafeInteger(maxConnections) || maxConnections < 1 || maxConnections > 1e6)) {
|
|
1195
|
+
throw new DefinitionError(`${where}.maxConnections must be from 1 to 1000000.`);
|
|
1196
|
+
}
|
|
1197
|
+
const drainMs = service.drainMs === undefined ? 30000 : Number(service.drainMs);
|
|
1198
|
+
if (!Number.isSafeInteger(drainMs) || drainMs < 0 || drainMs > 300000) {
|
|
1199
|
+
throw new DefinitionError(`${where}.drainMs must be from 0 to 300000.`);
|
|
1200
|
+
}
|
|
1201
|
+
return {
|
|
1202
|
+
strategy: service.strategy,
|
|
1203
|
+
publicPort,
|
|
1204
|
+
applicationPorts,
|
|
1205
|
+
command: command2,
|
|
1206
|
+
healthPath: service.healthPath,
|
|
1207
|
+
...maxConnections === undefined ? {} : { maxConnections },
|
|
1208
|
+
websocket: service.websocket === true,
|
|
1209
|
+
drainMs
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
function parseDeployDefinition(value, options = {}) {
|
|
1213
|
+
const root = record(value, "pipeline");
|
|
1214
|
+
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
1215
|
+
if (root.$schema !== undefined && root.$schema !== DEPLOY_SCHEMA_URL) {
|
|
1216
|
+
throw new DefinitionError(`pipeline.$schema must be ${DEPLOY_SCHEMA_URL}.`);
|
|
1217
|
+
}
|
|
1218
|
+
if (root.version !== PIPELINE_VERSION) {
|
|
1219
|
+
throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
|
|
1220
|
+
}
|
|
1221
|
+
if (root.requireAttestation !== undefined && typeof root.requireAttestation !== "boolean") {
|
|
1222
|
+
throw new DefinitionError("pipeline.requireAttestation must be a boolean.");
|
|
1223
|
+
}
|
|
1224
|
+
const rawProfiles = record(root.profiles, "pipeline.profiles");
|
|
1225
|
+
const profileEntries = Object.entries(rawProfiles);
|
|
1226
|
+
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
1227
|
+
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
1228
|
+
}
|
|
1229
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0 || root.steps.length > 256) {
|
|
1230
|
+
throw new DefinitionError("pipeline.steps must contain from 1 to 256 steps.");
|
|
1231
|
+
}
|
|
1232
|
+
const profiles = {};
|
|
1233
|
+
for (const [name2, raw] of profileEntries) {
|
|
1234
|
+
if (!NAME.test(name2))
|
|
1235
|
+
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
1236
|
+
const profile = record(raw, `profiles.${name2}`);
|
|
1237
|
+
exactKeys(profile, ["software", "service", "capacityCalibration"], `profiles.${name2}`);
|
|
1238
|
+
if (!Array.isArray(profile.software)) {
|
|
1239
|
+
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
1240
|
+
}
|
|
1241
|
+
const software = validateSoftwareRequirements(profile.software, options);
|
|
1242
|
+
const service = profile.service === undefined ? undefined : validateDeploymentService(profile.service, `profiles.${name2}.service`);
|
|
1243
|
+
if (service && !software.some(({ id }) => id === "nginx")) {
|
|
1244
|
+
throw new DefinitionError(`profiles.${name2}.service requires the reviewed nginx software strategy.`);
|
|
1245
|
+
}
|
|
1246
|
+
profiles[name2] = {
|
|
1247
|
+
software,
|
|
1248
|
+
...profile.service === undefined ? {} : {
|
|
1249
|
+
service
|
|
1250
|
+
},
|
|
1251
|
+
...profile.capacityCalibration === undefined ? {} : {
|
|
1252
|
+
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
1257
|
+
const steps = root.steps.map((raw, index) => {
|
|
1258
|
+
const step = record(raw, `steps[${index}]`);
|
|
1259
|
+
exactKeys(step, ["name", "exec", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
1260
|
+
const phase = text(step.phase, `steps[${index}].phase`);
|
|
1261
|
+
if (!phases.has(phase))
|
|
1262
|
+
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
1263
|
+
if (step.scope !== "target" && step.scope !== "release") {
|
|
1264
|
+
throw new DefinitionError(`steps[${index}].scope must be target or release.`);
|
|
1265
|
+
}
|
|
1266
|
+
if (step.always !== undefined && typeof step.always !== "boolean") {
|
|
1267
|
+
throw new DefinitionError(`steps[${index}].always must be a boolean.`);
|
|
1268
|
+
}
|
|
1269
|
+
let selectedProfiles;
|
|
1270
|
+
if (step.profiles !== undefined) {
|
|
1271
|
+
if (!Array.isArray(step.profiles) || step.profiles.length === 0 || step.profiles.some((name2) => typeof name2 !== "string" || !Object.hasOwn(profiles, name2))) {
|
|
1272
|
+
throw new DefinitionError(`steps[${index}].profiles must name existing profiles.`);
|
|
1273
|
+
}
|
|
1274
|
+
selectedProfiles = [...step.profiles];
|
|
1275
|
+
if (new Set(selectedProfiles).size !== selectedProfiles.length) {
|
|
1276
|
+
throw new DefinitionError(`steps[${index}].profiles must not contain duplicates.`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name2) => typeof name2 !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name2)))) {
|
|
1280
|
+
throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
|
|
1281
|
+
}
|
|
1282
|
+
if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
|
|
1283
|
+
throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
|
|
1284
|
+
}
|
|
1285
|
+
if (Array.isArray(step.secrets) && step.secrets.some((name2) => RESERVED_STEP_ENV.has(String(name2)))) {
|
|
1286
|
+
throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
|
|
1287
|
+
}
|
|
1288
|
+
const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
|
|
1289
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
|
|
1290
|
+
throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
|
|
1291
|
+
}
|
|
1292
|
+
let when;
|
|
1293
|
+
if (step.when !== undefined) {
|
|
1294
|
+
const conditions = record(step.when, `steps[${index}].when`);
|
|
1295
|
+
when = {};
|
|
1296
|
+
for (const [name2, expected] of Object.entries(conditions)) {
|
|
1297
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name2) || typeof expected !== "string" || expected.length === 0) {
|
|
1298
|
+
throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
|
|
1299
|
+
}
|
|
1300
|
+
when[name2] = expected;
|
|
1301
|
+
}
|
|
1302
|
+
if (Object.keys(when).length === 0)
|
|
1303
|
+
throw new DefinitionError(`steps[${index}].when must not be empty.`);
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
name: text(step.name, `steps[${index}].name`),
|
|
1307
|
+
exec: argv(step.exec, `steps[${index}].exec`),
|
|
1308
|
+
phase,
|
|
1309
|
+
scope: step.scope,
|
|
1310
|
+
profiles: selectedProfiles,
|
|
1311
|
+
secrets: step.secrets,
|
|
1312
|
+
always: step.always === true,
|
|
1313
|
+
timeoutMs,
|
|
1314
|
+
when
|
|
1315
|
+
};
|
|
1316
|
+
});
|
|
1317
|
+
if (new Set(steps.map((step) => step.name)).size !== steps.length) {
|
|
1318
|
+
throw new DefinitionError("pipeline.steps must have unique names.");
|
|
1319
|
+
}
|
|
1320
|
+
for (const [profile, selected] of Object.entries(profiles)) {
|
|
1321
|
+
if (selected.capacityCalibration && !steps.some((step) => step.phase === "health" && step.scope === "target" && (!step.profiles || step.profiles.includes(profile)))) {
|
|
1322
|
+
throw new DefinitionError(`profiles.${profile}.capacityCalibration requires a target-scoped health step.`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const name = text(root.name, "pipeline.name");
|
|
1326
|
+
if (name.length > 120)
|
|
1327
|
+
throw new DefinitionError("pipeline.name must be at most 120 characters.");
|
|
1328
|
+
return {
|
|
1329
|
+
version: PIPELINE_VERSION,
|
|
1330
|
+
name,
|
|
1331
|
+
requireAttestation: root.requireAttestation === true,
|
|
1332
|
+
profiles,
|
|
1333
|
+
steps
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
1337
|
+
if (!Object.hasOwn(definition.profiles, profile)) {
|
|
1338
|
+
throw new DefinitionError(`pipeline profile does not exist: ${profile}.`);
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
name: `${definition.name}:${phase}`,
|
|
1342
|
+
requireAttestation: definition.requireAttestation,
|
|
1343
|
+
steps: definition.steps.filter((step) => step.phase === phase && (!step.profiles || step.profiles.includes(profile)) && (step.scope === "target" || executeRelease))
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
789
1347
|
// src/software-helper.ts
|
|
790
|
-
import { chmodSync as
|
|
1348
|
+
import { chmodSync as chmodSync4, existsSync as existsSync4, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3 } from "node:fs";
|
|
791
1349
|
import { connect as connect2, createServer as createServer2 } from "node:net";
|
|
792
|
-
import { dirname as
|
|
1350
|
+
import { dirname as dirname4 } from "node:path";
|
|
1351
|
+
|
|
1352
|
+
// src/service-supervisor.ts
|
|
1353
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1354
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync, realpathSync, renameSync as renameSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1355
|
+
import { dirname as dirname3, join as join4, resolve as resolve3, sep } from "node:path";
|
|
1356
|
+
var SERVICE_STATE_DIRECTORY = "/var/lib/forgezero/services";
|
|
1357
|
+
var SERVICE_CONFIG_DIRECTORY = "/etc/forgezero/services";
|
|
1358
|
+
var SERVICE_UNIT_DIRECTORY = "/etc/systemd/system";
|
|
1359
|
+
var SERVICE_NGINX_DIRECTORY = "/etc/nginx/conf.d";
|
|
1360
|
+
var idFor = (key) => createHash3("sha256").update(key).digest("hex").slice(0, 24);
|
|
1361
|
+
var statePath = (id) => `${SERVICE_STATE_DIRECTORY}/${id}.json`;
|
|
1362
|
+
var within = (root, path2) => path2 === root || path2.startsWith(`${root}${sep}`);
|
|
1363
|
+
var defaultHost = {
|
|
1364
|
+
write(path2, content, mode) {
|
|
1365
|
+
mkdirSync4(dirname3(path2), { recursive: true, mode: 493 });
|
|
1366
|
+
const next = `${path2}.next`;
|
|
1367
|
+
writeFileSync4(next, content, { mode });
|
|
1368
|
+
renameSync4(next, path2);
|
|
1369
|
+
},
|
|
1370
|
+
read: (path2) => readFileSync4(path2, "utf8"),
|
|
1371
|
+
exists: existsSync3,
|
|
1372
|
+
list: (path2) => existsSync3(path2) ? readdirSync(path2) : [],
|
|
1373
|
+
realpath: realpathSync,
|
|
1374
|
+
mkdir: (path2, mode) => mkdirSync4(path2, { recursive: true, mode }),
|
|
1375
|
+
remove: (path2) => rmSync4(path2, { force: true }),
|
|
1376
|
+
async exec(argv2) {
|
|
1377
|
+
const child = Bun.spawn([...argv2], { stdout: "pipe", stderr: "pipe", env: {
|
|
1378
|
+
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
1379
|
+
LANG: "C",
|
|
1380
|
+
LC_ALL: "C"
|
|
1381
|
+
} });
|
|
1382
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1383
|
+
new Response(child.stdout).text(),
|
|
1384
|
+
new Response(child.stderr).text(),
|
|
1385
|
+
child.exited
|
|
1386
|
+
]);
|
|
1387
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
1388
|
+
},
|
|
1389
|
+
async health(port, path2) {
|
|
1390
|
+
try {
|
|
1391
|
+
const response = await fetch(`http://127.0.0.1:${port}${path2}`, {
|
|
1392
|
+
signal: AbortSignal.timeout(2000),
|
|
1393
|
+
redirect: "manual"
|
|
1394
|
+
});
|
|
1395
|
+
return response.status >= 200 && response.status < 300;
|
|
1396
|
+
} catch {
|
|
1397
|
+
return false;
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
1401
|
+
now: Date.now
|
|
1402
|
+
};
|
|
1403
|
+
function readState(host, path2) {
|
|
1404
|
+
if (!host.exists(path2))
|
|
1405
|
+
return null;
|
|
1406
|
+
try {
|
|
1407
|
+
const value = JSON.parse(host.read(path2));
|
|
1408
|
+
return value?.format === 1 && Array.isArray(value.applicationPorts) ? value : null;
|
|
1409
|
+
} catch {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function allStates(host) {
|
|
1414
|
+
return host.list(SERVICE_STATE_DIRECTORY).filter((name) => /^[a-f0-9]{24}\.json$/.test(name)).flatMap((name) => {
|
|
1415
|
+
const state = readState(host, join4(SERVICE_STATE_DIRECTORY, name));
|
|
1416
|
+
return state ? [state] : [];
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
function allocatedPorts(request, id, previous, states) {
|
|
1420
|
+
const required = request.service.strategy === "blue-green" ? 2 : 1;
|
|
1421
|
+
const occupied = new Set(states.filter((state) => state.id !== id).flatMap((state) => [state.publicPort, ...state.applicationPorts]));
|
|
1422
|
+
if (occupied.has(request.service.publicPort))
|
|
1423
|
+
throw new Error("SERVICE_PUBLIC_PORT_CONFLICT");
|
|
1424
|
+
const allocation = request.service.applicationPorts;
|
|
1425
|
+
if (allocation.mode === "fixed") {
|
|
1426
|
+
if (allocation.ports.some((port) => occupied.has(port)))
|
|
1427
|
+
throw new Error("SERVICE_APPLICATION_PORT_CONFLICT");
|
|
1428
|
+
return [...allocation.ports];
|
|
1429
|
+
}
|
|
1430
|
+
if (previous && previous.applicationPorts.length === required && previous.applicationPorts.every((port) => port >= allocation.from && port <= allocation.to && !occupied.has(port)))
|
|
1431
|
+
return [...previous.applicationPorts];
|
|
1432
|
+
const width = allocation.to - allocation.from + 1;
|
|
1433
|
+
const start = Number.parseInt(createHash3("sha256").update(request.key).digest("hex").slice(0, 8), 16) % width;
|
|
1434
|
+
for (let offset = 0;offset < width; offset += 1) {
|
|
1435
|
+
const first = allocation.from + (start + offset) % width;
|
|
1436
|
+
const candidate = Array.from({ length: required }, (_, index) => first + index);
|
|
1437
|
+
if (candidate.at(-1) <= allocation.to && candidate.every((port) => !occupied.has(port) && port !== request.service.publicPort)) {
|
|
1438
|
+
return candidate;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
throw new Error("SERVICE_DYNAMIC_PORTS_EXHAUSTED");
|
|
1442
|
+
}
|
|
1443
|
+
var appConfig = (request, port) => `${JSON.stringify({
|
|
1444
|
+
format: 1,
|
|
1445
|
+
root: request.root,
|
|
1446
|
+
release: request.release,
|
|
1447
|
+
home: `${request.root}/app-home`,
|
|
1448
|
+
argv: request.service.command.map((argument) => argument === "${FZ_APP_PORT}" ? String(port) : argument === "${FZ_RELEASE}" ? request.release : argument),
|
|
1449
|
+
environment: { FZ_APP_PORT: String(port), FZ_RELEASE: request.release }
|
|
1450
|
+
}, null, 2)}
|
|
1451
|
+
`;
|
|
1452
|
+
var unit = (id, slot) => `[Unit]
|
|
1453
|
+
Description=ForgeZero supervised tenant application ${id} slot ${slot}
|
|
1454
|
+
After=network-online.target
|
|
1455
|
+
Wants=network-online.target
|
|
1456
|
+
|
|
1457
|
+
[Service]
|
|
1458
|
+
Type=simple
|
|
1459
|
+
User=forgezero-app
|
|
1460
|
+
Group=forgezero-vault
|
|
1461
|
+
ExecStart=/usr/local/lib/forgezero/agent/fz-agent supervised-app --config=${SERVICE_CONFIG_DIRECTORY}/${id}-slot${slot}.json
|
|
1462
|
+
Restart=always
|
|
1463
|
+
RestartSec=2
|
|
1464
|
+
NoNewPrivileges=true
|
|
1465
|
+
PrivateTmp=true
|
|
1466
|
+
ProtectSystem=strict
|
|
1467
|
+
ProtectHome=true
|
|
1468
|
+
LimitCORE=0
|
|
1469
|
+
|
|
1470
|
+
[Install]
|
|
1471
|
+
WantedBy=multi-user.target
|
|
1472
|
+
`;
|
|
1473
|
+
var nginx = (request, id, port) => `# ForgeZero ${id}
|
|
1474
|
+
${request.service.maxConnections ? `limit_conn_zone $server_name zone=fz_${id}:64k;
|
|
1475
|
+
` : ""}server {
|
|
1476
|
+
listen 127.0.0.1:${request.service.publicPort};
|
|
1477
|
+
server_name _;
|
|
1478
|
+
${request.service.maxConnections ? ` limit_conn fz_${id} ${request.service.maxConnections};
|
|
1479
|
+
limit_conn_status 503;
|
|
1480
|
+
` : ""} location / {
|
|
1481
|
+
proxy_pass http://127.0.0.1:${port};
|
|
1482
|
+
proxy_http_version 1.1;
|
|
1483
|
+
${request.service.websocket ? ` proxy_set_header Upgrade $http_upgrade;
|
|
1484
|
+
proxy_set_header Connection "upgrade";
|
|
1485
|
+
` : ""} proxy_set_header Host $host;
|
|
1486
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
1487
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
`;
|
|
1491
|
+
async function checked2(host, argv2, code) {
|
|
1492
|
+
const result = await host.exec(argv2);
|
|
1493
|
+
if (result.exitCode !== 0)
|
|
1494
|
+
throw new Error(`${code}: ${result.output.slice(0, 512)}`);
|
|
1495
|
+
}
|
|
1496
|
+
async function activateSupervisedService(request, host = defaultHost) {
|
|
1497
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/.test(request.key) || !/^[a-f0-9]{40}$/.test(request.revision)) {
|
|
1498
|
+
throw new Error("SERVICE_IDENTITY_INVALID");
|
|
1499
|
+
}
|
|
1500
|
+
const root = host.realpath(resolve3(request.root));
|
|
1501
|
+
const release = host.realpath(resolve3(request.release));
|
|
1502
|
+
if (!within(root, release))
|
|
1503
|
+
throw new Error("SERVICE_RELEASE_OUTSIDE_ROOT");
|
|
1504
|
+
request = { ...request, root, release, service: validateDeploymentService(request.service) };
|
|
1505
|
+
const id = idFor(request.key);
|
|
1506
|
+
host.mkdir(SERVICE_STATE_DIRECTORY, 448);
|
|
1507
|
+
host.mkdir(SERVICE_CONFIG_DIRECTORY, 457);
|
|
1508
|
+
const previous = readState(host, statePath(id));
|
|
1509
|
+
const ports = allocatedPorts(request, id, previous, allStates(host));
|
|
1510
|
+
const nextSlot = request.service.strategy === "blue-green" ? previous?.activeSlot === 0 ? 1 : 0 : 0;
|
|
1511
|
+
const port = ports[nextSlot] ?? ports[0];
|
|
1512
|
+
const configPath = `${SERVICE_CONFIG_DIRECTORY}/${id}-slot${nextSlot}.json`;
|
|
1513
|
+
const unitName = `forgezero-app-${id}-slot${nextSlot}.service`;
|
|
1514
|
+
host.write(configPath, appConfig({ ...request, release }, port), 292);
|
|
1515
|
+
host.write(`${SERVICE_UNIT_DIRECTORY}/${unitName}`, unit(id, nextSlot), 420);
|
|
1516
|
+
await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "SERVICE_UNIT_RELOAD_FAILED");
|
|
1517
|
+
if (request.service.strategy === "direct" && previous) {
|
|
1518
|
+
await host.exec(["/usr/bin/systemctl", "stop", `forgezero-app-${id}-slot${previous.activeSlot}.service`]);
|
|
1519
|
+
}
|
|
1520
|
+
await checked2(host, ["/usr/bin/systemctl", "restart", unitName], "SERVICE_START_FAILED");
|
|
1521
|
+
let healthy = false;
|
|
1522
|
+
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
1523
|
+
const active = await host.exec(["/usr/bin/systemctl", "is-active", "--quiet", unitName]);
|
|
1524
|
+
if (active.exitCode === 0 && await host.health(port, request.service.healthPath)) {
|
|
1525
|
+
healthy = true;
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
await host.sleep(1000);
|
|
1529
|
+
}
|
|
1530
|
+
if (!healthy) {
|
|
1531
|
+
await host.exec(["/usr/bin/systemctl", "stop", unitName]);
|
|
1532
|
+
throw new Error("SERVICE_HEALTH_FAILED");
|
|
1533
|
+
}
|
|
1534
|
+
const nginxPath = `${SERVICE_NGINX_DIRECTORY}/forgezero-${id}.conf`;
|
|
1535
|
+
const oldNginx = host.exists(nginxPath) ? host.read(nginxPath) : null;
|
|
1536
|
+
try {
|
|
1537
|
+
host.write(nginxPath, nginx(request, id, port), 420);
|
|
1538
|
+
await checked2(host, ["/usr/sbin/nginx", "-t"], "SERVICE_NGINX_INVALID");
|
|
1539
|
+
await checked2(host, ["/usr/bin/systemctl", "reload", "nginx.service"], "SERVICE_NGINX_RELOAD_FAILED");
|
|
1540
|
+
} catch (cause) {
|
|
1541
|
+
if (oldNginx === null)
|
|
1542
|
+
host.remove(nginxPath);
|
|
1543
|
+
else
|
|
1544
|
+
host.write(nginxPath, oldNginx, 420);
|
|
1545
|
+
await host.exec(["/usr/bin/systemctl", "stop", unitName]);
|
|
1546
|
+
throw cause;
|
|
1547
|
+
}
|
|
1548
|
+
const state = {
|
|
1549
|
+
format: 1,
|
|
1550
|
+
id,
|
|
1551
|
+
key: request.key,
|
|
1552
|
+
revision: request.revision,
|
|
1553
|
+
strategy: request.service.strategy,
|
|
1554
|
+
publicPort: request.service.publicPort,
|
|
1555
|
+
applicationPorts: ports,
|
|
1556
|
+
activeSlot: nextSlot,
|
|
1557
|
+
healthPath: request.service.healthPath,
|
|
1558
|
+
updatedAtTs: host.now()
|
|
1559
|
+
};
|
|
1560
|
+
host.write(statePath(id), `${JSON.stringify(state, null, 2)}
|
|
1561
|
+
`, 384);
|
|
1562
|
+
if (request.service.strategy === "blue-green" && previous && previous.activeSlot !== nextSlot) {
|
|
1563
|
+
await host.sleep(request.service.drainMs ?? 30000);
|
|
1564
|
+
await host.exec(["/usr/bin/systemctl", "stop", `forgezero-app-${id}-slot${previous.activeSlot}.service`]);
|
|
1565
|
+
}
|
|
1566
|
+
return state;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// src/software-helper.ts
|
|
793
1570
|
var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
|
|
794
1571
|
var SOFTWARE_HELPER_GROUP = "forgezero-software";
|
|
795
1572
|
var SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
|
|
796
|
-
var MAX_REQUEST_BYTES2 =
|
|
1573
|
+
var MAX_REQUEST_BYTES2 = 128 * 1024;
|
|
797
1574
|
var MAX_PENDING_REQUESTS = 128;
|
|
798
|
-
var execute = async (command2) => {
|
|
799
|
-
const child = Bun.spawn(["/bin/bash", "-Eeuo", "pipefail", "-c", command2], {
|
|
800
|
-
stdout: "pipe",
|
|
801
|
-
stderr: "pipe",
|
|
802
|
-
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
803
|
-
});
|
|
804
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
805
|
-
new Response(child.stdout).text(),
|
|
806
|
-
new Response(child.stderr).text(),
|
|
807
|
-
child.exited
|
|
808
|
-
]);
|
|
809
|
-
return { exitCode, output: `${stdout}${stderr}` };
|
|
810
|
-
};
|
|
811
1575
|
function startSoftwareHelper(options = {}) {
|
|
812
1576
|
const socketPath = options.socketPath ?? DEFAULT_SOFTWARE_HELPER_SOCKET;
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
|
|
1577
|
+
if (existsSync4(socketPath))
|
|
1578
|
+
unlinkSync3(socketPath);
|
|
1579
|
+
mkdirSync5(dirname4(socketPath), { recursive: true, mode: 488 });
|
|
816
1580
|
const ensure = options.ensure ?? ensureSoftwareRequirements;
|
|
1581
|
+
const activate = options.activate ?? activateSupervisedService;
|
|
817
1582
|
let tail = Promise.resolve();
|
|
818
1583
|
let pending = 0;
|
|
819
1584
|
const server = createServer2((socket) => {
|
|
@@ -838,12 +1603,23 @@ function startSoftwareHelper(options = {}) {
|
|
|
838
1603
|
}
|
|
839
1604
|
pending += 1;
|
|
840
1605
|
const work = tail.then(() => Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
|
|
841
|
-
if (request.op
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1606
|
+
if (request.op === "ensure") {
|
|
1607
|
+
const requirements = validateSoftwareRequirements(request.requirements);
|
|
1608
|
+
const results = await ensure(requirements, { exec: executeSoftwareOperation });
|
|
1609
|
+
socket.end(`${JSON.stringify({ ok: true, results })}
|
|
1610
|
+
`);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (request.op === "activate-service" && request.request) {
|
|
1614
|
+
if (!options.allowedRoot || request.request.root !== options.allowedRoot) {
|
|
1615
|
+
throw new Error("service deployment root is not owned by this helper");
|
|
1616
|
+
}
|
|
1617
|
+
const state = await activate(request.request);
|
|
1618
|
+
socket.end(`${JSON.stringify({ ok: true, state })}
|
|
846
1619
|
`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
throw new Error("unknown software helper operation");
|
|
847
1623
|
}).catch((cause) => socket.end(`${JSON.stringify({
|
|
848
1624
|
ok: false,
|
|
849
1625
|
error: { code: "SOFTWARE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
|
|
@@ -859,12 +1635,40 @@ function startSoftwareHelper(options = {}) {
|
|
|
859
1635
|
});
|
|
860
1636
|
socket.on("error", () => socket.destroy());
|
|
861
1637
|
});
|
|
862
|
-
server.listen(socketPath, () =>
|
|
1638
|
+
server.listen(socketPath, () => chmodSync4(socketPath, 432));
|
|
863
1639
|
return server;
|
|
864
1640
|
}
|
|
1641
|
+
function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 10 * 60000) {
|
|
1642
|
+
return new Promise((resolve4, reject) => {
|
|
1643
|
+
const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "activate-service", request })}
|
|
1644
|
+
`));
|
|
1645
|
+
let buffer = "";
|
|
1646
|
+
socket.setTimeout(timeoutMs, () => {
|
|
1647
|
+
socket.destroy();
|
|
1648
|
+
reject(new Error("service helper timed out"));
|
|
1649
|
+
});
|
|
1650
|
+
socket.on("data", (chunk) => {
|
|
1651
|
+
buffer += chunk.toString("utf8");
|
|
1652
|
+
const newline = buffer.indexOf(`
|
|
1653
|
+
`);
|
|
1654
|
+
if (newline < 0)
|
|
1655
|
+
return;
|
|
1656
|
+
socket.end();
|
|
1657
|
+
try {
|
|
1658
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
1659
|
+
if (!response.ok || !response.state)
|
|
1660
|
+
throw new Error(response.error?.message ?? "service helper refused activation");
|
|
1661
|
+
resolve4(response.state);
|
|
1662
|
+
} catch (cause) {
|
|
1663
|
+
reject(cause);
|
|
1664
|
+
}
|
|
1665
|
+
});
|
|
1666
|
+
socket.on("error", reject);
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
865
1669
|
function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
|
|
866
1670
|
validateSoftwareRequirements(requirements);
|
|
867
|
-
return new Promise((
|
|
1671
|
+
return new Promise((resolve4, reject) => {
|
|
868
1672
|
const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
|
|
869
1673
|
`));
|
|
870
1674
|
let buffer = "";
|
|
@@ -883,7 +1687,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
883
1687
|
const response = JSON.parse(buffer.slice(0, newline));
|
|
884
1688
|
if (!response.ok || !response.results)
|
|
885
1689
|
throw new Error(response.error?.message ?? "software helper refused the request");
|
|
886
|
-
|
|
1690
|
+
resolve4(response.results);
|
|
887
1691
|
} catch (cause) {
|
|
888
1692
|
reject(cause);
|
|
889
1693
|
}
|
|
@@ -893,11 +1697,10 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
893
1697
|
}
|
|
894
1698
|
|
|
895
1699
|
// src/version.ts
|
|
896
|
-
var VERSION3 = "0.1.
|
|
1700
|
+
var VERSION3 = "0.1.42";
|
|
897
1701
|
|
|
898
1702
|
// src/egress-policy.ts
|
|
899
|
-
import { realpathSync } from "node:fs";
|
|
900
|
-
var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
|
|
1703
|
+
import { realpathSync as realpathSync2 } from "node:fs";
|
|
901
1704
|
var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
|
|
902
1705
|
var BLOCKED_IPV4 = [
|
|
903
1706
|
"0.0.0.0/8",
|
|
@@ -976,22 +1779,26 @@ function atLeast(version, floor) {
|
|
|
976
1779
|
}
|
|
977
1780
|
var CAPABILITY_CHECKS = {
|
|
978
1781
|
snpGuest: {
|
|
979
|
-
command: "
|
|
1782
|
+
command: "fz host check-device /dev/sev-guest",
|
|
1783
|
+
operation: { kind: "path-exists", path: "/dev/sev-guest", nodeType: "file" },
|
|
980
1784
|
satisfied: (stdout) => stdout.trim() === "yes",
|
|
981
1785
|
remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
|
|
982
1786
|
},
|
|
983
1787
|
systemd: {
|
|
984
|
-
command: "
|
|
1788
|
+
command: "fz host check-directory /run/systemd/system",
|
|
1789
|
+
operation: { kind: "path-exists", path: "/run/systemd/system", nodeType: "directory" },
|
|
985
1790
|
satisfied: (stdout) => stdout.trim() === "yes",
|
|
986
1791
|
remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
|
|
987
1792
|
},
|
|
988
1793
|
bun: {
|
|
989
|
-
command: "
|
|
1794
|
+
command: "fz host check-version bun",
|
|
1795
|
+
operation: { kind: "version", argv: ["/usr/local/bin/bun", "--version"] },
|
|
990
1796
|
satisfied: (stdout) => atLeast(stdout, "1.1.0"),
|
|
991
|
-
remedy: "Install
|
|
1797
|
+
remedy: "Install the pinned Bun release with `fz bootstrap`."
|
|
992
1798
|
},
|
|
993
1799
|
python: {
|
|
994
|
-
command: "
|
|
1800
|
+
command: "fz host check-version python3",
|
|
1801
|
+
operation: { kind: "version", argv: ["/usr/bin/python3", "--version"] },
|
|
995
1802
|
satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
|
|
996
1803
|
remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
|
|
997
1804
|
}
|
|
@@ -999,6 +1806,7 @@ var CAPABILITY_CHECKS = {
|
|
|
999
1806
|
var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
1000
1807
|
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
|
|
1001
1808
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1809
|
+
var APPLICATION_RUNTIME_USER = "forgezero-app";
|
|
1002
1810
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1003
1811
|
var VAULT_GROUP = "forgezero-vault";
|
|
1004
1812
|
var LIFECYCLE_GROUP = "forgezero-lifecycle";
|
|
@@ -1028,11 +1836,7 @@ function agentEgressUnit(options) {
|
|
|
1028
1836
|
systemdAgentEgressDirectives(runnerLoopbackPorts);
|
|
1029
1837
|
const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
|
|
1030
1838
|
const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
|
|
1031
|
-
const
|
|
1032
|
-
...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
|
|
1033
|
-
`public-tcp=${runnerPublicTcpPorts.join(",")}`
|
|
1034
|
-
].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
|
|
1035
|
-
`) : "";
|
|
1839
|
+
const policyProof = `ExecStartPost=${bin} egress-policy-check ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}`;
|
|
1036
1840
|
return `[Unit]
|
|
1037
1841
|
Description=ForgeZero Agent host egress policy
|
|
1038
1842
|
Documentation=https://www.forgezero.net/docs/agent
|
|
@@ -1046,7 +1850,7 @@ NotifyAccess=all
|
|
|
1046
1850
|
User=root
|
|
1047
1851
|
Group=root
|
|
1048
1852
|
ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
|
|
1049
|
-
${
|
|
1853
|
+
${policyProof}
|
|
1050
1854
|
Restart=on-failure
|
|
1051
1855
|
RestartSec=2
|
|
1052
1856
|
LimitCORE=0
|
|
@@ -1070,6 +1874,7 @@ WantedBy=multi-user.target
|
|
|
1070
1874
|
}
|
|
1071
1875
|
function softwareHelperUnit(options) {
|
|
1072
1876
|
const bin = options.binPath ?? "fz-agent";
|
|
1877
|
+
const root = options.deployRoot ?? "/opt/forgezero";
|
|
1073
1878
|
return `[Unit]
|
|
1074
1879
|
Description=ForgeZero declarative software strategy helper
|
|
1075
1880
|
Documentation=https://www.forgezero.net/docs/agent
|
|
@@ -1081,6 +1886,7 @@ Type=simple
|
|
|
1081
1886
|
User=root
|
|
1082
1887
|
Group=${SOFTWARE_HELPER_GROUP}
|
|
1083
1888
|
Environment=FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}
|
|
1889
|
+
Environment=FZ_DEPLOY_ROOT=${root}
|
|
1084
1890
|
ExecStart=${bin} software-helper
|
|
1085
1891
|
Restart=always
|
|
1086
1892
|
RestartSec=2
|
|
@@ -1196,10 +2002,6 @@ var systemdPath = (value, label) => {
|
|
|
1196
2002
|
throw new Error(`invalid ${label} path`);
|
|
1197
2003
|
return value;
|
|
1198
2004
|
};
|
|
1199
|
-
var awaitSocketCommand = (path) => {
|
|
1200
|
-
const socket = systemdPath(path, "readiness socket");
|
|
1201
|
-
return `for attempt in $(seq 1 100); do test -S ${socket} && exit 0; sleep 0.1; done; exit 1`;
|
|
1202
|
-
};
|
|
1203
2005
|
var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
1204
2006
|
function warpConfigUnit(options) {
|
|
1205
2007
|
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
@@ -1300,6 +2102,8 @@ function agentEnrolmentUnit(options) {
|
|
|
1300
2102
|
const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
|
|
1301
2103
|
` : "";
|
|
1302
2104
|
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
2105
|
+
` : "";
|
|
2106
|
+
const bootstrapSshPublicKey = options.bootstrapSshPublicKeyPath ? `Environment=FZ_BOOTSTRAP_SSH_PUBLIC_KEY_FILE=${options.bootstrapSshPublicKeyPath}
|
|
1303
2107
|
` : "";
|
|
1304
2108
|
const networkAttachment = [
|
|
1305
2109
|
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
@@ -1334,7 +2138,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
1334
2138
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
1335
2139
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
1336
2140
|
Environment=FZ_API=${options.apiUrl}
|
|
1337
|
-
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
2141
|
+
${label}${hostname}${gitPublicKey}${bootstrapSshPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
1338
2142
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
1339
2143
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
1340
2144
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -1428,9 +2232,14 @@ function agentUnit(options) {
|
|
|
1428
2232
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1429
2233
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1430
2234
|
}
|
|
1431
|
-
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
1432
|
-
if ([
|
|
1433
|
-
|
|
2235
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapSshPublicKeyPath && options.bootstrapTargetTelemetryEndpoint);
|
|
2236
|
+
if ([
|
|
2237
|
+
options.pullBootstrap,
|
|
2238
|
+
options.bootstrapSshCredentialPath,
|
|
2239
|
+
options.bootstrapSshPublicKeyPath,
|
|
2240
|
+
options.bootstrapTargetTelemetryEndpoint
|
|
2241
|
+
].some(Boolean) && !bootstrapEnabled) {
|
|
2242
|
+
throw new Error("bootstrap pull, SSH credential, public key and target telemetry endpoint must be supplied together");
|
|
1434
2243
|
}
|
|
1435
2244
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1436
2245
|
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
@@ -1465,8 +2274,8 @@ function agentUnit(options) {
|
|
|
1465
2274
|
throw new Error(`invalid deployment environment entry: ${name}`);
|
|
1466
2275
|
}
|
|
1467
2276
|
}
|
|
1468
|
-
for (const [name,
|
|
1469
|
-
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !
|
|
2277
|
+
for (const [name, path2] of Object.entries(deploymentCredentials)) {
|
|
2278
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path2.startsWith("/") || /[\r\n:]/.test(path2)) {
|
|
1470
2279
|
throw new Error(`invalid deployment credential entry: ${name}`);
|
|
1471
2280
|
}
|
|
1472
2281
|
}
|
|
@@ -1501,6 +2310,7 @@ function agentUnit(options) {
|
|
|
1501
2310
|
options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
|
|
1502
2311
|
options.pullBootstrap ? "FZ_BOOTSTRAP_PULL=true" : null,
|
|
1503
2312
|
options.pullBootstrap ? "FZ_BOOTSTRAP_SSH_KEY_CREDENTIAL=bootstrap-ssh-key" : null,
|
|
2313
|
+
options.pullBootstrap && options.bootstrapSshPublicKeyPath ? `FZ_BOOTSTRAP_SSH_PUBLIC_KEY_FILE=${options.bootstrapSshPublicKeyPath}` : null,
|
|
1504
2314
|
options.pullBootstrap ? `FZ_BOOTSTRAP_TARGET_OTLP_ENDPOINT=${options.bootstrapTargetTelemetryEndpoint}` : null,
|
|
1505
2315
|
`FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}`,
|
|
1506
2316
|
options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
|
|
@@ -1512,7 +2322,7 @@ function agentUnit(options) {
|
|
|
1512
2322
|
` : "";
|
|
1513
2323
|
const bootstrapCredential = bootstrapEnabled ? `LoadCredentialEncrypted=bootstrap-ssh-key:${bootstrapSshCredentialPath}
|
|
1514
2324
|
` : "";
|
|
1515
|
-
const projectCredentials = Object.entries(deploymentCredentials).map(([name,
|
|
2325
|
+
const projectCredentials = Object.entries(deploymentCredentials).map(([name, path2]) => `LoadCredentialEncrypted=${name}:${path2}`).join(`
|
|
1516
2326
|
`);
|
|
1517
2327
|
const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache ${deployRoot}/capacity` : "";
|
|
1518
2328
|
const supplementaryGroups = [
|
|
@@ -1606,6 +2416,55 @@ ${deploymentWrites}
|
|
|
1606
2416
|
WantedBy=multi-user.target
|
|
1607
2417
|
`;
|
|
1608
2418
|
}
|
|
2419
|
+
var renderOperation = (operation) => {
|
|
2420
|
+
if (operation.kind === "commands")
|
|
2421
|
+
return operation.commands.map(({ argv: argv2 }) => argv2.join(" ")).join(`
|
|
2422
|
+
`);
|
|
2423
|
+
if (operation.kind === "directories")
|
|
2424
|
+
return operation.directories.map((directory) => [
|
|
2425
|
+
"/usr/bin/install",
|
|
2426
|
+
"-d",
|
|
2427
|
+
...directory.owner ? ["-o", directory.owner] : [],
|
|
2428
|
+
...directory.group ? ["-g", directory.group] : [],
|
|
2429
|
+
"-m",
|
|
2430
|
+
directory.mode.toString(8).padStart(4, "0"),
|
|
2431
|
+
directory.path
|
|
2432
|
+
].join(" ")).join(`
|
|
2433
|
+
`);
|
|
2434
|
+
if (operation.kind === "install-runtime")
|
|
2435
|
+
return `/usr/bin/install -m 0755 ${operation.source} /opt/forgezero/agent/versions/${operation.version}/dist/fz-agent.js`;
|
|
2436
|
+
if (operation.kind === "ensure-seed")
|
|
2437
|
+
return `/usr/bin/systemd-creds encrypt --name=agent-seed - ${operation.credential}`;
|
|
2438
|
+
if (operation.kind === "ensure-git-identity")
|
|
2439
|
+
return `/usr/bin/ssh-keygen -t ed25519
|
|
2440
|
+
/usr/bin/systemd-creds encrypt --name=git-deploy-key <private> ${operation.credential}
|
|
2441
|
+
fz host write-public-key ${operation.publicKey}`;
|
|
2442
|
+
if (operation.kind === "ensure-bootstrap-ssh-identity")
|
|
2443
|
+
return `/usr/bin/ssh-keygen -t ed25519
|
|
2444
|
+
/usr/bin/systemd-creds encrypt --name=bootstrap-ssh-key <private> ${operation.credential}
|
|
2445
|
+
fz host write-public-key ${operation.publicKey}`;
|
|
2446
|
+
if (operation.kind === "ensure-enrolment")
|
|
2447
|
+
return `/usr/bin/systemd-creds encrypt --name=enrol-token ${operation.source} ${operation.credential}
|
|
2448
|
+
/usr/bin/rm -f ${operation.source}`;
|
|
2449
|
+
if (operation.kind === "wait-socket")
|
|
2450
|
+
return `fz host wait-socket ${operation.path}`;
|
|
2451
|
+
if (operation.kind === "verify-file")
|
|
2452
|
+
return `fz host verify-file ${operation.path}`;
|
|
2453
|
+
if (operation.kind === "verify-egress")
|
|
2454
|
+
return `/usr/sbin/nft --numeric list table inet forgezero_agent_egress
|
|
2455
|
+
fz-agent egress-policy-check`;
|
|
2456
|
+
if (operation.kind === "verify-resolved-stub")
|
|
2457
|
+
return "fz host verify-resolved-stub /run/systemd/resolve/stub-resolv.conf";
|
|
2458
|
+
if (operation.kind === "install-warp")
|
|
2459
|
+
return "/usr/bin/apt-get install -y cloudflare-warp";
|
|
2460
|
+
return "/usr/bin/warp-cli --accept-tos status";
|
|
2461
|
+
};
|
|
2462
|
+
var step = (label, operation, optional = false) => ({
|
|
2463
|
+
label,
|
|
2464
|
+
operation,
|
|
2465
|
+
optional: optional || undefined,
|
|
2466
|
+
command: renderOperation(operation)
|
|
2467
|
+
});
|
|
1609
2468
|
var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
1610
2469
|
function planProvision(options) {
|
|
1611
2470
|
const mode = options.mode;
|
|
@@ -1620,9 +2479,15 @@ function planProvision(options) {
|
|
|
1620
2479
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1621
2480
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1622
2481
|
}
|
|
1623
|
-
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
1624
|
-
if ([
|
|
1625
|
-
|
|
2482
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapSshPublicKeyPath && options.bootstrapTargetTelemetryEndpoint);
|
|
2483
|
+
if ([
|
|
2484
|
+
options.pullBootstrap,
|
|
2485
|
+
options.bootstrapSshCredentialPath,
|
|
2486
|
+
options.bootstrapSshPublicKeyPath,
|
|
2487
|
+
options.bootstrapSshSourcePath,
|
|
2488
|
+
options.bootstrapTargetTelemetryEndpoint
|
|
2489
|
+
].some(Boolean) && !bootstrapEnabled) {
|
|
2490
|
+
throw new Error("bootstrap pull, SSH credential, public key and target telemetry endpoint must be supplied together");
|
|
1626
2491
|
}
|
|
1627
2492
|
const warpValues = [
|
|
1628
2493
|
options.warpOrganization,
|
|
@@ -1650,8 +2515,29 @@ function planProvision(options) {
|
|
|
1650
2515
|
const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
|
|
1651
2516
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1652
2517
|
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
2518
|
+
const bootstrapSshPublicKeyPath = bootstrapEnabled ? systemdPath(options.bootstrapSshPublicKeyPath, "bootstrap SSH public key") : undefined;
|
|
2519
|
+
const bootstrapSshSourcePath = options.bootstrapSshSourcePath ? systemdPath(options.bootstrapSshSourcePath, "bootstrap SSH private-key source") : undefined;
|
|
2520
|
+
const bootstrapSshPublicKeyDir = bootstrapSshPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
1653
2521
|
const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
|
|
1654
2522
|
const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
|
|
2523
|
+
const enabledUnits = [
|
|
2524
|
+
"forgezero-agent.socket",
|
|
2525
|
+
"forgezero-agent-update-helper.service",
|
|
2526
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2527
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2528
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2529
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2530
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
|
|
2531
|
+
"forgezero-agent.service"
|
|
2532
|
+
];
|
|
2533
|
+
const restartedUnits = [
|
|
2534
|
+
"forgezero-agent-update-helper.service",
|
|
2535
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2536
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2537
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2538
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2539
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
|
|
2540
|
+
];
|
|
1655
2541
|
return {
|
|
1656
2542
|
mode,
|
|
1657
2543
|
reason: reasonFor(mode),
|
|
@@ -1696,146 +2582,91 @@ function planProvision(options) {
|
|
|
1696
2582
|
socketPath: options.socketPath,
|
|
1697
2583
|
user,
|
|
1698
2584
|
steps: [
|
|
1699
|
-
...options.enforceEgress ? [{
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
{
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
},
|
|
1707
|
-
{
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
}] : [],
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
{
|
|
1728
|
-
label: "service account",
|
|
1729
|
-
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
1730
|
-
},
|
|
1731
|
-
{
|
|
1732
|
-
label: "bind service account to vault group",
|
|
1733
|
-
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
1734
|
-
},
|
|
1735
|
-
{
|
|
1736
|
-
label: "grant verified Agent update access",
|
|
1737
|
-
command: `usermod -a -G ${AGENT_UPDATE_GROUP} ${user}`
|
|
1738
|
-
},
|
|
1739
|
-
...lifecycleEnabled ? [{
|
|
1740
|
-
label: "grant lifecycle helper socket access",
|
|
1741
|
-
command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
|
|
1742
|
-
}] : [],
|
|
1743
|
-
...deploymentEnabled ? [{
|
|
1744
|
-
label: "credential-free deployment account",
|
|
1745
|
-
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP} ${user}`
|
|
1746
|
-
}] : [],
|
|
1747
|
-
{
|
|
1748
|
-
label: "credential directory",
|
|
1749
|
-
command: `install -d -o root -g root -m 0700 ${credentialDir}`
|
|
1750
|
-
},
|
|
1751
|
-
{
|
|
1752
|
-
label: "Agent state directory",
|
|
1753
|
-
command: "install -d -o root -g root -m 0750 /var/lib/forgezero"
|
|
1754
|
-
},
|
|
1755
|
-
{
|
|
1756
|
-
label: "encrypted node identity",
|
|
1757
|
-
command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
|
|
1758
|
-
},
|
|
2585
|
+
...options.enforceEgress ? [step("Ubuntu Agent egress prerequisites", { kind: "commands", commands: [
|
|
2586
|
+
{ argv: ["/usr/bin/apt-get", "update", "-qq"] },
|
|
2587
|
+
{ argv: ["/usr/bin/apt-get", "install", "-y", "nftables"] },
|
|
2588
|
+
{ argv: ["/usr/bin/systemctl", "enable", "--now", "systemd-resolved.service"] }
|
|
2589
|
+
] }), step("prove systemd-resolved stub ownership", { kind: "verify-resolved-stub" })] : [],
|
|
2590
|
+
step("vault socket access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", VAULT_GROUP], acceptedExitCodes: [0, 9] }] }),
|
|
2591
|
+
step("Agent update helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", AGENT_UPDATE_GROUP], acceptedExitCodes: [0, 9] }] }),
|
|
2592
|
+
...sourceBinPath && binPath ? [step("root-owned agent runtime", { kind: "install-runtime", source: sourceBinPath, binary: binPath, version: VERSION3 })] : [],
|
|
2593
|
+
...warpEnabled ? [step("Cloudflare One client for Ubuntu 26.04", { kind: "install-warp" })] : [],
|
|
2594
|
+
...deploymentEnabled ? [step("deployment isolation group", { kind: "commands", commands: [
|
|
2595
|
+
{ argv: ["/usr/sbin/groupadd", "--system", DEPLOYMENT_GROUP], acceptedExitCodes: [0, 9] },
|
|
2596
|
+
{ argv: ["/usr/sbin/groupadd", "--system", SOFTWARE_HELPER_GROUP], acceptedExitCodes: [0, 9] }
|
|
2597
|
+
] })] : [],
|
|
2598
|
+
...lifecycleEnabled ? [step("lifecycle helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", LIFECYCLE_GROUP], acceptedExitCodes: [0, 9] }] })] : [],
|
|
2599
|
+
step("service account", { kind: "commands", commands: [{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", user], acceptedExitCodes: [0, 9] }] }),
|
|
2600
|
+
step("bind service account to vault group", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-g", VAULT_GROUP, user] }] }),
|
|
2601
|
+
step("grant verified Agent update access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", AGENT_UPDATE_GROUP, user] }] }),
|
|
2602
|
+
...lifecycleEnabled ? [step("grant lifecycle helper socket access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", LIFECYCLE_GROUP, user] }] })] : [],
|
|
2603
|
+
...deploymentEnabled ? [step("credential-free deployment account", { kind: "commands", commands: [
|
|
2604
|
+
{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", DEPLOYMENT_GROUP, DEPLOYMENT_RUNNER_USER], acceptedExitCodes: [0, 9] },
|
|
2605
|
+
{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", VAULT_GROUP, APPLICATION_RUNTIME_USER], acceptedExitCodes: [0, 9] },
|
|
2606
|
+
{ argv: ["/usr/sbin/usermod", "-a", "-G", DEPLOYMENT_GROUP, APPLICATION_RUNTIME_USER] },
|
|
2607
|
+
{ argv: ["/usr/sbin/usermod", "-a", "-G", `${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP}`, user] }
|
|
2608
|
+
] })] : [],
|
|
2609
|
+
step("credential and state directories", { kind: "directories", directories: [
|
|
2610
|
+
{ path: credentialDir, mode: 448, owner: "root", group: "root" },
|
|
2611
|
+
{ path: "/var/lib/forgezero", mode: 488, owner: "root", group: "root" }
|
|
2612
|
+
] }),
|
|
2613
|
+
step("encrypted node identity", { kind: "ensure-seed", credential: seedCredentialPath }),
|
|
1759
2614
|
...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
|
|
1760
|
-
{
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
{
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
2615
|
+
step("Git deploy identity directory", { kind: "directories", directories: [{ path: gitPublicKeyDir, mode: 493, owner: "root", group: "root" }] }),
|
|
2616
|
+
step("unique encrypted Git deploy identity", { kind: "ensure-git-identity", credential: gitCredentialPath, publicKey: gitPublicKeyPath })
|
|
2617
|
+
] : [],
|
|
2618
|
+
...bootstrapEnabled ? [
|
|
2619
|
+
step("bootstrap SSH public identity directory", { kind: "directories", directories: [
|
|
2620
|
+
{ path: bootstrapSshPublicKeyDir, mode: 493, owner: "root", group: "root" }
|
|
2621
|
+
] }),
|
|
2622
|
+
step("unique encrypted bootstrap SSH identity", {
|
|
2623
|
+
kind: "ensure-bootstrap-ssh-identity",
|
|
2624
|
+
credential: bootstrapSshCredentialPath,
|
|
2625
|
+
publicKey: bootstrapSshPublicKeyPath,
|
|
2626
|
+
...bootstrapSshSourcePath ? { source: bootstrapSshSourcePath } : {}
|
|
2627
|
+
})
|
|
1768
2628
|
] : [],
|
|
1769
2629
|
...enrolmentEnabled ? [
|
|
1770
|
-
{
|
|
1771
|
-
|
|
1772
|
-
command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
|
|
1773
|
-
},
|
|
1774
|
-
{
|
|
1775
|
-
label: "encrypted one-time enrolment capability",
|
|
1776
|
-
command: `test -s ${enrolStatePath} || test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
1777
|
-
}
|
|
2630
|
+
step("enrolment state directory", { kind: "directories", directories: [{ path: enrolStateDir, mode: 448, owner: user, group: user }] }),
|
|
2631
|
+
step("encrypted one-time enrolment capability", { kind: "ensure-enrolment", state: enrolStatePath, source: enrolTokenSourcePath, credential: enrolTokenCredentialPath })
|
|
1778
2632
|
] : [],
|
|
1779
|
-
...deploymentEnabled ? [{
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
},
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
}] : []
|
|
1816
|
-
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
1817
|
-
{ label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
|
|
1818
|
-
{ label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
|
|
1819
|
-
{ label: "prove the Agent update helper exists", command: awaitSocketCommand(DEFAULT_AGENT_UPDATE_SOCKET) },
|
|
1820
|
-
...deploymentEnabled ? [{
|
|
1821
|
-
label: "prove the deployment runner socket exists",
|
|
1822
|
-
command: awaitSocketCommand(DEPLOYMENT_RUNNER_SOCKET)
|
|
1823
|
-
}, {
|
|
1824
|
-
label: "prove the software strategy helper socket exists",
|
|
1825
|
-
command: awaitSocketCommand(DEFAULT_SOFTWARE_HELPER_SOCKET)
|
|
1826
|
-
}] : [],
|
|
1827
|
-
...lifecycleEnabled ? [{
|
|
1828
|
-
label: "prove the lifecycle helper socket exists",
|
|
1829
|
-
command: awaitSocketCommand(lifecycleHelperSocketPath)
|
|
1830
|
-
}] : [],
|
|
1831
|
-
...warpEnabled ? [{
|
|
1832
|
-
label: "prove Cloudflare WARP is connected",
|
|
1833
|
-
command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
|
|
1834
|
-
}] : [],
|
|
1835
|
-
...options.repository ? [{
|
|
1836
|
-
label: "prove the deployment control socket exists",
|
|
1837
|
-
command: awaitSocketCommand(options.controlSocketPath ?? "/run/forgezero/control.sock")
|
|
1838
|
-
}] : []
|
|
2633
|
+
...deploymentEnabled ? [step("deployment directories", { kind: "directories", directories: [
|
|
2634
|
+
{ path: deployRoot, mode: 493, owner: "root", group: "root" },
|
|
2635
|
+
{ path: `${deployRoot}/releases`, mode: 2040, owner: "root", group: DEPLOYMENT_GROUP },
|
|
2636
|
+
{ path: `${deployRoot}/cache`, mode: 488, owner: user, group: user },
|
|
2637
|
+
{ path: `${deployRoot}/capacity`, mode: 448, owner: user, group: user },
|
|
2638
|
+
{ path: `${deployRoot}/agent-home`, mode: 448, owner: user, group: user },
|
|
2639
|
+
{ path: `${deployRoot}/runner-home`, mode: 448, owner: DEPLOYMENT_RUNNER_USER, group: DEPLOYMENT_GROUP },
|
|
2640
|
+
{ path: `${deployRoot}/runner-home/cache`, mode: 448, owner: DEPLOYMENT_RUNNER_USER, group: DEPLOYMENT_GROUP },
|
|
2641
|
+
{ path: `${deployRoot}/app-home`, mode: 448, owner: APPLICATION_RUNTIME_USER, group: VAULT_GROUP }
|
|
2642
|
+
] })] : [],
|
|
2643
|
+
step("reload units", { kind: "commands", commands: [{ argv: ["/usr/bin/systemctl", "daemon-reload"] }] }),
|
|
2644
|
+
...deploymentEnabled ? [step("remove unsupported deployment socket activation", { kind: "commands", commands: [
|
|
2645
|
+
{ argv: ["/usr/bin/systemctl", "disable", "--now", "forgezero-deploy-runner.socket"], acceptedExitCodes: [0, 1, 5] },
|
|
2646
|
+
{ argv: ["/usr/bin/rm", "-f", "/etc/systemd/system/forgezero-deploy-runner.socket"] },
|
|
2647
|
+
{ argv: ["/usr/bin/systemctl", "daemon-reload"] }
|
|
2648
|
+
] })] : [],
|
|
2649
|
+
step("enable and converge services", { kind: "commands", commands: [
|
|
2650
|
+
{ argv: ["/usr/bin/systemctl", "enable", ...enabledUnits] },
|
|
2651
|
+
{ argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
|
|
2652
|
+
...restartedUnits.length ? [{ argv: ["/usr/bin/systemctl", "restart", ...restartedUnits] }] : [],
|
|
2653
|
+
{ argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.socket"] },
|
|
2654
|
+
{ argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
|
|
2655
|
+
{ argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.service"] }
|
|
2656
|
+
] }),
|
|
2657
|
+
...enrolmentEnabled ? [step("prove the compute binding is durable", { kind: "verify-file", path: enrolStatePath })] : [],
|
|
2658
|
+
...options.enforceEgress ? [step("prove the Agent egress policy is active", { kind: "verify-egress", runnerPublicTcpPorts, runnerLoopbackPorts })] : [],
|
|
2659
|
+
step("prove it is running", { kind: "commands", commands: [{ argv: ["/usr/bin/systemctl", "is-active", "forgezero-agent.service"] }] }),
|
|
2660
|
+
step("prove the public Vault socket exists", { kind: "wait-socket", path: options.socketPath, attempts: 100, intervalMs: 100 }),
|
|
2661
|
+
step("prove the Agent Vault backend exists", { kind: "wait-socket", path: agentBackendSocketPath(options.socketPath), attempts: 100, intervalMs: 100 }),
|
|
2662
|
+
step("prove the Agent update helper exists", { kind: "wait-socket", path: DEFAULT_AGENT_UPDATE_SOCKET, attempts: 100, intervalMs: 100 }),
|
|
2663
|
+
...deploymentEnabled ? [
|
|
2664
|
+
step("prove the deployment runner socket exists", { kind: "wait-socket", path: DEPLOYMENT_RUNNER_SOCKET, attempts: 100, intervalMs: 100 }),
|
|
2665
|
+
step("prove the software strategy helper socket exists", { kind: "wait-socket", path: DEFAULT_SOFTWARE_HELPER_SOCKET, attempts: 100, intervalMs: 100 })
|
|
2666
|
+
] : [],
|
|
2667
|
+
...lifecycleEnabled ? [step("prove the lifecycle helper socket exists", { kind: "wait-socket", path: lifecycleHelperSocketPath, attempts: 100, intervalMs: 100 })] : [],
|
|
2668
|
+
...warpEnabled ? [step("prove Cloudflare WARP is connected", { kind: "verify-warp" })] : [],
|
|
2669
|
+
...options.repository ? [step("prove the deployment control socket exists", { kind: "wait-socket", path: options.controlSocketPath ?? "/run/forgezero/control.sock", attempts: 100, intervalMs: 100 })] : []
|
|
1839
2670
|
]
|
|
1840
2671
|
};
|
|
1841
2672
|
}
|
|
@@ -1870,6 +2701,7 @@ export {
|
|
|
1870
2701
|
DEPLOYMENT_GROUP,
|
|
1871
2702
|
DEFAULT_RUNNER_PUBLIC_TCP_PORTS,
|
|
1872
2703
|
CAPABILITY_CHECKS,
|
|
2704
|
+
APPLICATION_RUNTIME_USER,
|
|
1873
2705
|
AGENT_SOCKET_UNIT_PATH,
|
|
1874
2706
|
AGENT_SOCKET_PROXY_UNIT_PATH,
|
|
1875
2707
|
AGENT_EGRESS_UNIT_PATH
|