@forgezero/agent 0.1.22 → 0.1.23

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";
@@ -1373,6 +1373,132 @@ function startProvisioningPull(options) {
1373
1373
  };
1374
1374
  }
1375
1375
 
1376
+ // src/migration-pull.ts
1377
+ class MigrationClaimLostError extends Error {
1378
+ constructor(message) {
1379
+ super(message);
1380
+ this.name = "MigrationClaimLostError";
1381
+ }
1382
+ }
1383
+ var post2 = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
1384
+ async function complete2(options, body) {
1385
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
1386
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1387
+ let last;
1388
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
1389
+ try {
1390
+ await post2(options, "complete", body);
1391
+ return;
1392
+ } catch (cause) {
1393
+ last = cause;
1394
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
1395
+ throw cause;
1396
+ if (attempt < attempts)
1397
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
1398
+ }
1399
+ }
1400
+ throw last;
1401
+ }
1402
+ async function pullMigrationOnce(options) {
1403
+ const response = await post2(options, "claim", {});
1404
+ if (!response.claim)
1405
+ return { status: "idle" };
1406
+ const claim = response.claim;
1407
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1408
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1409
+ const now = options.now ?? Date.now;
1410
+ let expires = claim.claimExpiresAtTs;
1411
+ let stopped = false;
1412
+ let timer;
1413
+ let renewal = null;
1414
+ let lost = null;
1415
+ const schedule = (override) => {
1416
+ if (stopped)
1417
+ return;
1418
+ const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
1419
+ timer = setTimer(() => {
1420
+ if (stopped || renewal)
1421
+ return;
1422
+ let retry;
1423
+ renewal = post2(options, "renew", {
1424
+ migrationKey: claim.migrationKey,
1425
+ claimToken: claim.claimToken
1426
+ }).then((value) => {
1427
+ expires = value.claimExpiresAtTs;
1428
+ }).catch((cause) => {
1429
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
1430
+ lost = new MigrationClaimLostError(cause.message);
1431
+ stopped = true;
1432
+ } else
1433
+ retry = Math.max(1000, options.renewRetryMs ?? 5000);
1434
+ }).finally(() => {
1435
+ renewal = null;
1436
+ if (!stopped)
1437
+ schedule(retry);
1438
+ });
1439
+ }, delay);
1440
+ };
1441
+ schedule();
1442
+ let evidence;
1443
+ try {
1444
+ evidence = await options.run(claim);
1445
+ } catch (cause) {
1446
+ stopped = true;
1447
+ clearTimer(timer);
1448
+ await renewal;
1449
+ if (lost)
1450
+ throw lost;
1451
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
1452
+ await complete2(options, {
1453
+ migrationKey: claim.migrationKey,
1454
+ claimToken: claim.claimToken,
1455
+ ok: false,
1456
+ detail: reason
1457
+ });
1458
+ return { status: "failed", claim, reason };
1459
+ }
1460
+ stopped = true;
1461
+ clearTimer(timer);
1462
+ await renewal;
1463
+ if (lost)
1464
+ throw lost;
1465
+ await complete2(options, {
1466
+ migrationKey: claim.migrationKey,
1467
+ claimToken: claim.claimToken,
1468
+ ok: true,
1469
+ evidence
1470
+ });
1471
+ return { status: "completed", claim, evidence };
1472
+ }
1473
+ function startMigrationPull(options) {
1474
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
1475
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1476
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1477
+ let stopped = false;
1478
+ let timer;
1479
+ let active = null;
1480
+ const tick = () => {
1481
+ if (stopped || active)
1482
+ return;
1483
+ active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
1484
+ active = null;
1485
+ if (!stopped)
1486
+ timer = setTimer(tick, interval);
1487
+ });
1488
+ };
1489
+ tick();
1490
+ return {
1491
+ async stop() {
1492
+ stopped = true;
1493
+ clearTimer(timer);
1494
+ await active;
1495
+ },
1496
+ get active() {
1497
+ return !stopped;
1498
+ }
1499
+ };
1500
+ }
1501
+
1376
1502
  // src/metal-helper-socket.ts
1377
1503
  import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
1378
1504
  import { connect as connect2, createServer as createServer3 } from "net";
@@ -1489,55 +1615,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
1489
1615
  ];
1490
1616
  }
1491
1617
 
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
1618
  // src/ubuntu.ts
1542
1619
  var SUPPORTED_GUEST_IMAGE = Object.freeze({
1543
1620
  key: "ubuntu-resolute-20260731",
@@ -1718,7 +1795,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
1718
1795
  encoding: b64
1719
1796
  content: ${base64(content)}
1720
1797
  `;
1721
- function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
1798
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
1722
1799
  const agentBun = "/usr/local/lib/forgezero/bun";
1723
1800
  const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
1724
1801
  # healthy and encrypted while attestation is silently impossible. Install and
@@ -1732,26 +1809,7 @@ printf 'sev-guest
1732
1809
  ` : "";
1733
1810
  return `#!/usr/bin/env bash
1734
1811
  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
1812
+ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
1755
1813
  curl -fsSL https://bun.sh/install -o /run/fz-bun-install
1756
1814
  printf '%s %s
1757
1815
  ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
@@ -1759,132 +1817,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
1759
1817
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
1760
1818
  rm -f /run/fz-bun-install
1761
1819
  fi
1762
- if [[ ! -x /usr/local/bin/fz-agent ]]; then
1820
+ if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
1763
1821
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
1764
1822
  ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
1765
1823
  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
1824
+ 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
1825
  `;
1879
1826
  }
1880
1827
  function cloudInit(profile, claim, manifest) {
1881
1828
  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" });
1829
+ const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled, manifest.name);
1888
1830
  const access = claim.access;
1889
1831
  const users = access ? `users:
1890
1832
  - default
@@ -1896,8 +1838,8 @@ function cloudInit(profile, claim, manifest) {
1896
1838
  ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1897
1839
  `)}
1898
1840
  ` : "";
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")}` : "";
1841
+ const enrolmentFiles = enrolled ? yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1842
+ `, "0600") : "";
1901
1843
  return {
1902
1844
  userData: `#cloud-config
1903
1845
  package_update: true
@@ -1906,10 +1848,8 @@ ${users}disable_root: true
1906
1848
  # every dynamically claimed repository must be cloneable on a clean image.
1907
1849
  packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
1908
1850
  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:
1851
+ ${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}runcmd:
1910
1852
  - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
1911
- ${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1912
- ` : ""}
1913
1853
  `,
1914
1854
  metaData: `instance-id: ${manifest.name}-${claim.attempt}
1915
1855
  local-hostname: ${manifest.name}
@@ -2732,22 +2672,268 @@ async function closeServerWithin(server, timeoutMs) {
2732
2672
  return closed;
2733
2673
  }
2734
2674
 
2675
+ // src/lifecycle-helper.ts
2676
+ import { chmodSync as chmodSync7, existsSync as existsSync9, readFileSync as readFileSync4, unlinkSync as unlinkSync7 } from "fs";
2677
+ import { connect as connect4, createConnection, createServer as createServer5, isIP } from "net";
2678
+ var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
2679
+ var MAX_REQUEST_BYTES4 = 16 * 1024;
2680
+ var REQUEST_TIMEOUT_MS = 5000;
2681
+ var ACTION_TIMEOUT_MS = 10 * 60000;
2682
+ var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
2683
+ var privateIp = (value) => {
2684
+ const address = value.replace(/^\[|\]$/g, "").toLowerCase();
2685
+ if (isIP(address) === 4) {
2686
+ const [a, b] = address.split(".").map(Number);
2687
+ return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
2688
+ }
2689
+ if (isIP(address) === 6) {
2690
+ const first = Number.parseInt(address.split(":", 1)[0], 16);
2691
+ return Number.isFinite(first) && (first & 65024) === 64512;
2692
+ }
2693
+ return false;
2694
+ };
2695
+ function validateLifecycleProfile(profile) {
2696
+ if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
2697
+ throw new Error("lifecycle profile needs one or more valid API service units");
2698
+ }
2699
+ if (!unitPattern.test(profile.databaseUnit))
2700
+ throw new Error("lifecycle profile database unit is invalid");
2701
+ const apiUrl = new URL(profile.apiHealthUrl);
2702
+ if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
2703
+ throw new Error("API health URL must be loopback HTTP");
2704
+ }
2705
+ const databaseUrl = new URL(profile.databaseHealthUrl);
2706
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2707
+ throw new Error("database health URL must be loopback or private HTTP");
2708
+ }
2709
+ if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
2710
+ throw new Error("lifecycle profile database ports are invalid");
2711
+ }
2712
+ }
2713
+ function loadLifecycleProfile(path) {
2714
+ const profile = JSON.parse(readFileSync4(path, "utf8"));
2715
+ validateLifecycleProfile(profile);
2716
+ return profile;
2717
+ }
2718
+ var spawnLifecycleCommand = async (argv) => {
2719
+ const child = Bun.spawn([...argv], {
2720
+ stdout: "pipe",
2721
+ stderr: "pipe",
2722
+ env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
2723
+ });
2724
+ let timedOut = false;
2725
+ const timer = setTimeout(() => {
2726
+ timedOut = true;
2727
+ child.kill("SIGTERM");
2728
+ }, ACTION_TIMEOUT_MS);
2729
+ const [stdout, stderr, exitCode] = await Promise.all([
2730
+ new Response(child.stdout).text(),
2731
+ new Response(child.stderr).text(),
2732
+ child.exited
2733
+ ]);
2734
+ clearTimeout(timer);
2735
+ return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
2736
+ };
2737
+ var requireSuccess = async (exec, argv, label) => {
2738
+ const result = await exec(argv);
2739
+ if (result.exitCode !== 0)
2740
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
2741
+ return result.stdout;
2742
+ };
2743
+ var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve2, reject) => {
2744
+ const socket = createConnection({ host, port });
2745
+ socket.setTimeout(timeoutMs);
2746
+ socket.once("connect", () => {
2747
+ socket.destroy();
2748
+ resolve2();
2749
+ });
2750
+ socket.once("timeout", () => {
2751
+ socket.destroy();
2752
+ reject(new Error(`private peer ${host}:${port} timed out`));
2753
+ });
2754
+ socket.once("error", reject);
2755
+ });
2756
+ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
2757
+ validateLifecycleProfile(profile);
2758
+ switch (claim.action) {
2759
+ case "network-ready": {
2760
+ if (!claim.peerPrivateAddresses?.length)
2761
+ throw new Error("network claim has no private peers");
2762
+ if (claim.network === "cloudflare-warp") {
2763
+ const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
2764
+ if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
2765
+ throw new Error("WARP is not connected");
2766
+ }
2767
+ for (const address of claim.peerPrivateAddresses) {
2768
+ for (const port of profile.databasePorts)
2769
+ await tcpProbe(address, port);
2770
+ }
2771
+ return {
2772
+ targetAgentReady: true,
2773
+ privateNetworkReady: true,
2774
+ ...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
2775
+ };
2776
+ }
2777
+ case "database-member-ready": {
2778
+ await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
2779
+ const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
2780
+ if (!response.ok && response.status !== 401)
2781
+ throw new Error(`database health returned HTTP ${response.status}`);
2782
+ return { databaseMemberHealthy: true };
2783
+ }
2784
+ case "api-ready": {
2785
+ const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
2786
+ if (!response.ok)
2787
+ throw new Error(`API health returned HTTP ${response.status}`);
2788
+ return { apiHealthy: true };
2789
+ }
2790
+ case "source-drained":
2791
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
2792
+ return { sourceDrained: true };
2793
+ case "source-stopped":
2794
+ await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
2795
+ return { sourceStopped: true };
2796
+ default:
2797
+ throw new Error("unknown lifecycle action");
2798
+ }
2799
+ }
2800
+ function startLifecycleHelper(options) {
2801
+ validateLifecycleProfile(options.profile);
2802
+ const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
2803
+ if (existsSync9(socketPath))
2804
+ unlinkSync7(socketPath);
2805
+ let tail = Promise.resolve();
2806
+ const server = createServer5((socket) => {
2807
+ let buffer = "";
2808
+ socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
2809
+ `));
2810
+ socket.on("data", (chunk) => {
2811
+ buffer += chunk.toString("utf8");
2812
+ if (buffer.length > MAX_REQUEST_BYTES4)
2813
+ return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
2814
+ `);
2815
+ const newline = buffer.indexOf(`
2816
+ `);
2817
+ if (newline < 0)
2818
+ return;
2819
+ socket.setTimeout(0);
2820
+ const line = buffer.slice(0, newline);
2821
+ buffer = "";
2822
+ const work = async () => {
2823
+ let request;
2824
+ try {
2825
+ request = JSON.parse(line);
2826
+ } catch {
2827
+ return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
2828
+ }
2829
+ if (request?.op !== "apply" || !request.claim)
2830
+ return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
2831
+ try {
2832
+ return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
2833
+ } catch (cause) {
2834
+ return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
2835
+ }
2836
+ };
2837
+ const response = tail.then(work, work);
2838
+ tail = response;
2839
+ response.then((value) => socket.end(`${JSON.stringify(value)}
2840
+ `));
2841
+ });
2842
+ socket.on("error", () => socket.destroy());
2843
+ });
2844
+ server.listen(socketPath, () => chmodSync7(socketPath, 432));
2845
+ return {
2846
+ server,
2847
+ async stop() {
2848
+ await new Promise((resolve2) => server.close(() => resolve2()));
2849
+ await tail;
2850
+ }
2851
+ };
2852
+ }
2853
+ function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
2854
+ return new Promise((resolve2, reject) => {
2855
+ const socket = connect4(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
2856
+ `));
2857
+ socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
2858
+ socket.destroy();
2859
+ reject(new Error("lifecycle helper response timed out"));
2860
+ });
2861
+ let buffer = "";
2862
+ socket.on("data", (chunk) => {
2863
+ buffer += chunk.toString("utf8");
2864
+ const newline = buffer.indexOf(`
2865
+ `);
2866
+ if (newline < 0)
2867
+ return;
2868
+ socket.end();
2869
+ try {
2870
+ const response = JSON.parse(buffer.slice(0, newline));
2871
+ if (response.ok)
2872
+ resolve2(response.evidence);
2873
+ else
2874
+ reject(new Error(response.error.message));
2875
+ } catch (cause) {
2876
+ reject(cause);
2877
+ }
2878
+ });
2879
+ socket.on("error", reject);
2880
+ });
2881
+ }
2882
+
2883
+ // src/warp-config.ts
2884
+ import { chmodSync as chmodSync8, mkdirSync as mkdirSync5, renameSync as renameSync3, symlinkSync, unlinkSync as unlinkSync8, writeFileSync as writeFileSync5 } from "fs";
2885
+ var xml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
2886
+ function renderWarpMdm(options) {
2887
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.organization))
2888
+ throw new Error("WARP organization is invalid");
2889
+ if (!options.clientId.trim() || !options.clientSecret.trim())
2890
+ throw new Error("WARP service-token credentials are empty");
2891
+ return `<?xml version="1.0" encoding="UTF-8"?>
2892
+ <dict>
2893
+ <key>auth_client_id</key><string>${xml(options.clientId.trim())}</string>
2894
+ <key>auth_client_secret</key><string>${xml(options.clientSecret.trim())}</string>
2895
+ <key>auto_connect</key><integer>1</integer>
2896
+ <key>onboarding</key><false/>
2897
+ <key>organization</key><string>${xml(options.organization)}</string>
2898
+ <key>service_mode</key><string>warp</string>
2899
+ </dict>
2900
+ `;
2901
+ }
2902
+ function materializeWarpMdm(options) {
2903
+ const runtimePath = options.runtimePath ?? "/run/forgezero-warp/mdm.xml";
2904
+ const servicePath = options.servicePath ?? "/var/lib/cloudflare-warp/mdm.xml";
2905
+ mkdirSync5(runtimePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
2906
+ mkdirSync5(servicePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
2907
+ const next = `${runtimePath}.next`;
2908
+ writeFileSync5(next, renderWarpMdm(options), { mode: 384 });
2909
+ chmodSync8(next, 384);
2910
+ renameSync3(next, runtimePath);
2911
+ try {
2912
+ unlinkSync8(servicePath);
2913
+ } catch (cause) {
2914
+ if (cause.code !== "ENOENT")
2915
+ throw cause;
2916
+ }
2917
+ symlinkSync(runtimePath, servicePath);
2918
+ return { runtimePath, servicePath };
2919
+ }
2920
+
2735
2921
  // src/version.ts
2736
- var VERSION = "0.1.22";
2922
+ var VERSION = "0.1.23";
2737
2923
 
2738
2924
  // src/index.ts
2739
2925
  function loadOrCreateSeed(path) {
2740
- if (existsSync9(path)) {
2741
- const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2926
+ if (existsSync10(path)) {
2927
+ const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2742
2928
  if (seed2.length < 32) {
2743
2929
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
2744
2930
  }
2745
2931
  return seed2;
2746
2932
  }
2747
- mkdirSync5(dirname4(path), { recursive: true });
2933
+ mkdirSync6(dirname4(path), { recursive: true });
2748
2934
  const seed = new Uint8Array(randomBytes(32));
2749
- writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2750
- chmodSync7(path, 384);
2935
+ writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2936
+ chmodSync9(path, 384);
2751
2937
  return seed;
2752
2938
  }
2753
2939
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
@@ -2758,9 +2944,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
2758
2944
  if (!directory)
2759
2945
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
2760
2946
  const path = `${directory}/${name}`;
2761
- if (!existsSync9(path))
2947
+ if (!existsSync10(path))
2762
2948
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2763
- const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2949
+ const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2764
2950
  if (seed.length < 32)
2765
2951
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2766
2952
  return seed;
@@ -2770,7 +2956,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
2770
2956
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2771
2957
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
2772
2958
  throw new Error("agent: invalid systemd credential name.");
2773
- const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
2959
+ const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2774
2960
  if (!value)
2775
2961
  throw new Error(`agent: systemd credential ${name} is empty.`);
2776
2962
  return value;
@@ -2876,7 +3062,7 @@ if (import.meta.main) {
2876
3062
  nodeKey: nodeKey2,
2877
3063
  keys: keys2,
2878
3064
  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
3065
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2880
3066
  });
2881
3067
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2882
3068
  process.exit(0);
@@ -2885,7 +3071,7 @@ if (import.meta.main) {
2885
3071
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2886
3072
  if (!profilePath)
2887
3073
  throw new Error("metal-helper requires --profile=/absolute/path.json");
2888
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3074
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2889
3075
  const helper = startMetalHelper({
2890
3076
  profile,
2891
3077
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -2903,11 +3089,53 @@ if (import.meta.main) {
2903
3089
  process.on("SIGINT", () => void stop());
2904
3090
  await new Promise(() => {});
2905
3091
  }
3092
+ if (command === "lifecycle-helper") {
3093
+ const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
3094
+ if (!profilePath?.startsWith("/"))
3095
+ throw new Error("lifecycle-helper requires --profile=/absolute/path.json");
3096
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
3097
+ throw new Error("lifecycle-helper must run as root");
3098
+ }
3099
+ const helper = startLifecycleHelper({
3100
+ profile: loadLifecycleProfile(profilePath),
3101
+ socketPath: process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET
3102
+ });
3103
+ console.log(`[lifecycle-helper] listening on ${process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET}`);
3104
+ let stopping = false;
3105
+ const stop = async () => {
3106
+ if (stopping)
3107
+ return;
3108
+ stopping = true;
3109
+ await helper.stop();
3110
+ process.exit(0);
3111
+ };
3112
+ process.on("SIGTERM", () => void stop());
3113
+ process.on("SIGINT", () => void stop());
3114
+ await new Promise(() => {});
3115
+ }
3116
+ if (command === "warp-config") {
3117
+ const organization = args.find((arg) => arg.startsWith("--organization="))?.slice("--organization=".length);
3118
+ if (!organization)
3119
+ throw new Error("warp-config requires --organization=<team-name>");
3120
+ if (typeof process.getuid !== "function" || process.getuid() !== 0)
3121
+ throw new Error("warp-config must run as root");
3122
+ const clientIdCredential = process.env.FZ_WARP_CLIENT_ID_CREDENTIAL;
3123
+ const clientSecretCredential = process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL;
3124
+ if (!clientIdCredential || !clientSecretCredential)
3125
+ throw new Error("warp-config requires systemd service-token credentials");
3126
+ materializeWarpMdm({
3127
+ organization,
3128
+ clientId: loadTextCredential(clientIdCredential),
3129
+ clientSecret: loadTextCredential(clientSecretCredential)
3130
+ });
3131
+ console.log("[warp-config] enrollment materialized in tmpfs");
3132
+ process.exit(0);
3133
+ }
2906
3134
  if (command === "metal-isolation") {
2907
3135
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2908
3136
  if (!profilePath)
2909
3137
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
2910
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3138
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2911
3139
  await applyMetalIsolation(profile);
2912
3140
  console.log("[metal-isolation] host and guest cgroup boundaries active");
2913
3141
  process.exit(0);
@@ -2952,7 +3180,7 @@ if (import.meta.main) {
2952
3180
  if (claimArg !== "-" && !claimArg.startsWith("/")) {
2953
3181
  throw new Error("metal-apply claim path must be absolute");
2954
3182
  }
2955
- const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
3183
+ const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
2956
3184
  if (Buffer.byteLength(raw) > 32 * 1024)
2957
3185
  throw new Error("metal-apply claim exceeds 32 KiB");
2958
3186
  const claim = JSON.parse(raw);
@@ -2978,9 +3206,9 @@ if (import.meta.main) {
2978
3206
  metalHostname: process.env.FZ_METAL_HOSTNAME,
2979
3207
  run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
2980
3208
  metalPreflight: () => ({
2981
- snpHost: existsSync9("/dev/sev"),
2982
- kvm: existsSync9("/dev/kvm"),
2983
- helper: existsSync9(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
3209
+ snpHost: existsSync10("/dev/sev"),
3210
+ kvm: existsSync10("/dev/kvm"),
3211
+ helper: existsSync10(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
2984
3212
  }),
2985
3213
  onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2986
3214
  });
@@ -3027,7 +3255,7 @@ if (import.meta.main) {
3027
3255
  process.exit(1);
3028
3256
  }
3029
3257
  }
3030
- const attestationSource = existsSync9("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3258
+ const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3031
3259
  const running = runAgent({
3032
3260
  socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
3033
3261
  seedCredential: process.env.FZ_SEED_CREDENTIAL,
@@ -3041,8 +3269,8 @@ if (import.meta.main) {
3041
3269
  const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
3042
3270
  let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
3043
3271
  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));
3272
+ const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync10(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
3273
+ const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync10(process.env.FZ_ENROL_TOKEN_FILE));
3046
3274
  if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
3047
3275
  binding = await enrolGuestIdentity({
3048
3276
  apiUrl: process.env.FZ_API,
@@ -3052,7 +3280,7 @@ if (import.meta.main) {
3052
3280
  nodeKey,
3053
3281
  keys,
3054
3282
  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
3283
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
3056
3284
  });
3057
3285
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
3058
3286
  }
@@ -3090,6 +3318,19 @@ if (import.meta.main) {
3090
3318
  }) : undefined;
3091
3319
  if (attestationLoop)
3092
3320
  console.log("[agent] periodic SEV-SNP attestation enabled");
3321
+ const migrationEnabled = process.env.FZ_MIGRATION_PULL === "true";
3322
+ if (migrationEnabled && (!binding || !nodeApiUrl)) {
3323
+ throw new Error("agent: outbound lifecycle work requires a persisted compute enrolment binding");
3324
+ }
3325
+ const migrationPull = migrationEnabled ? startMigrationPull({
3326
+ apiUrl: nodeApiUrl,
3327
+ nodeKey,
3328
+ keys,
3329
+ run: (claim) => requestLifecycleAction(claim, process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET),
3330
+ onEvent: (event, detail) => console.log(`[agent] migration ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
3331
+ }) : undefined;
3332
+ if (migrationPull)
3333
+ console.log("[agent] PQ-authenticated outbound lifecycle claims enabled");
3093
3334
  const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
3094
3335
  const deploymentSecrets = secretCache ? {
3095
3336
  async get(name) {
@@ -3175,8 +3416,9 @@ if (import.meta.main) {
3175
3416
  const pullDrain = pull?.stop() ?? Promise.resolve();
3176
3417
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3177
3418
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3419
+ const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
3178
3420
  const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
3179
- const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
3421
+ const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
3180
3422
  const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
3181
3423
  const socketClosed = await closeServerWithin(server, remaining());
3182
3424
  const timedOut = managerReports.some((report) => report.timedOut);
@@ -3193,7 +3435,7 @@ if (import.meta.main) {
3193
3435
  process.on("SIGINT", () => void shutdown("SIGINT"));
3194
3436
  } else {
3195
3437
  console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
3196
- if (vaultSync || attestationLoop) {
3438
+ if (vaultSync || attestationLoop || migrationPull) {
3197
3439
  let stopping = false;
3198
3440
  const stop = async (signal) => {
3199
3441
  if (stopping)
@@ -3205,7 +3447,8 @@ if (import.meta.main) {
3205
3447
  console.log(`[agent] ${signal}: stopping background intake and draining`);
3206
3448
  const backgroundDrained = await settleWithin(Promise.all([
3207
3449
  vaultSync?.stop() ?? Promise.resolve(),
3208
- attestationLoop?.stop() ?? Promise.resolve()
3450
+ attestationLoop?.stop() ?? Promise.resolve(),
3451
+ migrationPull?.stop() ?? Promise.resolve()
3209
3452
  ]), remaining());
3210
3453
  const socketClosed = await closeServerWithin(server, remaining());
3211
3454
  console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
@@ -3218,34 +3461,43 @@ if (import.meta.main) {
3218
3461
  }
3219
3462
  export {
3220
3463
  validateMetalProfile,
3464
+ validateLifecycleProfile,
3221
3465
  tenantNodeApiUrl,
3222
3466
  startProvisioningPull,
3223
3467
  startNodeVaultSync,
3224
3468
  startNodeAttestation,
3469
+ startMigrationPull,
3225
3470
  startMetalHelper,
3471
+ startLifecycleHelper,
3226
3472
  startDeploymentRunner,
3227
3473
  startDeploymentPull,
3228
3474
  startControlServer,
3229
3475
  startAgent,
3230
3476
  runAgent,
3231
3477
  requestMetalProvision,
3478
+ requestLifecycleAction,
3232
3479
  requestDeploymentCommand,
3233
3480
  requestControl,
3481
+ renderWarpMdm,
3234
3482
  removeMetalGuest,
3235
3483
  pullProvisioningOnce,
3484
+ pullMigrationOnce,
3236
3485
  pullDeploymentOnce,
3237
3486
  provisionMetalGuest,
3238
3487
  projectVaultCoordinate,
3239
3488
  projectVaultCacheKey,
3240
3489
  metalHousekeepingDropIn,
3241
3490
  metalGuestSliceUnit,
3491
+ materializeWarpMdm,
3242
3492
  loadTextCredential,
3243
3493
  loadSeedCredential,
3244
3494
  loadOrCreateSeed,
3495
+ loadLifecycleProfile,
3245
3496
  loadGuestBinding,
3246
3497
  handleRequest,
3247
3498
  handleApplicationRequest,
3248
3499
  guestNameFor,
3500
+ executeLifecycleAction,
3249
3501
  enrolGuestIdentity,
3250
3502
  createSystemdDeploymentSecrets,
3251
3503
  createSnpAttestationSource,
@@ -3265,6 +3517,7 @@ export {
3265
3517
  DEFAULT_SEED_PATH,
3266
3518
  DEFAULT_SEED_CREDENTIAL,
3267
3519
  DEFAULT_METAL_HELPER_SOCKET,
3520
+ DEFAULT_LIFECYCLE_HELPER_SOCKET,
3268
3521
  DEFAULT_ENROLMENT_STATE_PATH,
3269
3522
  DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
3270
3523
  DEFAULT_CONTROL_SOCKET,