@forgezero/agent 0.1.21 → 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";
@@ -473,17 +473,12 @@ function parseDeployDefinition(value) {
473
473
  }
474
474
  const roles = root.roles.map((raw, index) => {
475
475
  const role = record(raw, `roles[${index}]`);
476
- exactKeys(role, ["name", "count", "software"], `roles[${index}]`);
477
- const count = Number(role.count);
478
- if (!Number.isSafeInteger(count) || count < 1) {
479
- throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
480
- }
476
+ exactKeys(role, ["name", "software"], `roles[${index}]`);
481
477
  if (!Array.isArray(role.software)) {
482
478
  throw new DefinitionError(`roles[${index}].software must be an array.`);
483
479
  }
484
480
  return {
485
481
  name: text(role.name, `roles[${index}].name`),
486
- count,
487
482
  software: role.software.map((rawSoftware, softwareIndex) => {
488
483
  const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
489
484
  exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
@@ -1378,6 +1373,132 @@ function startProvisioningPull(options) {
1378
1373
  };
1379
1374
  }
1380
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
+
1381
1502
  // src/metal-helper-socket.ts
1382
1503
  import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
1383
1504
  import { connect as connect2, createServer as createServer3 } from "net";
@@ -1494,55 +1615,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
1494
1615
  ];
1495
1616
  }
1496
1617
 
1497
- // src/provision.ts
1498
- var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
1499
- var DEPLOYMENT_GROUP = "forgezero-deploy";
1500
- var VAULT_GROUP = "forgezero-vault";
1501
- var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
1502
- function deploymentRunnerUnit(options) {
1503
- const bin = options.binPath ?? "fz-agent";
1504
- const root = options.deployRoot ?? "/opt/forgezero";
1505
- const agentUser = options.user ?? "forgezero-agent";
1506
- return `[Unit]
1507
- Description=ForgeZero credential-free project command runner
1508
- Documentation=https://www.forgezero.net/docs/agent
1509
-
1510
- [Service]
1511
- Type=notify
1512
- NotifyAccess=all
1513
- User=${DEPLOYMENT_RUNNER_USER}
1514
- Group=${DEPLOYMENT_GROUP}
1515
- Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1516
- RuntimeDirectory=forgezero-deploy
1517
- RuntimeDirectoryMode=0710
1518
- ExecStartPre=+/usr/bin/install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0710 /run/forgezero-deploy
1519
- ExecStartPre=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
1520
- ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
1521
- ExecStartPost=+/usr/bin/chown ${agentUser}:${agentUser} ${DEPLOYMENT_RUNNER_SOCKET}
1522
- ExecStartPost=+/usr/bin/chmod 0600 ${DEPLOYMENT_RUNNER_SOCKET}
1523
- ExecStartPost=+/usr/bin/chown root:${VAULT_GROUP} /run/forgezero-deploy
1524
- ExecStopPost=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
1525
- Restart=always
1526
- RestartSec=2
1527
- UMask=0007
1528
- LimitCORE=0
1529
- NoNewPrivileges=false
1530
- PrivateTmp=true
1531
- ProtectSystem=strict
1532
- ProtectHome=true
1533
- ProtectKernelTunables=true
1534
- ProtectKernelModules=true
1535
- ProtectControlGroups=true
1536
- RestrictRealtime=true
1537
- MemoryDenyWriteExecute=true
1538
- LockPersonality=true
1539
- ReadWritePaths=${root}/releases ${root}/runner-home
1540
-
1541
- [Install]
1542
- WantedBy=multi-user.target
1543
- `;
1544
- }
1545
-
1546
1618
  // src/ubuntu.ts
1547
1619
  var SUPPORTED_GUEST_IMAGE = Object.freeze({
1548
1620
  key: "ubuntu-resolute-20260731",
@@ -1723,7 +1795,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
1723
1795
  encoding: b64
1724
1796
  content: ${base64(content)}
1725
1797
  `;
1726
- function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
1798
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
1727
1799
  const agentBun = "/usr/local/lib/forgezero/bun";
1728
1800
  const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
1729
1801
  # healthy and encrypted while attestation is silently impossible. Install and
@@ -1737,26 +1809,7 @@ printf 'sev-guest
1737
1809
  ` : "";
1738
1810
  return `#!/usr/bin/env bash
1739
1811
  set -Eeuo pipefail
1740
- ${attestationSetup}groupadd --system ${VAULT_GROUP} 2>/dev/null || true
1741
- useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
1742
- usermod -g ${VAULT_GROUP} forgezero-agent
1743
- groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
1744
- useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
1745
- usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
1746
- install -d -o root -g root -m 0700 /etc/forgezero/creds
1747
- install -d -o root -g root -m 0755 /etc/forgezero/git
1748
- install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
1749
- install -d -o root -g root -m 0755 /opt/forgezero
1750
- install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
1751
- install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
1752
- install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
1753
- ${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
1754
- systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
1755
- rm -f /run/forgezero-enrol-token
1756
- fi
1757
- chown root:root /var/lib/forgezero/enrol-token.cred
1758
- chmod 0400 /var/lib/forgezero/enrol-token.cred
1759
- ` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
1812
+ ${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
1760
1813
  curl -fsSL https://bun.sh/install -o /run/fz-bun-install
1761
1814
  printf '%s %s
1762
1815
  ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
@@ -1764,132 +1817,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
1764
1817
  install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
1765
1818
  rm -f /run/fz-bun-install
1766
1819
  fi
1767
- 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
1768
1821
  env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
1769
1822
  ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
1770
1823
  fi
1771
- if [[ ! -s /etc/forgezero/creds/agent-seed.cred ]]; then
1772
- umask 077
1773
- openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
1774
- ' >/run/fz-agent-seed
1775
- systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
1776
- rm -f /run/fz-agent-seed
1777
- fi
1778
- chmod 0400 /etc/forgezero/creds/agent-seed.cred
1779
- if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
1780
- umask 077
1781
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1782
- ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
1783
- systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
1784
- install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1785
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1786
- fi
1787
- if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
1788
- systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
1789
- ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
1790
- install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1791
- rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1792
- fi
1793
- chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
1794
- systemctl daemon-reload
1795
- systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true
1796
- rm -f /etc/systemd/system/forgezero-deploy-runner.socket
1797
- systemctl daemon-reload
1798
- systemctl enable --now forgezero-deploy-runner.service forgezero-agent.service
1799
- `;
1800
- }
1801
- function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
1802
- const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
1803
- ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1804
- ` : "";
1805
- const attestationDevice = attested ? `DevicePolicy=closed
1806
- DeviceAllow=/dev/sev-guest rw
1807
- ` : "";
1808
- return `[Unit]
1809
- Description=ForgeZero compute agent (${name})
1810
- After=network-online.target forgezero-deploy-runner.service
1811
- Wants=network-online.target
1812
- Requires=forgezero-deploy-runner.service
1813
-
1814
- [Service]
1815
- Type=simple
1816
- User=forgezero-agent
1817
- Group=${VAULT_GROUP}
1818
- SupplementaryGroups=${DEPLOYMENT_GROUP}
1819
- LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
1820
- LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
1821
- Environment=FZ_SEED_CREDENTIAL=agent-seed
1822
- Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
1823
- Environment=FZ_API=${profile.apiUrl}
1824
- Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
1825
- Environment=FZ_NODE_LABEL=${name}
1826
- Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
1827
- Environment=FZ_DEPLOY_ROOT=/opt/forgezero
1828
- Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
1829
- Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1830
- Environment=HOME=/opt/forgezero/home
1831
- ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
1832
- Restart=on-failure
1833
- RestartSec=5
1834
- RuntimeDirectory=forgezero
1835
- RuntimeDirectoryMode=0750
1836
- UMask=0007
1837
- LimitCORE=0
1838
- NoNewPrivileges=true
1839
- PrivateTmp=true
1840
- ProtectSystem=strict
1841
- ProtectHome=true
1842
- ReadWritePaths=/var/lib/forgezero /opt/forgezero
1843
- ${attestationDevice}
1844
-
1845
- [Install]
1846
- WantedBy=multi-user.target
1847
- `;
1848
- }
1849
- function guestEnrolmentDropIn() {
1850
- return `[Service]
1851
- LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
1852
- Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
1853
- `;
1854
- }
1855
- function guestEnrolmentCleanupScript() {
1856
- return `#!/usr/bin/env bash
1857
- set -Eeuo pipefail
1858
- for _ in $(seq 1 180); do
1859
- [[ -s /var/lib/forgezero/enrolment.json ]] && break
1860
- sleep 1
1861
- done
1862
- [[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
1863
- rm -f /var/lib/forgezero/enrol-token.cred
1864
- rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
1865
- systemctl daemon-reload
1866
- systemctl disable forgezero-enrolment-cleanup.service
1867
- `;
1868
- }
1869
- function guestEnrolmentCleanupUnit() {
1870
- return `[Unit]
1871
- Description=Remove the consumed ForgeZero guest enrolment credential
1872
- After=forgezero-agent.service
1873
- Requires=forgezero-agent.service
1874
- ConditionPathExists=/var/lib/forgezero/enrol-token.cred
1875
-
1876
- [Service]
1877
- Type=oneshot
1878
- ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
1879
- TimeoutStartSec=4min
1880
-
1881
- [Install]
1882
- 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" : ""}
1883
1825
  `;
1884
1826
  }
1885
1827
  function cloudInit(profile, claim, manifest) {
1886
1828
  const enrolled = Boolean(claim.enrolment);
1887
- const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
1888
- const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
1889
- const enrolmentDropIn = guestEnrolmentDropIn();
1890
- const cleanupScript = guestEnrolmentCleanupScript();
1891
- const cleanupUnit = guestEnrolmentCleanupUnit();
1892
- 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);
1893
1830
  const access = claim.access;
1894
1831
  const users = access ? `users:
1895
1832
  - default
@@ -1901,8 +1838,8 @@ function cloudInit(profile, claim, manifest) {
1901
1838
  ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1902
1839
  `)}
1903
1840
  ` : "";
1904
- const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1905
- `, "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") : "";
1906
1843
  return {
1907
1844
  userData: `#cloud-config
1908
1845
  package_update: true
@@ -1911,10 +1848,8 @@ ${users}disable_root: true
1911
1848
  # every dynamically claimed repository must be cloneable on a clean image.
1912
1849
  packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
1913
1850
  write_files:
1914
- ${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:
1915
1852
  - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
1916
- ${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1917
- ` : ""}
1918
1853
  `,
1919
1854
  metaData: `instance-id: ${manifest.name}-${claim.attempt}
1920
1855
  local-hostname: ${manifest.name}
@@ -2737,22 +2672,268 @@ async function closeServerWithin(server, timeoutMs) {
2737
2672
  return closed;
2738
2673
  }
2739
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
+
2740
2921
  // src/version.ts
2741
- var VERSION = "0.1.21";
2922
+ var VERSION = "0.1.23";
2742
2923
 
2743
2924
  // src/index.ts
2744
2925
  function loadOrCreateSeed(path) {
2745
- if (existsSync9(path)) {
2746
- 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"));
2747
2928
  if (seed2.length < 32) {
2748
2929
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
2749
2930
  }
2750
2931
  return seed2;
2751
2932
  }
2752
- mkdirSync5(dirname4(path), { recursive: true });
2933
+ mkdirSync6(dirname4(path), { recursive: true });
2753
2934
  const seed = new Uint8Array(randomBytes(32));
2754
- writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2755
- chmodSync7(path, 384);
2935
+ writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2936
+ chmodSync9(path, 384);
2756
2937
  return seed;
2757
2938
  }
2758
2939
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
@@ -2763,9 +2944,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
2763
2944
  if (!directory)
2764
2945
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
2765
2946
  const path = `${directory}/${name}`;
2766
- if (!existsSync9(path))
2947
+ if (!existsSync10(path))
2767
2948
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2768
- const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2949
+ const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2769
2950
  if (seed.length < 32)
2770
2951
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2771
2952
  return seed;
@@ -2775,7 +2956,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
2775
2956
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2776
2957
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
2777
2958
  throw new Error("agent: invalid systemd credential name.");
2778
- const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
2959
+ const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2779
2960
  if (!value)
2780
2961
  throw new Error(`agent: systemd credential ${name} is empty.`);
2781
2962
  return value;
@@ -2881,7 +3062,7 @@ if (import.meta.main) {
2881
3062
  nodeKey: nodeKey2,
2882
3063
  keys: keys2,
2883
3064
  label: process.env.FZ_NODE_LABEL,
2884
- 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
2885
3066
  });
2886
3067
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2887
3068
  process.exit(0);
@@ -2890,7 +3071,7 @@ if (import.meta.main) {
2890
3071
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2891
3072
  if (!profilePath)
2892
3073
  throw new Error("metal-helper requires --profile=/absolute/path.json");
2893
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3074
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2894
3075
  const helper = startMetalHelper({
2895
3076
  profile,
2896
3077
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -2908,11 +3089,53 @@ if (import.meta.main) {
2908
3089
  process.on("SIGINT", () => void stop());
2909
3090
  await new Promise(() => {});
2910
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
+ }
2911
3134
  if (command === "metal-isolation") {
2912
3135
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2913
3136
  if (!profilePath)
2914
3137
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
2915
- const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
3138
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2916
3139
  await applyMetalIsolation(profile);
2917
3140
  console.log("[metal-isolation] host and guest cgroup boundaries active");
2918
3141
  process.exit(0);
@@ -2957,7 +3180,7 @@ if (import.meta.main) {
2957
3180
  if (claimArg !== "-" && !claimArg.startsWith("/")) {
2958
3181
  throw new Error("metal-apply claim path must be absolute");
2959
3182
  }
2960
- const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
3183
+ const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
2961
3184
  if (Buffer.byteLength(raw) > 32 * 1024)
2962
3185
  throw new Error("metal-apply claim exceeds 32 KiB");
2963
3186
  const claim = JSON.parse(raw);
@@ -2983,9 +3206,9 @@ if (import.meta.main) {
2983
3206
  metalHostname: process.env.FZ_METAL_HOSTNAME,
2984
3207
  run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
2985
3208
  metalPreflight: () => ({
2986
- snpHost: existsSync9("/dev/sev"),
2987
- kvm: existsSync9("/dev/kvm"),
2988
- 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)
2989
3212
  }),
2990
3213
  onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2991
3214
  });
@@ -3032,7 +3255,7 @@ if (import.meta.main) {
3032
3255
  process.exit(1);
3033
3256
  }
3034
3257
  }
3035
- const attestationSource = existsSync9("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3258
+ const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
3036
3259
  const running = runAgent({
3037
3260
  socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
3038
3261
  seedCredential: process.env.FZ_SEED_CREDENTIAL,
@@ -3046,8 +3269,8 @@ if (import.meta.main) {
3046
3269
  const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
3047
3270
  let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
3048
3271
  const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
3049
- const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync9(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
3050
- 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));
3051
3274
  if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
3052
3275
  binding = await enrolGuestIdentity({
3053
3276
  apiUrl: process.env.FZ_API,
@@ -3057,7 +3280,7 @@ if (import.meta.main) {
3057
3280
  nodeKey,
3058
3281
  keys,
3059
3282
  label: process.env.FZ_NODE_LABEL,
3060
- 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
3061
3284
  });
3062
3285
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
3063
3286
  }
@@ -3095,6 +3318,19 @@ if (import.meta.main) {
3095
3318
  }) : undefined;
3096
3319
  if (attestationLoop)
3097
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");
3098
3334
  const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
3099
3335
  const deploymentSecrets = secretCache ? {
3100
3336
  async get(name) {
@@ -3180,8 +3416,9 @@ if (import.meta.main) {
3180
3416
  const pullDrain = pull?.stop() ?? Promise.resolve();
3181
3417
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3182
3418
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3419
+ const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
3183
3420
  const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
3184
- const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
3421
+ const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
3185
3422
  const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
3186
3423
  const socketClosed = await closeServerWithin(server, remaining());
3187
3424
  const timedOut = managerReports.some((report) => report.timedOut);
@@ -3198,7 +3435,7 @@ if (import.meta.main) {
3198
3435
  process.on("SIGINT", () => void shutdown("SIGINT"));
3199
3436
  } else {
3200
3437
  console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
3201
- if (vaultSync || attestationLoop) {
3438
+ if (vaultSync || attestationLoop || migrationPull) {
3202
3439
  let stopping = false;
3203
3440
  const stop = async (signal) => {
3204
3441
  if (stopping)
@@ -3210,7 +3447,8 @@ if (import.meta.main) {
3210
3447
  console.log(`[agent] ${signal}: stopping background intake and draining`);
3211
3448
  const backgroundDrained = await settleWithin(Promise.all([
3212
3449
  vaultSync?.stop() ?? Promise.resolve(),
3213
- attestationLoop?.stop() ?? Promise.resolve()
3450
+ attestationLoop?.stop() ?? Promise.resolve(),
3451
+ migrationPull?.stop() ?? Promise.resolve()
3214
3452
  ]), remaining());
3215
3453
  const socketClosed = await closeServerWithin(server, remaining());
3216
3454
  console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
@@ -3223,34 +3461,43 @@ if (import.meta.main) {
3223
3461
  }
3224
3462
  export {
3225
3463
  validateMetalProfile,
3464
+ validateLifecycleProfile,
3226
3465
  tenantNodeApiUrl,
3227
3466
  startProvisioningPull,
3228
3467
  startNodeVaultSync,
3229
3468
  startNodeAttestation,
3469
+ startMigrationPull,
3230
3470
  startMetalHelper,
3471
+ startLifecycleHelper,
3231
3472
  startDeploymentRunner,
3232
3473
  startDeploymentPull,
3233
3474
  startControlServer,
3234
3475
  startAgent,
3235
3476
  runAgent,
3236
3477
  requestMetalProvision,
3478
+ requestLifecycleAction,
3237
3479
  requestDeploymentCommand,
3238
3480
  requestControl,
3481
+ renderWarpMdm,
3239
3482
  removeMetalGuest,
3240
3483
  pullProvisioningOnce,
3484
+ pullMigrationOnce,
3241
3485
  pullDeploymentOnce,
3242
3486
  provisionMetalGuest,
3243
3487
  projectVaultCoordinate,
3244
3488
  projectVaultCacheKey,
3245
3489
  metalHousekeepingDropIn,
3246
3490
  metalGuestSliceUnit,
3491
+ materializeWarpMdm,
3247
3492
  loadTextCredential,
3248
3493
  loadSeedCredential,
3249
3494
  loadOrCreateSeed,
3495
+ loadLifecycleProfile,
3250
3496
  loadGuestBinding,
3251
3497
  handleRequest,
3252
3498
  handleApplicationRequest,
3253
3499
  guestNameFor,
3500
+ executeLifecycleAction,
3254
3501
  enrolGuestIdentity,
3255
3502
  createSystemdDeploymentSecrets,
3256
3503
  createSnpAttestationSource,
@@ -3270,6 +3517,7 @@ export {
3270
3517
  DEFAULT_SEED_PATH,
3271
3518
  DEFAULT_SEED_CREDENTIAL,
3272
3519
  DEFAULT_METAL_HELPER_SOCKET,
3520
+ DEFAULT_LIFECYCLE_HELPER_SOCKET,
3273
3521
  DEFAULT_ENROLMENT_STATE_PATH,
3274
3522
  DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
3275
3523
  DEFAULT_CONTROL_SOCKET,