@forgezero/agent 0.1.52 → 0.1.55

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.
@@ -4,6 +4,7 @@ import {
4
4
  accessSync,
5
5
  chmodSync,
6
6
  copyFileSync,
7
+ existsSync,
7
8
  mkdtempSync,
8
9
  mkdirSync,
9
10
  readFileSync,
@@ -24,6 +25,7 @@ var OS_CATALOG = [
24
25
  ];
25
26
  var SOFTWARE_CATALOG = [
26
27
  { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
28
+ { id: "docker", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
27
29
  { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
28
30
  { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
29
31
  { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
@@ -33,6 +35,7 @@ var SOFTWARE_CATALOG = [
33
35
  ];
34
36
  var UBUNTU_2604_X64 = [
35
37
  { requirement: { id: "bun", version: "1.3.14" } },
38
+ { requirement: { id: "docker", version: "ubuntu-26.04" } },
36
39
  { requirement: { id: "nginx", version: "ubuntu-26.04" } },
37
40
  { requirement: { id: "arangodb", version: "3.11.14" } },
38
41
  { requirement: { id: "cloudflared", version: "2026.7.3" } },
@@ -41,6 +44,15 @@ var UBUNTU_2604_X64 = [
41
44
  { requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
42
45
  ];
43
46
  var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
47
+ var DOCKER_DAEMON_PATH = "/etc/docker/daemon.json";
48
+ var DOCKER_DAEMON_CONFIG = `${JSON.stringify({
49
+ "data-root": "/var/lib/docker",
50
+ "storage-driver": "overlay2",
51
+ "live-restore": true,
52
+ "log-driver": "local",
53
+ "log-opts": { "max-size": "10m", "max-file": "3" }
54
+ }, null, 2)}
55
+ `;
44
56
  var softwareSpawnFailure = (cause, argv) => cause.code === "ENOENT" ? { exitCode: 127, output: `executable is absent: ${argv[0]}` } : undefined;
45
57
  var runSoftwareCommand = async (argv, env = {}) => {
46
58
  let child;
@@ -83,12 +95,21 @@ async function executeSoftwareOperation(operation) {
83
95
  if (operation.kind === "check") {
84
96
  if (software === "bun")
85
97
  return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
98
+ if (software === "docker") {
99
+ const binary = await run(["/usr/bin/docker", "--version"]);
100
+ if (!successful(binary, /Docker version/))
101
+ return { ...binary, exitCode: 1 };
102
+ if (!existsSync(DOCKER_DAEMON_PATH) || readFileSync(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG)
103
+ return { exitCode: 1, output: "Docker daemon policy is absent or differs from the reviewed ForgeZero policy" };
104
+ const active = await run(["/usr/bin/systemctl", "is-active", "--quiet", "docker.service"]);
105
+ return active.exitCode === 0 ? { exitCode: 0, output: binary.output } : active;
106
+ }
86
107
  if (software === "nginx") {
87
108
  const binary = await run(["/usr/sbin/nginx", "-v"]);
88
109
  return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
89
110
  }
90
111
  if (software === "arangodb") {
91
- const binary = await run(["/usr/bin/arangod", "--version"]);
112
+ const binary = await run(["/usr/sbin/arangod", "--version"]);
92
113
  if (!successful(binary, /3\.11\.14/))
93
114
  return { ...binary, exitCode: 1 };
94
115
  const [active, enabled] = await Promise.all([
@@ -115,11 +136,22 @@ async function executeSoftwareOperation(operation) {
115
136
  return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
116
137
  }
117
138
  }
118
- if (software === "nginx" || software === "ufw" || software === "openssh-client") {
119
- const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
120
- if (installed.exitCode !== 0 || software !== "nginx")
139
+ if (software === "docker" || software === "nginx" || software === "ufw" || software === "openssh-client") {
140
+ const packageName = software === "openssh-client" ? "openssh-client" : software === "docker" ? "docker.io" : software;
141
+ const installed = await aptInstall(packageName);
142
+ if (installed.exitCode !== 0 || software !== "nginx" && software !== "docker")
121
143
  return installed;
122
- return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
144
+ if (software === "docker") {
145
+ mkdirSync("/etc/docker", { recursive: true, mode: 493 });
146
+ if (existsSync(DOCKER_DAEMON_PATH) && readFileSync(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG) {
147
+ return { exitCode: 2, output: "refusing to overwrite an existing Docker daemon configuration that differs from the reviewed ForgeZero policy" };
148
+ }
149
+ if (!existsSync(DOCKER_DAEMON_PATH))
150
+ writeFileSync(DOCKER_DAEMON_PATH, DOCKER_DAEMON_CONFIG, { mode: 420, flag: "wx" });
151
+ const enabled = await run(["/usr/bin/systemctl", "enable", "docker.service"]);
152
+ return enabled.exitCode === 0 ? run(["/usr/bin/systemctl", "restart", "docker.service"]) : enabled;
153
+ }
154
+ return run(["/usr/bin/systemctl", "enable", "--now", `${software}.service`]);
123
155
  }
124
156
  const directory = mkdtempSync(join(tmpdir(), "forgezero-software-"));
125
157
  try {
@@ -204,7 +236,7 @@ function validateSoftwareRequirements(value, _options = {}) {
204
236
  if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
205
237
  throw new Error("software requirement contains an unknown field");
206
238
  }
207
- 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)) {
239
+ if (!["bun", "docker", "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)) {
208
240
  throw new Error("software requirement coordinate is invalid");
209
241
  }
210
242
  const requirement = { id: row.id, version: row.version };
@@ -677,13 +709,13 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
677
709
  }
678
710
 
679
711
  // src/software-helper.ts
680
- import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2 } from "node:fs";
712
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync4, unlinkSync as unlinkSync2 } from "node:fs";
681
713
  import { connect, createServer } from "node:net";
682
- import { dirname as dirname2 } from "node:path";
714
+ import { dirname as dirname3 } from "node:path";
683
715
 
684
716
  // src/service-supervisor.ts
685
717
  import { createHash as createHash2 } from "node:crypto";
686
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
718
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
687
719
  import { dirname, join as join2, resolve, sep } from "node:path";
688
720
  var SERVICE_STATE_DIRECTORY = "/var/lib/forgezero/services";
689
721
  var SERVICE_CONFIG_DIRECTORY = "/etc/forgezero/services";
@@ -700,8 +732,8 @@ var defaultHost = {
700
732
  renameSync2(next, path2);
701
733
  },
702
734
  read: (path2) => readFileSync2(path2, "utf8"),
703
- exists: existsSync,
704
- list: (path2) => existsSync(path2) ? readdirSync(path2) : [],
735
+ exists: existsSync2,
736
+ list: (path2) => existsSync2(path2) ? readdirSync(path2) : [],
705
737
  realpath: realpathSync,
706
738
  mkdir: (path2, mode) => mkdirSync2(path2, { recursive: true, mode }),
707
739
  remove: (path2) => rmSync2(path2, { force: true }),
@@ -898,6 +930,297 @@ async function activateSupervisedService(request, host = defaultHost) {
898
930
  return state;
899
931
  }
900
932
 
933
+ // src/container-supervisor.ts
934
+ import { createHash as createHash3 } from "node:crypto";
935
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync as realpathSync2, renameSync as renameSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
936
+ import { dirname as dirname2, resolve as resolve2, sep as sep2 } from "node:path";
937
+ var defaultHost2 = {
938
+ realpath: realpathSync2,
939
+ exists: existsSync3,
940
+ read: (path2) => readFileSync3(path2, "utf8"),
941
+ write(path2, content, mode) {
942
+ mkdirSync3(dirname2(path2), { recursive: true, mode: 493 });
943
+ const next = `${path2}.next`;
944
+ writeFileSync3(next, content, { mode });
945
+ renameSync3(next, path2);
946
+ },
947
+ remove: (path2) => rmSync3(path2, { force: true }),
948
+ list: (path2) => existsSync3(path2) ? readdirSync2(path2) : [],
949
+ mkdir: (path2, mode) => mkdirSync3(path2, { recursive: true, mode }),
950
+ async exec(argv2) {
951
+ const child = Bun.spawn([...argv2], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", LANG: "C", LC_ALL: "C" } });
952
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
953
+ return { exitCode, output: `${stdout}${stderr}` };
954
+ },
955
+ async health(port, path2, timeoutMs, method, expectedStatus) {
956
+ try {
957
+ const response = await fetch(`http://127.0.0.1:${port}${path2}`, { method, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
958
+ return expectedStatus.includes(response.status);
959
+ } catch {
960
+ return false;
961
+ }
962
+ },
963
+ sleep: (ms) => Bun.sleep(ms),
964
+ now: Date.now
965
+ };
966
+ var within2 = (root, path2) => path2 === root || path2.startsWith(`${root}${sep2}`);
967
+ var idFor2 = (key) => createHash3("sha256").update(key).digest("hex").slice(0, 24);
968
+ var statePath2 = (id) => `${SERVICE_STATE_DIRECTORY}/${id}.json`;
969
+ var nameFor = (id, slot) => `forgezero-${id}-slot${slot}`;
970
+ function validateRequest(request, host) {
971
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/.test(request.key) || !/^[a-f0-9]{40}$/.test(request.revision) || !/^[a-z][A-Za-z0-9-]{0,62}$/.test(request.component))
972
+ throw new Error("CONTAINER_IDENTITY_INVALID");
973
+ const root = host.realpath(resolve2(request.root));
974
+ const release = host.realpath(resolve2(request.release));
975
+ if (!within2(root, release))
976
+ throw new Error("CONTAINER_RELEASE_OUTSIDE_ROOT");
977
+ const application = request.application;
978
+ if (application.kind !== "application" || application.runtime.kind !== "container")
979
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
980
+ if (!application.resources.cpu || !application.resources.memory || !application.resources.pids)
981
+ throw new Error("CONTAINER_LIMITS_REQUIRED");
982
+ if (application.runtime.security.privileged !== false || application.runtime.security.noNewPrivileges !== true || !application.runtime.security.dropCapabilities.includes("ALL"))
983
+ throw new Error("CONTAINER_SECURITY_INVALID");
984
+ if (!application.network?.container || !["bridge", "host"].includes(application.network.container.mode))
985
+ throw new Error("CONTAINER_NETWORK_INVALID");
986
+ if (!application.network.ingress?.stablePort)
987
+ throw new Error("CONTAINER_STABLE_PORT_REQUIRED");
988
+ return { ...request, root, release };
989
+ }
990
+ async function checked2(host, argv2, code) {
991
+ const result = await host.exec(argv2);
992
+ if (result.exitCode !== 0)
993
+ throw new Error(`${code}: ${result.output.slice(0, 512)}`);
994
+ return result.output.trim();
995
+ }
996
+ async function buildContainerImage(requestValue, host = defaultHost2) {
997
+ const request = validateRequest(requestValue, host);
998
+ const runtime = request.application.runtime;
999
+ if (runtime.kind !== "container")
1000
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
1001
+ const source = runtime.image.source;
1002
+ if (source.kind === "registry") {
1003
+ if (!source.reference.includes("@sha256:"))
1004
+ throw new Error("CONTAINER_IMAGE_DIGEST_REQUIRED");
1005
+ await checked2(host, ["/usr/bin/docker", "pull", "--quiet", source.reference], "CONTAINER_PULL_FAILED");
1006
+ const digest2 = await checked2(host, ["/usr/bin/docker", "image", "inspect", "--format={{.Id}}", source.reference], "CONTAINER_INSPECT_FAILED");
1007
+ if (!/^sha256:[a-f0-9]{64}$/.test(digest2))
1008
+ throw new Error("CONTAINER_DIGEST_INVALID");
1009
+ return { image: source.reference, digest: digest2 };
1010
+ }
1011
+ const context = host.realpath(resolve2(request.release, source.context));
1012
+ const dockerfile = host.realpath(resolve2(request.release, source.dockerfile));
1013
+ if (!within2(request.release, context) || !within2(context, dockerfile))
1014
+ throw new Error("CONTAINER_BUILD_PATH_INVALID");
1015
+ const tag = `forgezero/${idFor2(request.key)}:${request.revision}`;
1016
+ await checked2(host, [
1017
+ "/usr/bin/docker",
1018
+ "build",
1019
+ "--pull",
1020
+ ...request.noCache ? ["--no-cache"] : [],
1021
+ "--file",
1022
+ dockerfile,
1023
+ "--tag",
1024
+ tag,
1025
+ "--label",
1026
+ `net.forgezero.revision=${request.revision}`,
1027
+ "--label",
1028
+ `net.forgezero.component=${request.component}`,
1029
+ context
1030
+ ], "CONTAINER_BUILD_FAILED");
1031
+ const digest = await checked2(host, ["/usr/bin/docker", "image", "inspect", "--format={{.Id}}", tag], "CONTAINER_INSPECT_FAILED");
1032
+ if (!/^sha256:[a-f0-9]{64}$/.test(digest))
1033
+ throw new Error("CONTAINER_DIGEST_INVALID");
1034
+ return { image: tag, digest };
1035
+ }
1036
+ function readState2(host, id) {
1037
+ if (!host.exists(statePath2(id)))
1038
+ return null;
1039
+ try {
1040
+ const value = JSON.parse(host.read(statePath2(id)));
1041
+ return value.format === 1 ? value : null;
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+ function usedPorts(host, exceptId) {
1047
+ const ports = new Set;
1048
+ for (const name of host.list(SERVICE_STATE_DIRECTORY).filter((entry) => /^[a-f0-9]{24}\.json$/.test(entry))) {
1049
+ try {
1050
+ const state = JSON.parse(host.read(`${SERVICE_STATE_DIRECTORY}/${name}`));
1051
+ if (state.id !== exceptId) {
1052
+ ports.add(state.publicPort);
1053
+ state.applicationPorts.forEach((port) => ports.add(port));
1054
+ }
1055
+ } catch {}
1056
+ }
1057
+ return ports;
1058
+ }
1059
+ function allocatePorts(host, id, count, stablePort, previous) {
1060
+ const used = usedPorts(host, id);
1061
+ if (used.has(stablePort))
1062
+ throw new Error("CONTAINER_PUBLIC_PORT_CONFLICT");
1063
+ if (previous?.applicationPorts.length === count && previous.applicationPorts.every((port) => !used.has(port)))
1064
+ return [...previous.applicationPorts];
1065
+ const from = 20000;
1066
+ const to = 49999;
1067
+ const width = to - from + 1;
1068
+ const start = Number.parseInt(id.slice(0, 8), 16) % width;
1069
+ for (let offset = 0;offset < width; offset += 1) {
1070
+ const first = from + (start + offset) % width;
1071
+ const values = Array.from({ length: count }, (_, index) => first + index);
1072
+ if (values.at(-1) <= to && values.every((port) => port !== stablePort && !used.has(port)))
1073
+ return values;
1074
+ }
1075
+ throw new Error("CONTAINER_PORTS_EXHAUSTED");
1076
+ }
1077
+ function nginx2(request, id, port) {
1078
+ const service = request.application.service;
1079
+ const stablePort = request.application.network.ingress.stablePort;
1080
+ return `# ForgeZero container ${id}
1081
+ ${service.maximumConnections ? `limit_conn_zone $server_name zone=fz_${id}:64k;
1082
+ ` : ""}server {
1083
+ listen 127.0.0.1:${stablePort};
1084
+ server_name _;
1085
+ ${service.maximumConnections ? ` limit_conn fz_${id} ${service.maximumConnections};
1086
+ limit_conn_status 503;
1087
+ ` : ""} location / {
1088
+ proxy_pass http://127.0.0.1:${port};
1089
+ proxy_http_version 1.1;
1090
+ ${service.websocket ? ` proxy_set_header Upgrade $http_upgrade;
1091
+ proxy_set_header Connection "upgrade";
1092
+ ` : ""} proxy_set_header Host $host;
1093
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1094
+ proxy_set_header X-Forwarded-Proto $scheme;
1095
+ }
1096
+ }
1097
+ `;
1098
+ }
1099
+ function runArgv(request, id, slot, port, networkName) {
1100
+ const { application } = request;
1101
+ const resources = application.resources;
1102
+ const runtime = application.runtime;
1103
+ if (runtime.kind !== "container")
1104
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
1105
+ const memory = resources.memory;
1106
+ const network = application.network.container;
1107
+ const argv2 = [
1108
+ "/usr/bin/docker",
1109
+ "run",
1110
+ "--detach",
1111
+ "--name",
1112
+ nameFor(id, slot),
1113
+ "--restart",
1114
+ "unless-stopped",
1115
+ "--label",
1116
+ `net.forgezero.key=${request.key}`,
1117
+ "--label",
1118
+ `net.forgezero.revision=${request.revision}`,
1119
+ "--cpus",
1120
+ String(resources.cpu.limit),
1121
+ "--cpu-shares",
1122
+ String(resources.cpu.weight ?? 100),
1123
+ "--memory",
1124
+ `${memory.limitMiB}m`,
1125
+ ...memory.reservationMiB ? ["--memory-reservation", `${memory.reservationMiB}m`] : [],
1126
+ ...memory.swap === "disabled" ? ["--memory-swap", `${memory.limitMiB}m`] : [],
1127
+ "--pids-limit",
1128
+ String(resources.pids.limit),
1129
+ "--security-opt",
1130
+ "no-new-privileges=true",
1131
+ "--cap-drop",
1132
+ "ALL",
1133
+ ...runtime.security.root === "read-only" ? ["--read-only"] : [],
1134
+ ...network.mode === "host" ? ["--network", "host", "--env", `FZ_APP_PORT=${port}`] : ["--network", networkName, "--publish", `127.0.0.1:${port}:${application.service.port}`]
1135
+ ];
1136
+ for (const mount of application.storage ?? []) {
1137
+ if (mount.class === "ephemeral")
1138
+ argv2.push("--tmpfs", `${mount.path}:rw,noexec,nosuid,size=${mount.sizeMiB}m`);
1139
+ else
1140
+ argv2.push("--volume", `forgezero-${id}-${mount.name}:${mount.path}`);
1141
+ }
1142
+ argv2.push(request.image, ...runtime.entrypoint ?? []);
1143
+ return argv2;
1144
+ }
1145
+ async function activateContainer(requestValue, host = defaultHost2) {
1146
+ const request = validateRequest(requestValue, host);
1147
+ if (!/^sha256:[a-f0-9]{64}$/.test(request.image) && !/^forgezero\/[a-f0-9]{24}:[a-f0-9]{40}$/.test(request.image) && !request.image.includes("@sha256:"))
1148
+ throw new Error("CONTAINER_IMAGE_INVALID");
1149
+ const id = idFor2(request.key);
1150
+ host.mkdir(SERVICE_STATE_DIRECTORY, 448);
1151
+ host.mkdir(SERVICE_NGINX_DIRECTORY, 493);
1152
+ const previous = readState2(host, id);
1153
+ const slots = request.application.rollout.strategy === "blue-green" ? 2 : 1;
1154
+ if (!["direct", "blue-green"].includes(request.application.rollout.strategy))
1155
+ throw new Error("CONTAINER_ROLLOUT_REQUIRES_CONTROL_PLANE");
1156
+ const containerNetwork = request.application.network.container;
1157
+ let networkName = "host";
1158
+ if (containerNetwork.mode === "bridge") {
1159
+ networkName = containerNetwork.network ? `forgezero-${id}-${containerNetwork.network}` : "bridge";
1160
+ if (networkName !== "bridge") {
1161
+ const inspected = await host.exec(["/usr/bin/docker", "network", "inspect", '--format={{index .Labels "net.forgezero.owner"}}', networkName]);
1162
+ if (inspected.exitCode !== 0) {
1163
+ await checked2(host, ["/usr/bin/docker", "network", "create", "--driver", "bridge", "--label", `net.forgezero.owner=${id}`, networkName], "CONTAINER_NETWORK_CREATE_FAILED");
1164
+ } else if (inspected.output.trim() !== id)
1165
+ throw new Error("CONTAINER_NETWORK_OWNERSHIP_INVALID");
1166
+ }
1167
+ }
1168
+ const ports = allocatePorts(host, id, slots, request.application.network.ingress.stablePort, previous);
1169
+ const nextSlot = slots === 2 && previous?.activeSlot === 0 ? 1 : 0;
1170
+ const port = ports[nextSlot];
1171
+ const name = nameFor(id, nextSlot);
1172
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1173
+ await checked2(host, runArgv(request, id, nextSlot, port, networkName), "CONTAINER_START_FAILED");
1174
+ let healthy = false;
1175
+ const health = request.application.service.health;
1176
+ for (let attempt = 0;attempt < (health.attempts ?? 30); attempt += 1) {
1177
+ if (await host.health(port, health.path, health.timeoutMs, health.method, health.expectedStatus)) {
1178
+ healthy = true;
1179
+ break;
1180
+ }
1181
+ await host.sleep(health.intervalMs ?? 1000);
1182
+ }
1183
+ if (!healthy) {
1184
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1185
+ throw new Error("CONTAINER_HEALTH_FAILED");
1186
+ }
1187
+ const nginxPath = `${SERVICE_NGINX_DIRECTORY}/forgezero-${id}.conf`;
1188
+ const previousNginx = host.exists(nginxPath) ? host.read(nginxPath) : null;
1189
+ host.write(nginxPath, nginx2(request, id, port), 420);
1190
+ try {
1191
+ await checked2(host, ["/usr/sbin/nginx", "-t"], "CONTAINER_NGINX_INVALID");
1192
+ await checked2(host, ["/usr/bin/systemctl", "reload", "nginx.service"], "CONTAINER_NGINX_RELOAD_FAILED");
1193
+ } catch (cause) {
1194
+ if (previousNginx === null)
1195
+ host.remove(nginxPath);
1196
+ else
1197
+ host.write(nginxPath, previousNginx, 420);
1198
+ await host.exec(["/usr/sbin/nginx", "-t"]);
1199
+ await host.exec(["/usr/bin/systemctl", "reload", "nginx.service"]);
1200
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1201
+ throw cause;
1202
+ }
1203
+ const state = {
1204
+ format: 1,
1205
+ id,
1206
+ key: request.key,
1207
+ revision: request.revision,
1208
+ strategy: request.application.rollout.strategy,
1209
+ publicPort: request.application.network.ingress.stablePort,
1210
+ applicationPorts: ports,
1211
+ activeSlot: nextSlot,
1212
+ healthPath: health.path,
1213
+ updatedAtTs: host.now()
1214
+ };
1215
+ host.write(statePath2(id), `${JSON.stringify(state, null, 2)}
1216
+ `, 384);
1217
+ if (previous && previous.activeSlot !== nextSlot) {
1218
+ await host.sleep(request.application.rollout.drainMs ?? 30000);
1219
+ await host.exec(["/usr/bin/docker", "rm", "--force", nameFor(id, previous.activeSlot)]);
1220
+ }
1221
+ return state;
1222
+ }
1223
+
901
1224
  // src/software-helper.ts
902
1225
  var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
903
1226
  var SOFTWARE_HELPER_GROUP = "forgezero-software";
@@ -906,11 +1229,13 @@ var MAX_REQUEST_BYTES = 128 * 1024;
906
1229
  var MAX_PENDING_REQUESTS = 128;
907
1230
  function startSoftwareHelper(options = {}) {
908
1231
  const socketPath = options.socketPath ?? DEFAULT_SOFTWARE_HELPER_SOCKET;
909
- if (existsSync2(socketPath))
1232
+ if (existsSync4(socketPath))
910
1233
  unlinkSync2(socketPath);
911
- mkdirSync3(dirname2(socketPath), { recursive: true, mode: 488 });
1234
+ mkdirSync4(dirname3(socketPath), { recursive: true, mode: 488 });
912
1235
  const ensure = options.ensure ?? ensureSoftwareRequirements;
913
1236
  const activate = options.activate ?? activateSupervisedService;
1237
+ const buildContainer = options.buildContainer ?? buildContainerImage;
1238
+ const activateTypedContainer = options.activateContainer ?? activateContainer;
914
1239
  let tail = Promise.resolve();
915
1240
  let pending = 0;
916
1241
  const server = createServer((socket) => {
@@ -943,10 +1268,29 @@ function startSoftwareHelper(options = {}) {
943
1268
  return;
944
1269
  }
945
1270
  if (request.op === "activate-service" && request.request) {
946
- if (!options.allowedRoot || request.request.root !== options.allowedRoot) {
1271
+ const serviceRequest = request.request;
1272
+ if (!options.allowedRoot || serviceRequest.root !== options.allowedRoot) {
947
1273
  throw new Error("service deployment root is not owned by this helper");
948
1274
  }
949
- const state = await activate(request.request);
1275
+ const state = await activate(serviceRequest);
1276
+ socket.end(`${JSON.stringify({ ok: true, state })}
1277
+ `);
1278
+ return;
1279
+ }
1280
+ if (request.op === "build-container" && request.request) {
1281
+ const containerRequest = request.request;
1282
+ if (!options.allowedRoot || containerRequest.root !== options.allowedRoot)
1283
+ throw new Error("container deployment root is not owned by this helper");
1284
+ const result = await buildContainer(containerRequest);
1285
+ socket.end(`${JSON.stringify({ ok: true, result })}
1286
+ `);
1287
+ return;
1288
+ }
1289
+ if (request.op === "activate-container" && request.request) {
1290
+ const containerRequest = request.request;
1291
+ if (!options.allowedRoot || containerRequest.root !== options.allowedRoot)
1292
+ throw new Error("container deployment root is not owned by this helper");
1293
+ const state = await activateTypedContainer(containerRequest);
950
1294
  socket.end(`${JSON.stringify({ ok: true, state })}
951
1295
  `);
952
1296
  return;
@@ -970,8 +1314,43 @@ function startSoftwareHelper(options = {}) {
970
1314
  server.listen(socketPath, () => chmodSync2(socketPath, 432));
971
1315
  return server;
972
1316
  }
1317
+ function requestContainer(op, request, socketPath, timeoutMs) {
1318
+ return new Promise((resolve3, reject) => {
1319
+ const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op, request })}
1320
+ `));
1321
+ let buffer = "";
1322
+ socket.setTimeout(timeoutMs, () => {
1323
+ socket.destroy();
1324
+ reject(new Error("container helper timed out"));
1325
+ });
1326
+ socket.on("data", (chunk) => {
1327
+ buffer += chunk.toString("utf8");
1328
+ const newline = buffer.indexOf(`
1329
+ `);
1330
+ if (newline < 0)
1331
+ return;
1332
+ socket.end();
1333
+ try {
1334
+ const response = JSON.parse(buffer.slice(0, newline));
1335
+ const result = response.result ?? response.state;
1336
+ if (!response.ok || !result)
1337
+ throw new Error(response.error?.message ?? "container helper refused request");
1338
+ resolve3(result);
1339
+ } catch (cause) {
1340
+ reject(cause);
1341
+ }
1342
+ });
1343
+ socket.on("error", reject);
1344
+ });
1345
+ }
1346
+ function requestContainerBuild(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 30 * 60000) {
1347
+ return requestContainer("build-container", request, socketPath, timeoutMs);
1348
+ }
1349
+ function requestContainerActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
1350
+ return requestContainer("activate-container", request, socketPath, timeoutMs);
1351
+ }
973
1352
  function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 10 * 60000) {
974
- return new Promise((resolve2, reject) => {
1353
+ return new Promise((resolve3, reject) => {
975
1354
  const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "activate-service", request })}
976
1355
  `));
977
1356
  let buffer = "";
@@ -990,7 +1369,7 @@ function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_
990
1369
  const response = JSON.parse(buffer.slice(0, newline));
991
1370
  if (!response.ok || !response.state)
992
1371
  throw new Error(response.error?.message ?? "service helper refused activation");
993
- resolve2(response.state);
1372
+ resolve3(response.state);
994
1373
  } catch (cause) {
995
1374
  reject(cause);
996
1375
  }
@@ -1000,7 +1379,7 @@ function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_
1000
1379
  }
1001
1380
  function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
1002
1381
  validateSoftwareRequirements(requirements);
1003
- return new Promise((resolve2, reject) => {
1382
+ return new Promise((resolve3, reject) => {
1004
1383
  const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
1005
1384
  `));
1006
1385
  let buffer = "";
@@ -1019,7 +1398,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
1019
1398
  const response = JSON.parse(buffer.slice(0, newline));
1020
1399
  if (!response.ok || !response.results)
1021
1400
  throw new Error(response.error?.message ?? "software helper refused the request");
1022
- resolve2(response.results);
1401
+ resolve3(response.results);
1023
1402
  } catch (cause) {
1024
1403
  reject(cause);
1025
1404
  }
@@ -1031,6 +1410,8 @@ export {
1031
1410
  startSoftwareHelper,
1032
1411
  requestSoftware,
1033
1412
  requestServiceActivation,
1413
+ requestContainerBuild,
1414
+ requestContainerActivation,
1034
1415
  SOFTWARE_HELPER_UNIT_PATH,
1035
1416
  SOFTWARE_HELPER_GROUP,
1036
1417
  DEFAULT_SOFTWARE_HELPER_SOCKET
@@ -1,6 +1,6 @@
1
1
  export type CatalogStatus = 'testing' | 'active' | 'retired';
2
2
  export type DeploymentChannel = 'development' | 'production';
3
- export type SoftwareId = 'bun' | 'nginx' | 'arangodb' | 'cloudflared' | 'cloudflare-warp' | 'ufw' | 'openssh-client';
3
+ export type SoftwareId = 'bun' | 'docker' | 'nginx' | 'arangodb' | 'cloudflared' | 'cloudflare-warp' | 'ufw' | 'openssh-client';
4
4
  /** Repository input is a catalogue coordinate, never a root command. */
5
5
  export interface SoftwareRequirement {
6
6
  id: SoftwareId;
package/dist/software.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  accessSync,
5
5
  chmodSync,
6
6
  copyFileSync,
7
+ existsSync,
7
8
  mkdtempSync,
8
9
  mkdirSync,
9
10
  readFileSync,
@@ -24,6 +25,7 @@ var OS_CATALOG = [
24
25
  ];
25
26
  var SOFTWARE_CATALOG = [
26
27
  { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
28
+ { id: "docker", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
27
29
  { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
28
30
  { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
29
31
  { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
@@ -33,6 +35,7 @@ var SOFTWARE_CATALOG = [
33
35
  ];
34
36
  var UBUNTU_2604_X64 = [
35
37
  { requirement: { id: "bun", version: "1.3.14" } },
38
+ { requirement: { id: "docker", version: "ubuntu-26.04" } },
36
39
  { requirement: { id: "nginx", version: "ubuntu-26.04" } },
37
40
  { requirement: { id: "arangodb", version: "3.11.14" } },
38
41
  { requirement: { id: "cloudflared", version: "2026.7.3" } },
@@ -41,6 +44,15 @@ var UBUNTU_2604_X64 = [
41
44
  { requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
42
45
  ];
43
46
  var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
47
+ var DOCKER_DAEMON_PATH = "/etc/docker/daemon.json";
48
+ var DOCKER_DAEMON_CONFIG = `${JSON.stringify({
49
+ "data-root": "/var/lib/docker",
50
+ "storage-driver": "overlay2",
51
+ "live-restore": true,
52
+ "log-driver": "local",
53
+ "log-opts": { "max-size": "10m", "max-file": "3" }
54
+ }, null, 2)}
55
+ `;
44
56
  var softwareSpawnFailure = (cause, argv) => cause.code === "ENOENT" ? { exitCode: 127, output: `executable is absent: ${argv[0]}` } : undefined;
45
57
  var runSoftwareCommand = async (argv, env = {}) => {
46
58
  let child;
@@ -83,12 +95,21 @@ async function executeSoftwareOperation(operation) {
83
95
  if (operation.kind === "check") {
84
96
  if (software === "bun")
85
97
  return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
98
+ if (software === "docker") {
99
+ const binary = await run(["/usr/bin/docker", "--version"]);
100
+ if (!successful(binary, /Docker version/))
101
+ return { ...binary, exitCode: 1 };
102
+ if (!existsSync(DOCKER_DAEMON_PATH) || readFileSync(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG)
103
+ return { exitCode: 1, output: "Docker daemon policy is absent or differs from the reviewed ForgeZero policy" };
104
+ const active = await run(["/usr/bin/systemctl", "is-active", "--quiet", "docker.service"]);
105
+ return active.exitCode === 0 ? { exitCode: 0, output: binary.output } : active;
106
+ }
86
107
  if (software === "nginx") {
87
108
  const binary = await run(["/usr/sbin/nginx", "-v"]);
88
109
  return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
89
110
  }
90
111
  if (software === "arangodb") {
91
- const binary = await run(["/usr/bin/arangod", "--version"]);
112
+ const binary = await run(["/usr/sbin/arangod", "--version"]);
92
113
  if (!successful(binary, /3\.11\.14/))
93
114
  return { ...binary, exitCode: 1 };
94
115
  const [active, enabled] = await Promise.all([
@@ -115,11 +136,22 @@ async function executeSoftwareOperation(operation) {
115
136
  return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
116
137
  }
117
138
  }
118
- if (software === "nginx" || software === "ufw" || software === "openssh-client") {
119
- const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
120
- if (installed.exitCode !== 0 || software !== "nginx")
139
+ if (software === "docker" || software === "nginx" || software === "ufw" || software === "openssh-client") {
140
+ const packageName = software === "openssh-client" ? "openssh-client" : software === "docker" ? "docker.io" : software;
141
+ const installed = await aptInstall(packageName);
142
+ if (installed.exitCode !== 0 || software !== "nginx" && software !== "docker")
121
143
  return installed;
122
- return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
144
+ if (software === "docker") {
145
+ mkdirSync("/etc/docker", { recursive: true, mode: 493 });
146
+ if (existsSync(DOCKER_DAEMON_PATH) && readFileSync(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG) {
147
+ return { exitCode: 2, output: "refusing to overwrite an existing Docker daemon configuration that differs from the reviewed ForgeZero policy" };
148
+ }
149
+ if (!existsSync(DOCKER_DAEMON_PATH))
150
+ writeFileSync(DOCKER_DAEMON_PATH, DOCKER_DAEMON_CONFIG, { mode: 420, flag: "wx" });
151
+ const enabled = await run(["/usr/bin/systemctl", "enable", "docker.service"]);
152
+ return enabled.exitCode === 0 ? run(["/usr/bin/systemctl", "restart", "docker.service"]) : enabled;
153
+ }
154
+ return run(["/usr/bin/systemctl", "enable", "--now", `${software}.service`]);
123
155
  }
124
156
  const directory = mkdtempSync(join(tmpdir(), "forgezero-software-"));
125
157
  try {
@@ -204,7 +236,7 @@ function validateSoftwareRequirements(value, _options = {}) {
204
236
  if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
205
237
  throw new Error("software requirement contains an unknown field");
206
238
  }
207
- 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)) {
239
+ if (!["bun", "docker", "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)) {
208
240
  throw new Error("software requirement coordinate is invalid");
209
241
  }
210
242
  const requirement = { id: row.id, version: row.version };
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.52";
2
+ export declare const VERSION = "0.1.55";