@forgezero/agent 0.1.22 → 0.1.24

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/fz-agent.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/index.ts
5
5
  import { randomBytes } from "crypto";
6
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync9, mkdirSync as mkdirSync5, chmodSync as chmodSync7 } from "fs";
6
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, existsSync as existsSync10, mkdirSync as mkdirSync6, chmodSync as chmodSync9 } from "fs";
7
7
  import { dirname as dirname4, join as join4 } from "path";
8
8
  import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
9
9
  import { DEFAULT_SOCKET } from "@forgezero/vault";
@@ -1158,6 +1158,26 @@ import {
1158
1158
  writeFileSync as writeFileSync2
1159
1159
  } from "fs";
1160
1160
  import { dirname as dirname2 } from "path";
1161
+ function privateNetworkAttachmentFromEnvironment(env = process.env) {
1162
+ const values = {
1163
+ accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
1164
+ tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
1165
+ virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
1166
+ warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
1167
+ };
1168
+ const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
1169
+ if (!supplied)
1170
+ return;
1171
+ if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
1172
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
1173
+ }
1174
+ return {
1175
+ accountId: values.accountId,
1176
+ tunnelId: values.tunnelId,
1177
+ ...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
1178
+ warpPolicyId: values.warpPolicyId
1179
+ };
1180
+ }
1161
1181
  var validBinding = (value, expectedNodeKey) => {
1162
1182
  if (!value || typeof value !== "object")
1163
1183
  return false;
@@ -1204,6 +1224,7 @@ async function enrolGuestIdentity(options) {
1204
1224
  token,
1205
1225
  label: options.label,
1206
1226
  gitDeployPublicKey: options.gitDeployPublicKey,
1227
+ privateNetworkAttachment: options.privateNetworkAttachment,
1207
1228
  publicKeys: {
1208
1229
  ed25519: options.keys.ed25519.publicKey,
1209
1230
  mlDsa: options.keys.mlDsa.publicKey
@@ -1373,6 +1394,132 @@ function startProvisioningPull(options) {
1373
1394
  };
1374
1395
  }
1375
1396
 
1397
+ // src/migration-pull.ts
1398
+ class MigrationClaimLostError extends Error {
1399
+ constructor(message) {
1400
+ super(message);
1401
+ this.name = "MigrationClaimLostError";
1402
+ }
1403
+ }
1404
+ var post2 = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
1405
+ async function complete2(options, body) {
1406
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
1407
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1408
+ let last;
1409
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
1410
+ try {
1411
+ await post2(options, "complete", body);
1412
+ return;
1413
+ } catch (cause) {
1414
+ last = cause;
1415
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
1416
+ throw cause;
1417
+ if (attempt < attempts)
1418
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
1419
+ }
1420
+ }
1421
+ throw last;
1422
+ }
1423
+ async function pullMigrationOnce(options) {
1424
+ const response = await post2(options, "claim", {});
1425
+ if (!response.claim)
1426
+ return { status: "idle" };
1427
+ const claim = response.claim;
1428
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1429
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1430
+ const now = options.now ?? Date.now;
1431
+ let expires = claim.claimExpiresAtTs;
1432
+ let stopped = false;
1433
+ let timer;
1434
+ let renewal = null;
1435
+ let lost = null;
1436
+ const schedule = (override) => {
1437
+ if (stopped)
1438
+ return;
1439
+ const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
1440
+ timer = setTimer(() => {
1441
+ if (stopped || renewal)
1442
+ return;
1443
+ let retry;
1444
+ renewal = post2(options, "renew", {
1445
+ migrationKey: claim.migrationKey,
1446
+ claimToken: claim.claimToken
1447
+ }).then((value) => {
1448
+ expires = value.claimExpiresAtTs;
1449
+ }).catch((cause) => {
1450
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
1451
+ lost = new MigrationClaimLostError(cause.message);
1452
+ stopped = true;
1453
+ } else
1454
+ retry = Math.max(1000, options.renewRetryMs ?? 5000);
1455
+ }).finally(() => {
1456
+ renewal = null;
1457
+ if (!stopped)
1458
+ schedule(retry);
1459
+ });
1460
+ }, delay);
1461
+ };
1462
+ schedule();
1463
+ let evidence;
1464
+ try {
1465
+ evidence = await options.run(claim);
1466
+ } catch (cause) {
1467
+ stopped = true;
1468
+ clearTimer(timer);
1469
+ await renewal;
1470
+ if (lost)
1471
+ throw lost;
1472
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
1473
+ await complete2(options, {
1474
+ migrationKey: claim.migrationKey,
1475
+ claimToken: claim.claimToken,
1476
+ ok: false,
1477
+ detail: reason
1478
+ });
1479
+ return { status: "failed", claim, reason };
1480
+ }
1481
+ stopped = true;
1482
+ clearTimer(timer);
1483
+ await renewal;
1484
+ if (lost)
1485
+ throw lost;
1486
+ await complete2(options, {
1487
+ migrationKey: claim.migrationKey,
1488
+ claimToken: claim.claimToken,
1489
+ ok: true,
1490
+ evidence
1491
+ });
1492
+ return { status: "completed", claim, evidence };
1493
+ }
1494
+ function startMigrationPull(options) {
1495
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
1496
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1497
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1498
+ let stopped = false;
1499
+ let timer;
1500
+ let active = null;
1501
+ const tick = () => {
1502
+ if (stopped || active)
1503
+ return;
1504
+ active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
1505
+ active = null;
1506
+ if (!stopped)
1507
+ timer = setTimer(tick, interval);
1508
+ });
1509
+ };
1510
+ tick();
1511
+ return {
1512
+ async stop() {
1513
+ stopped = true;
1514
+ clearTimer(timer);
1515
+ await active;
1516
+ },
1517
+ get active() {
1518
+ return !stopped;
1519
+ }
1520
+ };
1521
+ }
1522
+
1376
1523
  // src/metal-helper-socket.ts
1377
1524
  import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
1378
1525
  import { connect as connect2, createServer as createServer3 } from "net";
@@ -1489,55 +1636,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
1489
1636
  ];
1490
1637
  }
1491
1638
 
1492
- // src/provision.ts
1493
- var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
1494
- var DEPLOYMENT_GROUP = "forgezero-deploy";
1495
- var VAULT_GROUP = "forgezero-vault";
1496
- var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
1497
- function deploymentRunnerUnit(options) {
1498
- const bin = options.binPath ?? "fz-agent";
1499
- const root = options.deployRoot ?? "/opt/forgezero";
1500
- const agentUser = options.user ?? "forgezero-agent";
1501
- return `[Unit]
1502
- Description=ForgeZero credential-free project command runner
1503
- Documentation=https://www.forgezero.net/docs/agent
1504
-
1505
- [Service]
1506
- Type=notify
1507
- NotifyAccess=all
1508
- User=${DEPLOYMENT_RUNNER_USER}
1509
- Group=${DEPLOYMENT_GROUP}
1510
- Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1511
- RuntimeDirectory=forgezero-deploy
1512
- RuntimeDirectoryMode=0710
1513
- ExecStartPre=+/usr/bin/install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0710 /run/forgezero-deploy
1514
- ExecStartPre=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
1515
- ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
1516
- ExecStartPost=+/usr/bin/chown ${agentUser}:${agentUser} ${DEPLOYMENT_RUNNER_SOCKET}
1517
- ExecStartPost=+/usr/bin/chmod 0600 ${DEPLOYMENT_RUNNER_SOCKET}
1518
- ExecStartPost=+/usr/bin/chown root:${VAULT_GROUP} /run/forgezero-deploy
1519
- ExecStopPost=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
1520
- Restart=always
1521
- RestartSec=2
1522
- UMask=0007
1523
- LimitCORE=0
1524
- NoNewPrivileges=false
1525
- PrivateTmp=true
1526
- ProtectSystem=strict
1527
- ProtectHome=true
1528
- ProtectKernelTunables=true
1529
- ProtectKernelModules=true
1530
- ProtectControlGroups=true
1531
- RestrictRealtime=true
1532
- MemoryDenyWriteExecute=true
1533
- LockPersonality=true
1534
- ReadWritePaths=${root}/releases ${root}/runner-home
1535
-
1536
- [Install]
1537
- WantedBy=multi-user.target
1538
- `;
1539
- }
1540
-
1541
1639
  // src/ubuntu.ts
1542
1640
  var SUPPORTED_GUEST_IMAGE = Object.freeze({
1543
1641
  key: "ubuntu-resolute-20260731",
@@ -1718,7 +1816,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
1718
1816
  encoding: b64
1719
1817
  content: ${base64(content)}
1720
1818
  `;
1721
- function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
1819
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
1722
1820
  const agentBun = "/usr/local/lib/forgezero/bun";
1723
1821
  const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
1724
1822
  # healthy and encrypted while attestation is silently impossible. Install and
@@ -1732,26 +1830,7 @@ printf 'sev-guest
1732
1830
  ` : "";
1733
1831
  return `#!/usr/bin/env bash
1734
1832
  set -Eeuo pipefail
1735
- ${attestationSetup}groupadd --system ${VAULT_GROUP} 2>/dev/null || true
1736
- useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
1737
- usermod -g ${VAULT_GROUP} forgezero-agent
1738
- groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
1739
- useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
1740
- usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
1741
- install -d -o root -g root -m 0700 /etc/forgezero/creds
1742
- install -d -o root -g root -m 0755 /etc/forgezero/git
1743
- install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
1744
- install -d -o root -g root -m 0755 /opt/forgezero
1745
- install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
1746
- install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
1747
- install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
1748
- ${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
1749
- systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
1750
- rm -f /run/forgezero-enrol-token
1751
- fi
1752
- chown root:root /var/lib/forgezero/enrol-token.cred
1753
- chmod 0400 /var/lib/forgezero/enrol-token.cred
1754
- ` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
1833
+ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
1755
1834
  curl -fsSL https://bun.sh/install -o /run/fz-bun-install
1756
1835
  printf '%s %s
1757
1836
  ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
@@ -1759,132 +1838,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
1759
1838
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
1760
1839
  rm -f /run/fz-bun-install
1761
1840
  fi
1762
- if [[ ! -x /usr/local/bin/fz-agent ]]; then
1841
+ if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
1763
1842
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
1764
1843
  ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
1765
1844
  fi
1766
- if [[ ! -s /etc/forgezero/creds/agent-seed.cred ]]; then
1767
- umask 077
1768
- openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
1769
- ' >/run/fz-agent-seed
1770
- systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
1771
- rm -f /run/fz-agent-seed
1772
- fi
1773
- chmod 0400 /etc/forgezero/creds/agent-seed.cred
1774
- if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
1775
- umask 077
1776
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1777
- ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
1778
- systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
1779
- install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1780
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1781
- fi
1782
- if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
1783
- systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
1784
- ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
1785
- install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1786
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1787
- fi
1788
- chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
1789
- systemctl daemon-reload
1790
- systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true
1791
- rm -f /etc/systemd/system/forgezero-deploy-runner.socket
1792
- systemctl daemon-reload
1793
- systemctl enable --now forgezero-deploy-runner.service forgezero-agent.service
1794
- `;
1795
- }
1796
- function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
1797
- const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
1798
- ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1799
- ` : "";
1800
- const attestationDevice = attested ? `DevicePolicy=closed
1801
- DeviceAllow=/dev/sev-guest rw
1802
- ` : "";
1803
- return `[Unit]
1804
- Description=ForgeZero compute agent (${name})
1805
- After=network-online.target forgezero-deploy-runner.service
1806
- Wants=network-online.target
1807
- Requires=forgezero-deploy-runner.service
1808
-
1809
- [Service]
1810
- Type=simple
1811
- User=forgezero-agent
1812
- Group=${VAULT_GROUP}
1813
- SupplementaryGroups=${DEPLOYMENT_GROUP}
1814
- LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
1815
- LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
1816
- Environment=FZ_SEED_CREDENTIAL=agent-seed
1817
- Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
1818
- Environment=FZ_API=${profile.apiUrl}
1819
- Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
1820
- Environment=FZ_NODE_LABEL=${name}
1821
- Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
1822
- Environment=FZ_DEPLOY_ROOT=/opt/forgezero
1823
- Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
1824
- Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1825
- Environment=HOME=/opt/forgezero/home
1826
- ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
1827
- Restart=on-failure
1828
- RestartSec=5
1829
- RuntimeDirectory=forgezero
1830
- RuntimeDirectoryMode=0750
1831
- UMask=0007
1832
- LimitCORE=0
1833
- NoNewPrivileges=true
1834
- PrivateTmp=true
1835
- ProtectSystem=strict
1836
- ProtectHome=true
1837
- ReadWritePaths=/var/lib/forgezero /opt/forgezero
1838
- ${attestationDevice}
1839
-
1840
- [Install]
1841
- WantedBy=multi-user.target
1842
- `;
1843
- }
1844
- function guestEnrolmentDropIn() {
1845
- return `[Service]
1846
- LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
1847
- Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
1848
- `;
1849
- }
1850
- function guestEnrolmentCleanupScript() {
1851
- return `#!/usr/bin/env bash
1852
- set -Eeuo pipefail
1853
- for _ in $(seq 1 180); do
1854
- [[ -s /var/lib/forgezero/enrolment.json ]] && break
1855
- sleep 1
1856
- done
1857
- [[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
1858
- rm -f /var/lib/forgezero/enrol-token.cred
1859
- rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
1860
- systemctl daemon-reload
1861
- systemctl disable forgezero-enrolment-cleanup.service
1862
- `;
1863
- }
1864
- function guestEnrolmentCleanupUnit() {
1865
- return `[Unit]
1866
- Description=Remove the consumed ForgeZero guest enrolment credential
1867
- After=forgezero-agent.service
1868
- Requires=forgezero-agent.service
1869
- ConditionPathExists=/var/lib/forgezero/enrol-token.cred
1870
-
1871
- [Service]
1872
- Type=oneshot
1873
- ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
1874
- TimeoutStartSec=4min
1875
-
1876
- [Install]
1877
- WantedBy=multi-user.target
1845
+ env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/bin/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
1878
1846
  `;
1879
1847
  }
1880
1848
  function cloudInit(profile, claim, manifest) {
1881
1849
  const enrolled = Boolean(claim.enrolment);
1882
- const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
1883
- const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
1884
- const enrolmentDropIn = guestEnrolmentDropIn();
1885
- const cleanupScript = guestEnrolmentCleanupScript();
1886
- const cleanupUnit = guestEnrolmentCleanupUnit();
1887
- const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero", user: "forgezero-agent" });
1850
+ const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled, manifest.name);
1888
1851
  const access = claim.access;
1889
1852
  const users = access ? `users:
1890
1853
  - default
@@ -1896,8 +1859,8 @@ function cloudInit(profile, claim, manifest) {
1896
1859
  ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1897
1860
  `)}
1898
1861
  ` : "";
1899
- const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1900
- `, "0600")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}` : "";
1862
+ const enrolmentFiles = enrolled ? yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1863
+ `, "0600") : "";
1901
1864
  return {
1902
1865
  userData: `#cloud-config
1903
1866
  package_update: true
@@ -1906,10 +1869,8 @@ ${users}disable_root: true
1906
1869
  # every dynamically claimed repository must be cloneable on a clean image.
1907
1870
  packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
1908
1871
  write_files:
1909
- ${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit, "0644")}runcmd:
1872
+ ${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}runcmd:
1910
1873
  - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
1911
- ${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1912
- ` : ""}
1913
1874
  `,
1914
1875
  metaData: `instance-id: ${manifest.name}-${claim.attempt}
1915
1876
  local-hostname: ${manifest.name}
@@ -2732,22 +2693,268 @@ async function closeServerWithin(server, timeoutMs) {
2732
2693
  return closed;
2733
2694
  }
2734
2695
 
2696
+ // src/lifecycle-helper.ts
2697
+ import { chmodSync as chmodSync7, existsSync as existsSync9, readFileSync as readFileSync4, unlinkSync as unlinkSync7 } from "fs";
2698
+ import { connect as connect4, createConnection, createServer as createServer5, isIP } from "net";
2699
+ var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
2700
+ var MAX_REQUEST_BYTES4 = 16 * 1024;
2701
+ var REQUEST_TIMEOUT_MS = 5000;
2702
+ var ACTION_TIMEOUT_MS = 10 * 60000;
2703
+ var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
2704
+ var privateIp = (value) => {
2705
+ const address = value.replace(/^\[|\]$/g, "").toLowerCase();
2706
+ if (isIP(address) === 4) {
2707
+ const [a, b] = address.split(".").map(Number);
2708
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
2709
+ }
2710
+ if (isIP(address) === 6) {
2711
+ const first = Number.parseInt(address.split(":", 1)[0], 16);
2712
+ return Number.isFinite(first) && (first & 65024) === 64512;
2713
+ }
2714
+ return false;
2715
+ };
2716
+ function validateLifecycleProfile(profile) {
2717
+ if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
2718
+ throw new Error("lifecycle profile needs one or more valid API service units");
2719
+ }
2720
+ if (!unitPattern.test(profile.databaseUnit))
2721
+ throw new Error("lifecycle profile database unit is invalid");
2722
+ const apiUrl = new URL(profile.apiHealthUrl);
2723
+ if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
2724
+ throw new Error("API health URL must be loopback HTTP");
2725
+ }
2726
+ const databaseUrl = new URL(profile.databaseHealthUrl);
2727
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2728
+ throw new Error("database health URL must be loopback or private HTTP");
2729
+ }
2730
+ if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
2731
+ throw new Error("lifecycle profile database ports are invalid");
2732
+ }
2733
+ }
2734
+ function loadLifecycleProfile(path) {
2735
+ const profile = JSON.parse(readFileSync4(path, "utf8"));
2736
+ validateLifecycleProfile(profile);
2737
+ return profile;
2738
+ }
2739
+ var spawnLifecycleCommand = async (argv) => {
2740
+ const child = Bun.spawn([...argv], {
2741
+ stdout: "pipe",
2742
+ stderr: "pipe",
2743
+ env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
2744
+ });
2745
+ let timedOut = false;
2746
+ const timer = setTimeout(() => {
2747
+ timedOut = true;
2748
+ child.kill("SIGTERM");
2749
+ }, ACTION_TIMEOUT_MS);
2750
+ const [stdout, stderr, exitCode] = await Promise.all([
2751
+ new Response(child.stdout).text(),
2752
+ new Response(child.stderr).text(),
2753
+ child.exited
2754
+ ]);
2755
+ clearTimeout(timer);
2756
+ return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
2757
+ };
2758
+ var requireSuccess = async (exec, argv, label) => {
2759
+ const result = await exec(argv);
2760
+ if (result.exitCode !== 0)
2761
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
2762
+ return result.stdout;
2763
+ };
2764
+ var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve2, reject) => {
2765
+ const socket = createConnection({ host, port });
2766
+ socket.setTimeout(timeoutMs);
2767
+ socket.once("connect", () => {
2768
+ socket.destroy();
2769
+ resolve2();
2770
+ });
2771
+ socket.once("timeout", () => {
2772
+ socket.destroy();
2773
+ reject(new Error(`private peer ${host}:${port} timed out`));
2774
+ });
2775
+ socket.once("error", reject);
2776
+ });
2777
+ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
2778
+ validateLifecycleProfile(profile);
2779
+ switch (claim.action) {
2780
+ case "network-ready": {
2781
+ if (!claim.peerPrivateAddresses?.length)
2782
+ throw new Error("network claim has no private peers");
2783
+ if (claim.network === "cloudflare-warp") {
2784
+ const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
2785
+ if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
2786
+ throw new Error("WARP is not connected");
2787
+ }
2788
+ for (const address of claim.peerPrivateAddresses) {
2789
+ for (const port of profile.databasePorts)
2790
+ await tcpProbe(address, port);
2791
+ }
2792
+ return {
2793
+ targetAgentReady: true,
2794
+ privateNetworkReady: true,
2795
+ ...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
2796
+ };
2797
+ }
2798
+ case "database-member-ready": {
2799
+ await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
2800
+ const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
2801
+ if (!response.ok && response.status !== 401)
2802
+ throw new Error(`database health returned HTTP ${response.status}`);
2803
+ return { databaseMemberHealthy: true };
2804
+ }
2805
+ case "api-ready": {
2806
+ const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
2807
+ if (!response.ok)
2808
+ throw new Error(`API health returned HTTP ${response.status}`);
2809
+ return { apiHealthy: true };
2810
+ }
2811
+ case "source-drained":
2812
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
2813
+ return { sourceDrained: true };
2814
+ case "source-stopped":
2815
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
2816
+ return { sourceStopped: true };
2817
+ default:
2818
+ throw new Error("unknown lifecycle action");
2819
+ }
2820
+ }
2821
+ function startLifecycleHelper(options) {
2822
+ validateLifecycleProfile(options.profile);
2823
+ const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
2824
+ if (existsSync9(socketPath))
2825
+ unlinkSync7(socketPath);
2826
+ let tail = Promise.resolve();
2827
+ const server = createServer5((socket) => {
2828
+ let buffer = "";
2829
+ socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
2830
+ `));
2831
+ socket.on("data", (chunk) => {
2832
+ buffer += chunk.toString("utf8");
2833
+ if (buffer.length > MAX_REQUEST_BYTES4)
2834
+ return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
2835
+ `);
2836
+ const newline = buffer.indexOf(`
2837
+ `);
2838
+ if (newline < 0)
2839
+ return;
2840
+ socket.setTimeout(0);
2841
+ const line = buffer.slice(0, newline);
2842
+ buffer = "";
2843
+ const work = async () => {
2844
+ let request;
2845
+ try {
2846
+ request = JSON.parse(line);
2847
+ } catch {
2848
+ return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
2849
+ }
2850
+ if (request?.op !== "apply" || !request.claim)
2851
+ return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
2852
+ try {
2853
+ return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
2854
+ } catch (cause) {
2855
+ return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
2856
+ }
2857
+ };
2858
+ const response = tail.then(work, work);
2859
+ tail = response;
2860
+ response.then((value) => socket.end(`${JSON.stringify(value)}
2861
+ `));
2862
+ });
2863
+ socket.on("error", () => socket.destroy());
2864
+ });
2865
+ server.listen(socketPath, () => chmodSync7(socketPath, 432));
2866
+ return {
2867
+ server,
2868
+ async stop() {
2869
+ await new Promise((resolve2) => server.close(() => resolve2()));
2870
+ await tail;
2871
+ }
2872
+ };
2873
+ }
2874
+ function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
2875
+ return new Promise((resolve2, reject) => {
2876
+ const socket = connect4(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
2877
+ `));
2878
+ socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
2879
+ socket.destroy();
2880
+ reject(new Error("lifecycle helper response timed out"));
2881
+ });
2882
+ let buffer = "";
2883
+ socket.on("data", (chunk) => {
2884
+ buffer += chunk.toString("utf8");
2885
+ const newline = buffer.indexOf(`
2886
+ `);
2887
+ if (newline < 0)
2888
+ return;
2889
+ socket.end();
2890
+ try {
2891
+ const response = JSON.parse(buffer.slice(0, newline));
2892
+ if (response.ok)
2893
+ resolve2(response.evidence);
2894
+ else
2895
+ reject(new Error(response.error.message));
2896
+ } catch (cause) {
2897
+ reject(cause);
2898
+ }
2899
+ });
2900
+ socket.on("error", reject);
2901
+ });
2902
+ }
2903
+
2904
+ // src/warp-config.ts
2905
+ import { chmodSync as chmodSync8, mkdirSync as mkdirSync5, renameSync as renameSync3, symlinkSync, unlinkSync as unlinkSync8, writeFileSync as writeFileSync5 } from "fs";
2906
+ var xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
2907
+ function renderWarpMdm(options) {
2908
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.organization))
2909
+ throw new Error("WARP organization is invalid");
2910
+ if (!options.clientId.trim() || !options.clientSecret.trim())
2911
+ throw new Error("WARP service-token credentials are empty");
2912
+ return `<?xml version="1.0" encoding="UTF-8"?>
2913
+ <dict>
2914
+ <key>auth_client_id</key><string>${xml(options.clientId.trim())}</string>
2915
+ <key>auth_client_secret</key><string>${xml(options.clientSecret.trim())}</string>
2916
+ <key>auto_connect</key><integer>1</integer>
2917
+ <key>onboarding</key><false/>
2918
+ <key>organization</key><string>${xml(options.organization)}</string>
2919
+ <key>service_mode</key><string>warp</string>
2920
+ </dict>
2921
+ `;
2922
+ }
2923
+ function materializeWarpMdm(options) {
2924
+ const runtimePath = options.runtimePath ?? "/run/forgezero-warp/mdm.xml";
2925
+ const servicePath = options.servicePath ?? "/var/lib/cloudflare-warp/mdm.xml";
2926
+ mkdirSync5(runtimePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
2927
+ mkdirSync5(servicePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
2928
+ const next = `${runtimePath}.next`;
2929
+ writeFileSync5(next, renderWarpMdm(options), { mode: 384 });
2930
+ chmodSync8(next, 384);
2931
+ renameSync3(next, runtimePath);
2932
+ try {
2933
+ unlinkSync8(servicePath);
2934
+ } catch (cause) {
2935
+ if (cause.code !== "ENOENT")
2936
+ throw cause;
2937
+ }
2938
+ symlinkSync(runtimePath, servicePath);
2939
+ return { runtimePath, servicePath };
2940
+ }
2941
+
2735
2942
  // src/version.ts
2736
- var VERSION = "0.1.22";
2943
+ var VERSION = "0.1.24";
2737
2944
 
2738
2945
  // src/index.ts
2739
2946
  function loadOrCreateSeed(path) {
2740
- if (existsSync9(path)) {
2741
- const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2947
+ if (existsSync10(path)) {
2948
+ const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2742
2949
  if (seed2.length < 32) {
2743
2950
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
2744
2951
  }
2745
2952
  return seed2;
2746
2953
  }
2747
- mkdirSync5(dirname4(path), { recursive: true });
2954
+ mkdirSync6(dirname4(path), { recursive: true });
2748
2955
  const seed = new Uint8Array(randomBytes(32));
2749
- writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2750
- chmodSync7(path, 384);
2956
+ writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2957
+ chmodSync9(path, 384);
2751
2958
  return seed;
2752
2959
  }
2753
2960
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
@@ -2758,9 +2965,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
2758
2965
  if (!directory)
2759
2966
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
2760
2967
  const path = `${directory}/${name}`;
2761
- if (!existsSync9(path))
2968
+ if (!existsSync10(path))
2762
2969
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2763
- const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2970
+ const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2764
2971
  if (seed.length < 32)
2765
2972
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2766
2973
  return seed;
@@ -2770,7 +2977,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
2770
2977
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2771
2978
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
2772
2979
  throw new Error("agent: invalid systemd credential name.");
2773
- const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
2980
+ const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2774
2981
  if (!value)
2775
2982
  throw new Error(`agent: systemd credential ${name} is empty.`);
2776
2983
  return value;
@@ -2876,7 +3083,8 @@ if (import.meta.main) {
2876
3083
  nodeKey: nodeKey2,
2877
3084
  keys: keys2,
2878
3085
  label: process.env.FZ_NODE_LABEL,
2879
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
3086
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3087
+ privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
2880
3088
  });
2881
3089
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2882
3090
  process.exit(0);
@@ -2885,7 +3093,7 @@ if (import.meta.main) {
2885
3093
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2886
3094
  if (!profilePath)
2887
3095
  throw new Error("metal-helper requires --profile=/absolute/path.json");
2888
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3096
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2889
3097
  const helper = startMetalHelper({
2890
3098
  profile,
2891
3099
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -2903,11 +3111,53 @@ if (import.meta.main) {
2903
3111
  process.on("SIGINT", () => void stop());
2904
3112
  await new Promise(() => {});
2905
3113
  }
3114
+ if (command === "lifecycle-helper") {
3115
+ const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
3116
+ if (!profilePath?.startsWith("/"))
3117
+ throw new Error("lifecycle-helper requires --profile=/absolute/path.json");
3118
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
3119
+ throw new Error("lifecycle-helper must run as root");
3120
+ }
3121
+ const helper = startLifecycleHelper({
3122
+ profile: loadLifecycleProfile(profilePath),
3123
+ socketPath: process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET
3124
+ });
3125
+ console.log(`[lifecycle-helper] listening on ${process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET}`);
3126
+ let stopping = false;
3127
+ const stop = async () => {
3128
+ if (stopping)
3129
+ return;
3130
+ stopping = true;
3131
+ await helper.stop();
3132
+ process.exit(0);
3133
+ };
3134
+ process.on("SIGTERM", () => void stop());
3135
+ process.on("SIGINT", () => void stop());
3136
+ await new Promise(() => {});
3137
+ }
3138
+ if (command === "warp-config") {
3139
+ const organization = args.find((arg) => arg.startsWith("--organization="))?.slice("--organization=".length);
3140
+ if (!organization)
3141
+ throw new Error("warp-config requires --organization=<team-name>");
3142
+ if (typeof process.getuid !== "function" || process.getuid() !== 0)
3143
+ throw new Error("warp-config must run as root");
3144
+ const clientIdCredential = process.env.FZ_WARP_CLIENT_ID_CREDENTIAL;
3145
+ const clientSecretCredential = process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL;
3146
+ if (!clientIdCredential || !clientSecretCredential)
3147
+ throw new Error("warp-config requires systemd service-token credentials");
3148
+ materializeWarpMdm({
3149
+ organization,
3150
+ clientId: loadTextCredential(clientIdCredential),
3151
+ clientSecret: loadTextCredential(clientSecretCredential)
3152
+ });
3153
+ console.log("[warp-config] enrollment materialized in tmpfs");
3154
+ process.exit(0);
3155
+ }
2906
3156
  if (command === "metal-isolation") {
2907
3157
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2908
3158
  if (!profilePath)
2909
3159
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
2910
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3160
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2911
3161
  await applyMetalIsolation(profile);
2912
3162
  console.log("[metal-isolation] host and guest cgroup boundaries active");
2913
3163
  process.exit(0);
@@ -2952,7 +3202,7 @@ if (import.meta.main) {
2952
3202
  if (claimArg !== "-" && !claimArg.startsWith("/")) {
2953
3203
  throw new Error("metal-apply claim path must be absolute");
2954
3204
  }
2955
- const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
3205
+ const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
2956
3206
  if (Buffer.byteLength(raw) > 32 * 1024)
2957
3207
  throw new Error("metal-apply claim exceeds 32 KiB");
2958
3208
  const claim = JSON.parse(raw);
@@ -2978,9 +3228,9 @@ if (import.meta.main) {
2978
3228
  metalHostname: process.env.FZ_METAL_HOSTNAME,
2979
3229
  run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
2980
3230
  metalPreflight: () => ({
2981
- snpHost: existsSync9("/dev/sev"),
2982
- kvm: existsSync9("/dev/kvm"),
2983
- helper: existsSync9(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
3231
+ snpHost: existsSync10("/dev/sev"),
3232
+ kvm: existsSync10("/dev/kvm"),
3233
+ helper: existsSync10(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
2984
3234
  }),
2985
3235
  onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2986
3236
  });
@@ -3027,7 +3277,7 @@ if (import.meta.main) {
3027
3277
  process.exit(1);
3028
3278
  }
3029
3279
  }
3030
- const attestationSource = existsSync9("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3280
+ const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3031
3281
  const running = runAgent({
3032
3282
  socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
3033
3283
  seedCredential: process.env.FZ_SEED_CREDENTIAL,
@@ -3041,8 +3291,8 @@ if (import.meta.main) {
3041
3291
  const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
3042
3292
  let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
3043
3293
  const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
3044
- const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync9(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
3045
- const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync9(process.env.FZ_ENROL_TOKEN_FILE));
3294
+ const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync10(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
3295
+ const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync10(process.env.FZ_ENROL_TOKEN_FILE));
3046
3296
  if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
3047
3297
  binding = await enrolGuestIdentity({
3048
3298
  apiUrl: process.env.FZ_API,
@@ -3052,7 +3302,8 @@ if (import.meta.main) {
3052
3302
  nodeKey,
3053
3303
  keys,
3054
3304
  label: process.env.FZ_NODE_LABEL,
3055
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
3305
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3306
+ privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
3056
3307
  });
3057
3308
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
3058
3309
  }
@@ -3090,6 +3341,19 @@ if (import.meta.main) {
3090
3341
  }) : undefined;
3091
3342
  if (attestationLoop)
3092
3343
  console.log("[agent] periodic SEV-SNP attestation enabled");
3344
+ const migrationEnabled = process.env.FZ_MIGRATION_PULL === "true";
3345
+ if (migrationEnabled && (!binding || !nodeApiUrl)) {
3346
+ throw new Error("agent: outbound lifecycle work requires a persisted compute enrolment binding");
3347
+ }
3348
+ const migrationPull = migrationEnabled ? startMigrationPull({
3349
+ apiUrl: nodeApiUrl,
3350
+ nodeKey,
3351
+ keys,
3352
+ run: (claim) => requestLifecycleAction(claim, process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET),
3353
+ onEvent: (event, detail) => console.log(`[agent] migration ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
3354
+ }) : undefined;
3355
+ if (migrationPull)
3356
+ console.log("[agent] PQ-authenticated outbound lifecycle claims enabled");
3093
3357
  const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
3094
3358
  const deploymentSecrets = secretCache ? {
3095
3359
  async get(name) {
@@ -3175,8 +3439,9 @@ if (import.meta.main) {
3175
3439
  const pullDrain = pull?.stop() ?? Promise.resolve();
3176
3440
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3177
3441
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3442
+ const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
3178
3443
  const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
3179
- const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
3444
+ const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
3180
3445
  const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
3181
3446
  const socketClosed = await closeServerWithin(server, remaining());
3182
3447
  const timedOut = managerReports.some((report) => report.timedOut);
@@ -3193,7 +3458,7 @@ if (import.meta.main) {
3193
3458
  process.on("SIGINT", () => void shutdown("SIGINT"));
3194
3459
  } else {
3195
3460
  console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
3196
- if (vaultSync || attestationLoop) {
3461
+ if (vaultSync || attestationLoop || migrationPull) {
3197
3462
  let stopping = false;
3198
3463
  const stop = async (signal) => {
3199
3464
  if (stopping)
@@ -3205,7 +3470,8 @@ if (import.meta.main) {
3205
3470
  console.log(`[agent] ${signal}: stopping background intake and draining`);
3206
3471
  const backgroundDrained = await settleWithin(Promise.all([
3207
3472
  vaultSync?.stop() ?? Promise.resolve(),
3208
- attestationLoop?.stop() ?? Promise.resolve()
3473
+ attestationLoop?.stop() ?? Promise.resolve(),
3474
+ migrationPull?.stop() ?? Promise.resolve()
3209
3475
  ]), remaining());
3210
3476
  const socketClosed = await closeServerWithin(server, remaining());
3211
3477
  console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
@@ -3218,34 +3484,43 @@ if (import.meta.main) {
3218
3484
  }
3219
3485
  export {
3220
3486
  validateMetalProfile,
3487
+ validateLifecycleProfile,
3221
3488
  tenantNodeApiUrl,
3222
3489
  startProvisioningPull,
3223
3490
  startNodeVaultSync,
3224
3491
  startNodeAttestation,
3492
+ startMigrationPull,
3225
3493
  startMetalHelper,
3494
+ startLifecycleHelper,
3226
3495
  startDeploymentRunner,
3227
3496
  startDeploymentPull,
3228
3497
  startControlServer,
3229
3498
  startAgent,
3230
3499
  runAgent,
3231
3500
  requestMetalProvision,
3501
+ requestLifecycleAction,
3232
3502
  requestDeploymentCommand,
3233
3503
  requestControl,
3504
+ renderWarpMdm,
3234
3505
  removeMetalGuest,
3235
3506
  pullProvisioningOnce,
3507
+ pullMigrationOnce,
3236
3508
  pullDeploymentOnce,
3237
3509
  provisionMetalGuest,
3238
3510
  projectVaultCoordinate,
3239
3511
  projectVaultCacheKey,
3240
3512
  metalHousekeepingDropIn,
3241
3513
  metalGuestSliceUnit,
3514
+ materializeWarpMdm,
3242
3515
  loadTextCredential,
3243
3516
  loadSeedCredential,
3244
3517
  loadOrCreateSeed,
3518
+ loadLifecycleProfile,
3245
3519
  loadGuestBinding,
3246
3520
  handleRequest,
3247
3521
  handleApplicationRequest,
3248
3522
  guestNameFor,
3523
+ executeLifecycleAction,
3249
3524
  enrolGuestIdentity,
3250
3525
  createSystemdDeploymentSecrets,
3251
3526
  createSnpAttestationSource,
@@ -3265,6 +3540,7 @@ export {
3265
3540
  DEFAULT_SEED_PATH,
3266
3541
  DEFAULT_SEED_CREDENTIAL,
3267
3542
  DEFAULT_METAL_HELPER_SOCKET,
3543
+ DEFAULT_LIFECYCLE_HELPER_SOCKET,
3268
3544
  DEFAULT_ENROLMENT_STATE_PATH,
3269
3545
  DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
3270
3546
  DEFAULT_CONTROL_SOCKET,