@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.
package/dist/provision.js CHANGED
@@ -682,6 +682,7 @@ import {
682
682
  accessSync,
683
683
  chmodSync as chmodSync3,
684
684
  copyFileSync,
685
+ existsSync as existsSync3,
685
686
  mkdtempSync,
686
687
  mkdirSync as mkdirSync3,
687
688
  readFileSync as readFileSync3,
@@ -702,6 +703,7 @@ var OS_CATALOG = [
702
703
  ];
703
704
  var SOFTWARE_CATALOG = [
704
705
  { id: "bun", version: "1.3.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
706
+ { id: "docker", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
705
707
  { id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
706
708
  { id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
707
709
  { id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
@@ -711,6 +713,7 @@ var SOFTWARE_CATALOG = [
711
713
  ];
712
714
  var UBUNTU_2604_X64 = [
713
715
  { requirement: { id: "bun", version: "1.3.14" } },
716
+ { requirement: { id: "docker", version: "ubuntu-26.04" } },
714
717
  { requirement: { id: "nginx", version: "ubuntu-26.04" } },
715
718
  { requirement: { id: "arangodb", version: "3.11.14" } },
716
719
  { requirement: { id: "cloudflared", version: "2026.7.3" } },
@@ -719,6 +722,15 @@ var UBUNTU_2604_X64 = [
719
722
  { requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
720
723
  ];
721
724
  var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
725
+ var DOCKER_DAEMON_PATH = "/etc/docker/daemon.json";
726
+ var DOCKER_DAEMON_CONFIG = `${JSON.stringify({
727
+ "data-root": "/var/lib/docker",
728
+ "storage-driver": "overlay2",
729
+ "live-restore": true,
730
+ "log-driver": "local",
731
+ "log-opts": { "max-size": "10m", "max-file": "3" }
732
+ }, null, 2)}
733
+ `;
722
734
  var softwareSpawnFailure = (cause, argv) => cause.code === "ENOENT" ? { exitCode: 127, output: `executable is absent: ${argv[0]}` } : undefined;
723
735
  var runSoftwareCommand = async (argv, env = {}) => {
724
736
  let child;
@@ -761,12 +773,21 @@ async function executeSoftwareOperation(operation) {
761
773
  if (operation.kind === "check") {
762
774
  if (software === "bun")
763
775
  return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
776
+ if (software === "docker") {
777
+ const binary = await run(["/usr/bin/docker", "--version"]);
778
+ if (!successful(binary, /Docker version/))
779
+ return { ...binary, exitCode: 1 };
780
+ if (!existsSync3(DOCKER_DAEMON_PATH) || readFileSync3(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG)
781
+ return { exitCode: 1, output: "Docker daemon policy is absent or differs from the reviewed ForgeZero policy" };
782
+ const active = await run(["/usr/bin/systemctl", "is-active", "--quiet", "docker.service"]);
783
+ return active.exitCode === 0 ? { exitCode: 0, output: binary.output } : active;
784
+ }
764
785
  if (software === "nginx") {
765
786
  const binary = await run(["/usr/sbin/nginx", "-v"]);
766
787
  return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
767
788
  }
768
789
  if (software === "arangodb") {
769
- const binary = await run(["/usr/bin/arangod", "--version"]);
790
+ const binary = await run(["/usr/sbin/arangod", "--version"]);
770
791
  if (!successful(binary, /3\.11\.14/))
771
792
  return { ...binary, exitCode: 1 };
772
793
  const [active, enabled] = await Promise.all([
@@ -793,11 +814,22 @@ async function executeSoftwareOperation(operation) {
793
814
  return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
794
815
  }
795
816
  }
796
- if (software === "nginx" || software === "ufw" || software === "openssh-client") {
797
- const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
798
- if (installed.exitCode !== 0 || software !== "nginx")
817
+ if (software === "docker" || software === "nginx" || software === "ufw" || software === "openssh-client") {
818
+ const packageName = software === "openssh-client" ? "openssh-client" : software === "docker" ? "docker.io" : software;
819
+ const installed = await aptInstall(packageName);
820
+ if (installed.exitCode !== 0 || software !== "nginx" && software !== "docker")
799
821
  return installed;
800
- return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
822
+ if (software === "docker") {
823
+ mkdirSync3("/etc/docker", { recursive: true, mode: 493 });
824
+ if (existsSync3(DOCKER_DAEMON_PATH) && readFileSync3(DOCKER_DAEMON_PATH, "utf8") !== DOCKER_DAEMON_CONFIG) {
825
+ return { exitCode: 2, output: "refusing to overwrite an existing Docker daemon configuration that differs from the reviewed ForgeZero policy" };
826
+ }
827
+ if (!existsSync3(DOCKER_DAEMON_PATH))
828
+ writeFileSync3(DOCKER_DAEMON_PATH, DOCKER_DAEMON_CONFIG, { mode: 420, flag: "wx" });
829
+ const enabled = await run(["/usr/bin/systemctl", "enable", "docker.service"]);
830
+ return enabled.exitCode === 0 ? run(["/usr/bin/systemctl", "restart", "docker.service"]) : enabled;
831
+ }
832
+ return run(["/usr/bin/systemctl", "enable", "--now", `${software}.service`]);
801
833
  }
802
834
  const directory = mkdtempSync(join3(tmpdir(), "forgezero-software-"));
803
835
  try {
@@ -882,7 +914,7 @@ function validateSoftwareRequirements(value, _options = {}) {
882
914
  if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
883
915
  throw new Error("software requirement contains an unknown field");
884
916
  }
885
- 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)) {
917
+ 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)) {
886
918
  throw new Error("software requirement coordinate is invalid");
887
919
  }
888
920
  const requirement = { id: row.id, version: row.version };
@@ -1355,13 +1387,13 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1355
1387
  }
1356
1388
 
1357
1389
  // src/software-helper.ts
1358
- import { chmodSync as chmodSync4, existsSync as existsSync4, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3 } from "node:fs";
1390
+ import { chmodSync as chmodSync4, existsSync as existsSync6, mkdirSync as mkdirSync6, unlinkSync as unlinkSync3 } from "node:fs";
1359
1391
  import { connect as connect2, createServer as createServer2 } from "node:net";
1360
- import { dirname as dirname4 } from "node:path";
1392
+ import { dirname as dirname5 } from "node:path";
1361
1393
 
1362
1394
  // src/service-supervisor.ts
1363
1395
  import { createHash as createHash3 } from "node:crypto";
1364
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync, realpathSync, renameSync as renameSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
1396
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync, realpathSync, renameSync as renameSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
1365
1397
  import { dirname as dirname3, join as join4, resolve as resolve3, sep } from "node:path";
1366
1398
  var SERVICE_STATE_DIRECTORY = "/var/lib/forgezero/services";
1367
1399
  var SERVICE_CONFIG_DIRECTORY = "/etc/forgezero/services";
@@ -1378,8 +1410,8 @@ var defaultHost = {
1378
1410
  renameSync4(next, path2);
1379
1411
  },
1380
1412
  read: (path2) => readFileSync4(path2, "utf8"),
1381
- exists: existsSync3,
1382
- list: (path2) => existsSync3(path2) ? readdirSync(path2) : [],
1413
+ exists: existsSync4,
1414
+ list: (path2) => existsSync4(path2) ? readdirSync(path2) : [],
1383
1415
  realpath: realpathSync,
1384
1416
  mkdir: (path2, mode) => mkdirSync4(path2, { recursive: true, mode }),
1385
1417
  remove: (path2) => rmSync4(path2, { force: true }),
@@ -1576,6 +1608,297 @@ async function activateSupervisedService(request, host = defaultHost) {
1576
1608
  return state;
1577
1609
  }
1578
1610
 
1611
+ // src/container-supervisor.ts
1612
+ import { createHash as createHash4 } from "node:crypto";
1613
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync2, realpathSync as realpathSync2, renameSync as renameSync5, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "node:fs";
1614
+ import { dirname as dirname4, resolve as resolve4, sep as sep2 } from "node:path";
1615
+ var defaultHost2 = {
1616
+ realpath: realpathSync2,
1617
+ exists: existsSync5,
1618
+ read: (path2) => readFileSync5(path2, "utf8"),
1619
+ write(path2, content, mode) {
1620
+ mkdirSync5(dirname4(path2), { recursive: true, mode: 493 });
1621
+ const next = `${path2}.next`;
1622
+ writeFileSync5(next, content, { mode });
1623
+ renameSync5(next, path2);
1624
+ },
1625
+ remove: (path2) => rmSync5(path2, { force: true }),
1626
+ list: (path2) => existsSync5(path2) ? readdirSync2(path2) : [],
1627
+ mkdir: (path2, mode) => mkdirSync5(path2, { recursive: true, mode }),
1628
+ async exec(argv2) {
1629
+ 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" } });
1630
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
1631
+ return { exitCode, output: `${stdout}${stderr}` };
1632
+ },
1633
+ async health(port, path2, timeoutMs, method, expectedStatus) {
1634
+ try {
1635
+ const response = await fetch(`http://127.0.0.1:${port}${path2}`, { method, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
1636
+ return expectedStatus.includes(response.status);
1637
+ } catch {
1638
+ return false;
1639
+ }
1640
+ },
1641
+ sleep: (ms) => Bun.sleep(ms),
1642
+ now: Date.now
1643
+ };
1644
+ var within2 = (root, path2) => path2 === root || path2.startsWith(`${root}${sep2}`);
1645
+ var idFor2 = (key) => createHash4("sha256").update(key).digest("hex").slice(0, 24);
1646
+ var statePath2 = (id) => `${SERVICE_STATE_DIRECTORY}/${id}.json`;
1647
+ var nameFor = (id, slot) => `forgezero-${id}-slot${slot}`;
1648
+ function validateRequest(request, host) {
1649
+ 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))
1650
+ throw new Error("CONTAINER_IDENTITY_INVALID");
1651
+ const root = host.realpath(resolve4(request.root));
1652
+ const release = host.realpath(resolve4(request.release));
1653
+ if (!within2(root, release))
1654
+ throw new Error("CONTAINER_RELEASE_OUTSIDE_ROOT");
1655
+ const application = request.application;
1656
+ if (application.kind !== "application" || application.runtime.kind !== "container")
1657
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
1658
+ if (!application.resources.cpu || !application.resources.memory || !application.resources.pids)
1659
+ throw new Error("CONTAINER_LIMITS_REQUIRED");
1660
+ if (application.runtime.security.privileged !== false || application.runtime.security.noNewPrivileges !== true || !application.runtime.security.dropCapabilities.includes("ALL"))
1661
+ throw new Error("CONTAINER_SECURITY_INVALID");
1662
+ if (!application.network?.container || !["bridge", "host"].includes(application.network.container.mode))
1663
+ throw new Error("CONTAINER_NETWORK_INVALID");
1664
+ if (!application.network.ingress?.stablePort)
1665
+ throw new Error("CONTAINER_STABLE_PORT_REQUIRED");
1666
+ return { ...request, root, release };
1667
+ }
1668
+ async function checked3(host, argv2, code) {
1669
+ const result = await host.exec(argv2);
1670
+ if (result.exitCode !== 0)
1671
+ throw new Error(`${code}: ${result.output.slice(0, 512)}`);
1672
+ return result.output.trim();
1673
+ }
1674
+ async function buildContainerImage(requestValue, host = defaultHost2) {
1675
+ const request = validateRequest(requestValue, host);
1676
+ const runtime = request.application.runtime;
1677
+ if (runtime.kind !== "container")
1678
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
1679
+ const source = runtime.image.source;
1680
+ if (source.kind === "registry") {
1681
+ if (!source.reference.includes("@sha256:"))
1682
+ throw new Error("CONTAINER_IMAGE_DIGEST_REQUIRED");
1683
+ await checked3(host, ["/usr/bin/docker", "pull", "--quiet", source.reference], "CONTAINER_PULL_FAILED");
1684
+ const digest2 = await checked3(host, ["/usr/bin/docker", "image", "inspect", "--format={{.Id}}", source.reference], "CONTAINER_INSPECT_FAILED");
1685
+ if (!/^sha256:[a-f0-9]{64}$/.test(digest2))
1686
+ throw new Error("CONTAINER_DIGEST_INVALID");
1687
+ return { image: source.reference, digest: digest2 };
1688
+ }
1689
+ const context = host.realpath(resolve4(request.release, source.context));
1690
+ const dockerfile = host.realpath(resolve4(request.release, source.dockerfile));
1691
+ if (!within2(request.release, context) || !within2(context, dockerfile))
1692
+ throw new Error("CONTAINER_BUILD_PATH_INVALID");
1693
+ const tag = `forgezero/${idFor2(request.key)}:${request.revision}`;
1694
+ await checked3(host, [
1695
+ "/usr/bin/docker",
1696
+ "build",
1697
+ "--pull",
1698
+ ...request.noCache ? ["--no-cache"] : [],
1699
+ "--file",
1700
+ dockerfile,
1701
+ "--tag",
1702
+ tag,
1703
+ "--label",
1704
+ `net.forgezero.revision=${request.revision}`,
1705
+ "--label",
1706
+ `net.forgezero.component=${request.component}`,
1707
+ context
1708
+ ], "CONTAINER_BUILD_FAILED");
1709
+ const digest = await checked3(host, ["/usr/bin/docker", "image", "inspect", "--format={{.Id}}", tag], "CONTAINER_INSPECT_FAILED");
1710
+ if (!/^sha256:[a-f0-9]{64}$/.test(digest))
1711
+ throw new Error("CONTAINER_DIGEST_INVALID");
1712
+ return { image: tag, digest };
1713
+ }
1714
+ function readState2(host, id) {
1715
+ if (!host.exists(statePath2(id)))
1716
+ return null;
1717
+ try {
1718
+ const value = JSON.parse(host.read(statePath2(id)));
1719
+ return value.format === 1 ? value : null;
1720
+ } catch {
1721
+ return null;
1722
+ }
1723
+ }
1724
+ function usedPorts(host, exceptId) {
1725
+ const ports = new Set;
1726
+ for (const name of host.list(SERVICE_STATE_DIRECTORY).filter((entry) => /^[a-f0-9]{24}\.json$/.test(entry))) {
1727
+ try {
1728
+ const state = JSON.parse(host.read(`${SERVICE_STATE_DIRECTORY}/${name}`));
1729
+ if (state.id !== exceptId) {
1730
+ ports.add(state.publicPort);
1731
+ state.applicationPorts.forEach((port) => ports.add(port));
1732
+ }
1733
+ } catch {}
1734
+ }
1735
+ return ports;
1736
+ }
1737
+ function allocatePorts(host, id, count, stablePort, previous) {
1738
+ const used = usedPorts(host, id);
1739
+ if (used.has(stablePort))
1740
+ throw new Error("CONTAINER_PUBLIC_PORT_CONFLICT");
1741
+ if (previous?.applicationPorts.length === count && previous.applicationPorts.every((port) => !used.has(port)))
1742
+ return [...previous.applicationPorts];
1743
+ const from = 20000;
1744
+ const to = 49999;
1745
+ const width = to - from + 1;
1746
+ const start = Number.parseInt(id.slice(0, 8), 16) % width;
1747
+ for (let offset = 0;offset < width; offset += 1) {
1748
+ const first = from + (start + offset) % width;
1749
+ const values = Array.from({ length: count }, (_, index) => first + index);
1750
+ if (values.at(-1) <= to && values.every((port) => port !== stablePort && !used.has(port)))
1751
+ return values;
1752
+ }
1753
+ throw new Error("CONTAINER_PORTS_EXHAUSTED");
1754
+ }
1755
+ function nginx2(request, id, port) {
1756
+ const service = request.application.service;
1757
+ const stablePort = request.application.network.ingress.stablePort;
1758
+ return `# ForgeZero container ${id}
1759
+ ${service.maximumConnections ? `limit_conn_zone $server_name zone=fz_${id}:64k;
1760
+ ` : ""}server {
1761
+ listen 127.0.0.1:${stablePort};
1762
+ server_name _;
1763
+ ${service.maximumConnections ? ` limit_conn fz_${id} ${service.maximumConnections};
1764
+ limit_conn_status 503;
1765
+ ` : ""} location / {
1766
+ proxy_pass http://127.0.0.1:${port};
1767
+ proxy_http_version 1.1;
1768
+ ${service.websocket ? ` proxy_set_header Upgrade $http_upgrade;
1769
+ proxy_set_header Connection "upgrade";
1770
+ ` : ""} proxy_set_header Host $host;
1771
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1772
+ proxy_set_header X-Forwarded-Proto $scheme;
1773
+ }
1774
+ }
1775
+ `;
1776
+ }
1777
+ function runArgv(request, id, slot, port, networkName) {
1778
+ const { application } = request;
1779
+ const resources = application.resources;
1780
+ const runtime = application.runtime;
1781
+ if (runtime.kind !== "container")
1782
+ throw new Error("CONTAINER_RUNTIME_REQUIRED");
1783
+ const memory = resources.memory;
1784
+ const network = application.network.container;
1785
+ const argv2 = [
1786
+ "/usr/bin/docker",
1787
+ "run",
1788
+ "--detach",
1789
+ "--name",
1790
+ nameFor(id, slot),
1791
+ "--restart",
1792
+ "unless-stopped",
1793
+ "--label",
1794
+ `net.forgezero.key=${request.key}`,
1795
+ "--label",
1796
+ `net.forgezero.revision=${request.revision}`,
1797
+ "--cpus",
1798
+ String(resources.cpu.limit),
1799
+ "--cpu-shares",
1800
+ String(resources.cpu.weight ?? 100),
1801
+ "--memory",
1802
+ `${memory.limitMiB}m`,
1803
+ ...memory.reservationMiB ? ["--memory-reservation", `${memory.reservationMiB}m`] : [],
1804
+ ...memory.swap === "disabled" ? ["--memory-swap", `${memory.limitMiB}m`] : [],
1805
+ "--pids-limit",
1806
+ String(resources.pids.limit),
1807
+ "--security-opt",
1808
+ "no-new-privileges=true",
1809
+ "--cap-drop",
1810
+ "ALL",
1811
+ ...runtime.security.root === "read-only" ? ["--read-only"] : [],
1812
+ ...network.mode === "host" ? ["--network", "host", "--env", `FZ_APP_PORT=${port}`] : ["--network", networkName, "--publish", `127.0.0.1:${port}:${application.service.port}`]
1813
+ ];
1814
+ for (const mount of application.storage ?? []) {
1815
+ if (mount.class === "ephemeral")
1816
+ argv2.push("--tmpfs", `${mount.path}:rw,noexec,nosuid,size=${mount.sizeMiB}m`);
1817
+ else
1818
+ argv2.push("--volume", `forgezero-${id}-${mount.name}:${mount.path}`);
1819
+ }
1820
+ argv2.push(request.image, ...runtime.entrypoint ?? []);
1821
+ return argv2;
1822
+ }
1823
+ async function activateContainer(requestValue, host = defaultHost2) {
1824
+ const request = validateRequest(requestValue, host);
1825
+ 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:"))
1826
+ throw new Error("CONTAINER_IMAGE_INVALID");
1827
+ const id = idFor2(request.key);
1828
+ host.mkdir(SERVICE_STATE_DIRECTORY, 448);
1829
+ host.mkdir(SERVICE_NGINX_DIRECTORY, 493);
1830
+ const previous = readState2(host, id);
1831
+ const slots = request.application.rollout.strategy === "blue-green" ? 2 : 1;
1832
+ if (!["direct", "blue-green"].includes(request.application.rollout.strategy))
1833
+ throw new Error("CONTAINER_ROLLOUT_REQUIRES_CONTROL_PLANE");
1834
+ const containerNetwork = request.application.network.container;
1835
+ let networkName = "host";
1836
+ if (containerNetwork.mode === "bridge") {
1837
+ networkName = containerNetwork.network ? `forgezero-${id}-${containerNetwork.network}` : "bridge";
1838
+ if (networkName !== "bridge") {
1839
+ const inspected = await host.exec(["/usr/bin/docker", "network", "inspect", '--format={{index .Labels "net.forgezero.owner"}}', networkName]);
1840
+ if (inspected.exitCode !== 0) {
1841
+ await checked3(host, ["/usr/bin/docker", "network", "create", "--driver", "bridge", "--label", `net.forgezero.owner=${id}`, networkName], "CONTAINER_NETWORK_CREATE_FAILED");
1842
+ } else if (inspected.output.trim() !== id)
1843
+ throw new Error("CONTAINER_NETWORK_OWNERSHIP_INVALID");
1844
+ }
1845
+ }
1846
+ const ports = allocatePorts(host, id, slots, request.application.network.ingress.stablePort, previous);
1847
+ const nextSlot = slots === 2 && previous?.activeSlot === 0 ? 1 : 0;
1848
+ const port = ports[nextSlot];
1849
+ const name = nameFor(id, nextSlot);
1850
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1851
+ await checked3(host, runArgv(request, id, nextSlot, port, networkName), "CONTAINER_START_FAILED");
1852
+ let healthy = false;
1853
+ const health = request.application.service.health;
1854
+ for (let attempt = 0;attempt < (health.attempts ?? 30); attempt += 1) {
1855
+ if (await host.health(port, health.path, health.timeoutMs, health.method, health.expectedStatus)) {
1856
+ healthy = true;
1857
+ break;
1858
+ }
1859
+ await host.sleep(health.intervalMs ?? 1000);
1860
+ }
1861
+ if (!healthy) {
1862
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1863
+ throw new Error("CONTAINER_HEALTH_FAILED");
1864
+ }
1865
+ const nginxPath = `${SERVICE_NGINX_DIRECTORY}/forgezero-${id}.conf`;
1866
+ const previousNginx = host.exists(nginxPath) ? host.read(nginxPath) : null;
1867
+ host.write(nginxPath, nginx2(request, id, port), 420);
1868
+ try {
1869
+ await checked3(host, ["/usr/sbin/nginx", "-t"], "CONTAINER_NGINX_INVALID");
1870
+ await checked3(host, ["/usr/bin/systemctl", "reload", "nginx.service"], "CONTAINER_NGINX_RELOAD_FAILED");
1871
+ } catch (cause) {
1872
+ if (previousNginx === null)
1873
+ host.remove(nginxPath);
1874
+ else
1875
+ host.write(nginxPath, previousNginx, 420);
1876
+ await host.exec(["/usr/sbin/nginx", "-t"]);
1877
+ await host.exec(["/usr/bin/systemctl", "reload", "nginx.service"]);
1878
+ await host.exec(["/usr/bin/docker", "rm", "--force", name]);
1879
+ throw cause;
1880
+ }
1881
+ const state = {
1882
+ format: 1,
1883
+ id,
1884
+ key: request.key,
1885
+ revision: request.revision,
1886
+ strategy: request.application.rollout.strategy,
1887
+ publicPort: request.application.network.ingress.stablePort,
1888
+ applicationPorts: ports,
1889
+ activeSlot: nextSlot,
1890
+ healthPath: health.path,
1891
+ updatedAtTs: host.now()
1892
+ };
1893
+ host.write(statePath2(id), `${JSON.stringify(state, null, 2)}
1894
+ `, 384);
1895
+ if (previous && previous.activeSlot !== nextSlot) {
1896
+ await host.sleep(request.application.rollout.drainMs ?? 30000);
1897
+ await host.exec(["/usr/bin/docker", "rm", "--force", nameFor(id, previous.activeSlot)]);
1898
+ }
1899
+ return state;
1900
+ }
1901
+
1579
1902
  // src/software-helper.ts
1580
1903
  var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
1581
1904
  var SOFTWARE_HELPER_GROUP = "forgezero-software";
@@ -1584,11 +1907,13 @@ var MAX_REQUEST_BYTES2 = 128 * 1024;
1584
1907
  var MAX_PENDING_REQUESTS = 128;
1585
1908
  function startSoftwareHelper(options = {}) {
1586
1909
  const socketPath = options.socketPath ?? DEFAULT_SOFTWARE_HELPER_SOCKET;
1587
- if (existsSync4(socketPath))
1910
+ if (existsSync6(socketPath))
1588
1911
  unlinkSync3(socketPath);
1589
- mkdirSync5(dirname4(socketPath), { recursive: true, mode: 488 });
1912
+ mkdirSync6(dirname5(socketPath), { recursive: true, mode: 488 });
1590
1913
  const ensure = options.ensure ?? ensureSoftwareRequirements;
1591
1914
  const activate = options.activate ?? activateSupervisedService;
1915
+ const buildContainer = options.buildContainer ?? buildContainerImage;
1916
+ const activateTypedContainer = options.activateContainer ?? activateContainer;
1592
1917
  let tail = Promise.resolve();
1593
1918
  let pending = 0;
1594
1919
  const server = createServer2((socket) => {
@@ -1621,10 +1946,29 @@ function startSoftwareHelper(options = {}) {
1621
1946
  return;
1622
1947
  }
1623
1948
  if (request.op === "activate-service" && request.request) {
1624
- if (!options.allowedRoot || request.request.root !== options.allowedRoot) {
1949
+ const serviceRequest = request.request;
1950
+ if (!options.allowedRoot || serviceRequest.root !== options.allowedRoot) {
1625
1951
  throw new Error("service deployment root is not owned by this helper");
1626
1952
  }
1627
- const state = await activate(request.request);
1953
+ const state = await activate(serviceRequest);
1954
+ socket.end(`${JSON.stringify({ ok: true, state })}
1955
+ `);
1956
+ return;
1957
+ }
1958
+ if (request.op === "build-container" && request.request) {
1959
+ const containerRequest = request.request;
1960
+ if (!options.allowedRoot || containerRequest.root !== options.allowedRoot)
1961
+ throw new Error("container deployment root is not owned by this helper");
1962
+ const result = await buildContainer(containerRequest);
1963
+ socket.end(`${JSON.stringify({ ok: true, result })}
1964
+ `);
1965
+ return;
1966
+ }
1967
+ if (request.op === "activate-container" && request.request) {
1968
+ const containerRequest = request.request;
1969
+ if (!options.allowedRoot || containerRequest.root !== options.allowedRoot)
1970
+ throw new Error("container deployment root is not owned by this helper");
1971
+ const state = await activateTypedContainer(containerRequest);
1628
1972
  socket.end(`${JSON.stringify({ ok: true, state })}
1629
1973
  `);
1630
1974
  return;
@@ -1648,8 +1992,43 @@ function startSoftwareHelper(options = {}) {
1648
1992
  server.listen(socketPath, () => chmodSync4(socketPath, 432));
1649
1993
  return server;
1650
1994
  }
1995
+ function requestContainer(op, request, socketPath, timeoutMs) {
1996
+ return new Promise((resolve5, reject) => {
1997
+ const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op, request })}
1998
+ `));
1999
+ let buffer = "";
2000
+ socket.setTimeout(timeoutMs, () => {
2001
+ socket.destroy();
2002
+ reject(new Error("container helper timed out"));
2003
+ });
2004
+ socket.on("data", (chunk) => {
2005
+ buffer += chunk.toString("utf8");
2006
+ const newline = buffer.indexOf(`
2007
+ `);
2008
+ if (newline < 0)
2009
+ return;
2010
+ socket.end();
2011
+ try {
2012
+ const response = JSON.parse(buffer.slice(0, newline));
2013
+ const result = response.result ?? response.state;
2014
+ if (!response.ok || !result)
2015
+ throw new Error(response.error?.message ?? "container helper refused request");
2016
+ resolve5(result);
2017
+ } catch (cause) {
2018
+ reject(cause);
2019
+ }
2020
+ });
2021
+ socket.on("error", reject);
2022
+ });
2023
+ }
2024
+ function requestContainerBuild(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 30 * 60000) {
2025
+ return requestContainer("build-container", request, socketPath, timeoutMs);
2026
+ }
2027
+ function requestContainerActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
2028
+ return requestContainer("activate-container", request, socketPath, timeoutMs);
2029
+ }
1651
2030
  function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 10 * 60000) {
1652
- return new Promise((resolve4, reject) => {
2031
+ return new Promise((resolve5, reject) => {
1653
2032
  const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "activate-service", request })}
1654
2033
  `));
1655
2034
  let buffer = "";
@@ -1668,7 +2047,7 @@ function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_
1668
2047
  const response = JSON.parse(buffer.slice(0, newline));
1669
2048
  if (!response.ok || !response.state)
1670
2049
  throw new Error(response.error?.message ?? "service helper refused activation");
1671
- resolve4(response.state);
2050
+ resolve5(response.state);
1672
2051
  } catch (cause) {
1673
2052
  reject(cause);
1674
2053
  }
@@ -1678,7 +2057,7 @@ function requestServiceActivation(request, socketPath = DEFAULT_SOFTWARE_HELPER_
1678
2057
  }
1679
2058
  function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
1680
2059
  validateSoftwareRequirements(requirements);
1681
- return new Promise((resolve4, reject) => {
2060
+ return new Promise((resolve5, reject) => {
1682
2061
  const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
1683
2062
  `));
1684
2063
  let buffer = "";
@@ -1697,7 +2076,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
1697
2076
  const response = JSON.parse(buffer.slice(0, newline));
1698
2077
  if (!response.ok || !response.results)
1699
2078
  throw new Error(response.error?.message ?? "software helper refused the request");
1700
- resolve4(response.results);
2079
+ resolve5(response.results);
1701
2080
  } catch (cause) {
1702
2081
  reject(cause);
1703
2082
  }
@@ -1707,10 +2086,10 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
1707
2086
  }
1708
2087
 
1709
2088
  // src/version.ts
1710
- var VERSION3 = "0.1.52";
2089
+ var VERSION3 = "0.1.55";
1711
2090
 
1712
2091
  // src/egress-policy.ts
1713
- import { realpathSync as realpathSync2 } from "node:fs";
2092
+ import { realpathSync as realpathSync3 } from "node:fs";
1714
2093
  var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
1715
2094
  var BLOCKED_IPV4 = [
1716
2095
  "0.0.0.0/8",
@@ -1,6 +1,7 @@
1
1
  import { type Server } from 'node:net';
2
2
  import { ensureSoftwareRequirements, type SoftwareRequirement } from './software';
3
3
  import { activateSupervisedService, type ServiceActivationRequest, type ServiceActivationState } from './service-supervisor';
4
+ import { activateContainer, buildContainerImage, type ContainerActivateRequest, type ContainerBuildRequest, type ContainerBuildResult } from './container-supervisor';
4
5
  export declare const DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
5
6
  export declare const SOFTWARE_HELPER_GROUP = "forgezero-software";
6
7
  export declare const SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
@@ -8,8 +9,12 @@ export declare function startSoftwareHelper(options?: {
8
9
  socketPath?: string;
9
10
  ensure?: typeof ensureSoftwareRequirements;
10
11
  activate?: typeof activateSupervisedService;
12
+ buildContainer?: typeof buildContainerImage;
13
+ activateContainer?: typeof activateContainer;
11
14
  allowedRoot?: string;
12
15
  }): Server;
16
+ export declare function requestContainerBuild(request: ContainerBuildRequest, socketPath?: string, timeoutMs?: number): Promise<ContainerBuildResult>;
17
+ export declare function requestContainerActivation(request: ContainerActivateRequest, socketPath?: string, timeoutMs?: number): Promise<ServiceActivationState>;
13
18
  export declare function requestServiceActivation(request: ServiceActivationRequest, socketPath?: string, timeoutMs?: number): Promise<ServiceActivationState>;
14
19
  export declare function requestSoftware(requirements: readonly SoftwareRequirement[], socketPath?: string, timeoutMs?: number): Promise<Array<{
15
20
  id: string;