@forgezero/agent 0.1.78 → 0.1.80

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
@@ -5114,16 +5114,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
5114
5114
  return { exitCode, output: `${stdout}${stderr}` };
5115
5115
  };
5116
5116
  var run = runSoftwareCommand;
5117
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
5118
+ const source = new URL(url);
5119
+ if (source.protocol !== "https:" || source.username || source.password)
5120
+ throw new Error("software download must use credential-free HTTPS");
5121
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
5122
+ throw new Error("software download size bound is invalid");
5123
+ return [
5124
+ "/usr/bin/curl",
5125
+ "--fail",
5126
+ "--location",
5127
+ "--silent",
5128
+ "--show-error",
5129
+ "--proto",
5130
+ "=https",
5131
+ "--tlsv1.2",
5132
+ "--max-time",
5133
+ "1800",
5134
+ "--max-filesize",
5135
+ String(maximumBytes),
5136
+ "--output",
5137
+ destination,
5138
+ source.href
5139
+ ];
5140
+ };
5117
5141
  var download = async (url, destination, sha2562, maximumBytes = 512 * 1024 * 1024) => {
5118
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
5119
- if (!response.ok)
5120
- throw new Error(`download failed with HTTP ${response.status}`);
5121
- const declared = Number(response.headers.get("content-length") ?? 0);
5122
- if (declared > maximumBytes)
5123
- throw new Error("download exceeds reviewed size bound");
5124
5142
  if (existsSync2(destination))
5125
5143
  throw new Error("download destination already exists");
5126
- await Bun.write(destination, response);
5144
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
5145
+ if (received.exitCode !== 0) {
5146
+ rmSync(destination, { force: true });
5147
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
5148
+ }
5127
5149
  chmodSync2(destination, 384);
5128
5150
  if (statSync(destination).size > maximumBytes) {
5129
5151
  rmSync(destination, { force: true });
@@ -6362,7 +6384,7 @@ function reference(value, where) {
6362
6384
  const row2 = object(value, where);
6363
6385
  exact3(row2, ["$ref"], where);
6364
6386
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
6365
- if (!/^(inputs\.[a-z][A-Za-z0-9-]{0,62}|steps\.[a-z][A-Za-z0-9-]{0,62}\.(status|outputs\.[a-z][A-Za-z0-9-]{0,62})|components\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|targets\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|deployment\.[a-z][A-Za-z0-9-]{0,62}|execution\.(targetName|slot|releaseExecutor|runtime|cpuCores|memoryMiB|storageGiB))$/.test(coordinate)) {
6387
+ if (!/^(inputs\.[a-z][A-Za-z0-9-]{0,62}|steps\.[a-z][A-Za-z0-9-]{0,62}\.(status|outputs\.[a-z][A-Za-z0-9-]{0,62})|components\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|targets\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|deployment\.[a-z][A-Za-z0-9-]{0,62}|execution\.(targetName|slot|releaseExecutor|runtime|cpuCores|memoryMiB|storageGiB|site|topologyRole|topologyTransport|localRelayAddress))$/.test(coordinate)) {
6366
6388
  fail(where, "contains an unsupported reference.");
6367
6389
  }
6368
6390
  return { $ref: coordinate };
@@ -6794,6 +6816,12 @@ function validateStep(value, where, targets, context) {
6794
6816
  if (!targets.has(String(scope.target)))
6795
6817
  fail(`${where}.scope.target`, "must name an existing target.");
6796
6818
  integer(scope.size, `${where}.scope.size`, 1, 32);
6819
+ } else if (scope.kind === "topology-role") {
6820
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
6821
+ if (!targets.has(String(scope.target)))
6822
+ fail(`${where}.scope.target`, "must name an existing target.");
6823
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
6824
+ fail(`${where}.scope.role`, "is unsupported.");
6797
6825
  } else
6798
6826
  fail(`${where}.scope.kind`, "is unsupported.");
6799
6827
  if (row2.timeoutMs !== undefined)
@@ -6948,7 +6976,7 @@ function compileDeployment(sourceValue) {
6948
6976
  const targetDefinitions = new Map;
6949
6977
  for (const [targetName, value] of targetEntries) {
6950
6978
  const target = object(value, `deployment.spec.targets.${targetName}`);
6951
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
6979
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
6952
6980
  if (target.kind !== "compute")
6953
6981
  fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
6954
6982
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -7070,6 +7098,45 @@ function compileDeployment(sourceValue) {
7070
7098
  }
7071
7099
  }
7072
7100
  }
7101
+ if (target.topology !== undefined) {
7102
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
7103
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
7104
+ if (topology.groupBy !== "metal")
7105
+ fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
7106
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
7107
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
7108
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
7109
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
7110
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
7111
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
7112
+ fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
7113
+ }
7114
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
7115
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
7116
+ }
7117
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
7118
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
7119
+ }
7120
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
7121
+ if (crossMetal.mode === "private-lan")
7122
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
7123
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
7124
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
7125
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
7126
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
7127
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
7128
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
7129
+ if (typeof crossMetal.network === "string")
7130
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
7131
+ else
7132
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
7133
+ } else
7134
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
7135
+ if (allocation.sharing !== "exclusive")
7136
+ fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
7137
+ if (minimum < 1 + Number(relays.standbyPerMetal))
7138
+ fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
7139
+ }
7073
7140
  if (target.placement !== undefined)
7074
7141
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
7075
7142
  targetDefinitions.set(targetName, target);
@@ -7093,6 +7160,9 @@ function compileDeployment(sourceValue) {
7093
7160
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
7094
7161
  fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
7095
7162
  }
7163
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
7164
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
7165
+ }
7096
7166
  }
7097
7167
  const components = object(spec.components, "deployment.spec.components");
7098
7168
  const componentEntries = Object.entries(components);
@@ -7350,6 +7420,19 @@ function resolveDeploymentTargets(plan, supplied = {}) {
7350
7420
  } : { mode: "disabled" } } : {}
7351
7421
  };
7352
7422
  }
7423
+ let topology;
7424
+ if (target.topology) {
7425
+ const crossMetal = target.topology.crossMetal;
7426
+ topology = {
7427
+ groupBy: "metal",
7428
+ relays: { ...target.topology.relays },
7429
+ crossMetal: crossMetal.mode === "private-lan" ? { mode: "private-lan" } : {
7430
+ ...crossMetal,
7431
+ credential: credentialSource(crossMetal.credential, `target ${name} topology WARP credential`),
7432
+ network: stringCoordinate(crossMetal.network, `target ${name} topology WARP network`)
7433
+ }
7434
+ };
7435
+ }
7353
7436
  return {
7354
7437
  name,
7355
7438
  profiles: [...target.selector.profiles],
@@ -7368,7 +7451,8 @@ function resolveDeploymentTargets(plan, supplied = {}) {
7368
7451
  }
7369
7452
  },
7370
7453
  ...target.provisioning ? { provisioning: { ...target.provisioning } } : {},
7371
- ...connectivity ? { connectivity } : {}
7454
+ ...connectivity ? { connectivity } : {},
7455
+ ...topology ? { topology } : {}
7372
7456
  };
7373
7457
  });
7374
7458
  }
@@ -8074,12 +8158,19 @@ function createDeploymentManager(options) {
8074
8158
  if (!selected || mismatches.length > 0) {
8075
8159
  throw new DeploymentError("PIPELINE_FAILED", `the control-plane target assignment does not match this exact deployment plan (${mismatches.join(", ")})`);
8076
8160
  }
8077
- const expectedPrivate = selected.connectivity?.private;
8161
+ const assignedTopology = assigned.topology;
8162
+ const selectedTopology = selected.topology;
8163
+ const topologyMatches = selectedTopology ? Boolean(assignedTopology && /^[A-Za-z0-9.-]{1,253}$/.test(assignedTopology.site) && /^[A-Za-z0-9.-]{1,253}$/.test(assignedTopology.nodeIdentity) && /^[A-Za-z0-9._:-]{1,256}$/.test(assignedTopology.localRelayReference) && /^sha256:[a-f0-9]{64}$/.test(assignedTopology.generation) && /^(?:\d{1,3}\.){3}0\/24$/.test(assignedTopology.siteCidr) && assignedTopology.localRelayAddress.length <= 64 && assignedTopology.localPeerAddresses.length <= 1023 && assignedTopology.localPeerAddresses.every((address) => typeof address === "string" && address.length <= 64) && assignedTopology.localPeerIdentities.length === assignedTopology.localPeerAddresses.length && assignedTopology.localPeerIdentities.every((identity) => /^[A-Za-z0-9.-]{1,253}$/.test(identity)) && assignedTopology.remoteRelayAddresses.length <= 1023 && assignedTopology.remoteRelayAddresses.every((address) => typeof address === "string" && address.length <= 64) && assignedTopology.remoteRelayIdentities.length === assignedTopology.remoteRelayAddresses.length && assignedTopology.remoteRelayIdentities.every((identity) => /^[A-Za-z0-9.-]{1,253}$/.test(identity)) && assignedTopology.memberIdentities.length >= 1 && assignedTopology.memberIdentities.length <= 1024 && new Set(assignedTopology.memberIdentities).size === assignedTopology.memberIdentities.length && assignedTopology.memberIdentities.includes(assignedTopology.nodeIdentity) && assignedTopology.memberIdentities.every((identity) => /^[A-Za-z0-9.-]{1,253}$/.test(identity)) && (assignedTopology.role === "member" ? assignedTopology.remoteRelayAddresses.length === 0 : assignedTopology.remoteSiteCidrs.length === assignedTopology.remoteRelayAddresses.length) && assignedTopology.remoteSiteCidrs.every((cidr) => typeof cidr === "string" && /^(?:\d{1,3}\.){3}0\/24$/.test(cidr)) && Number.isSafeInteger(assignedTopology.healthPort) && assignedTopology.healthPort >= 1 && assignedTopology.healthPort <= 65535 && assignedTopology.routedTcpPorts.length >= 1 && assignedTopology.routedTcpPorts.length <= 64 && new Set(assignedTopology.routedTcpPorts).size === assignedTopology.routedTcpPorts.length && assignedTopology.routedTcpPorts.includes(assignedTopology.healthPort) && assignedTopology.routedTcpPorts.every((port) => Number.isSafeInteger(port) && port >= 1 && port <= 65535) && (assignedTopology.role !== "member" || assignedTopology.transport === "private-lan" && assignedTopology.warpRequired === false && assignedTopology.remoteRelayAddresses.length === 0 && assignedTopology.localPeerAddresses.length === 1) && (assignedTopology.warpRequired ? assignedTopology.role !== "member" && assignedTopology.transport === "cloudflare-warp" && selectedTopology.crossMetal.mode !== "private-lan" : assignedTopology.transport === "private-lan")) : assignedTopology === undefined;
8164
+ const expectedPrivate = selectedTopology ? assignedTopology?.warpRequired ? selectedTopology.crossMetal.mode === "private-lan" ? undefined : {
8165
+ mode: "cloudflare-warp",
8166
+ credential: selectedTopology.crossMetal.credential,
8167
+ network: selectedTopology.crossMetal.network
8168
+ } : { mode: "private-lan" } : selected.connectivity?.private;
8078
8169
  const expectedPublic = selected.connectivity?.public;
8079
8170
  const assignedPrivate = assigned.connectivity?.private;
8080
8171
  const assignedPublic = assigned.connectivity?.public;
8081
8172
  const publicMatches = expectedPublic?.mode === "cloudflare-tunnel" ? assignedPublic?.mode === "cloudflare-tunnel" && assignedPublic.credential === expectedPublic.credential && assignedPublic.service.protocol === expectedPublic.service.protocol && assignedPublic.service.port === expectedPublic.service.port && assignedPublic.hostname === (expectedPublic.hostname.mode === "static" ? `${expectedPublic.hostname.label}.${expectedPublic.zone}` : `${expectedPublic.hostname.prefix}${expectedPublic.hostname.startAt + assigned.slot}.${expectedPublic.zone}`) : expectedPublic?.mode === "disabled" ? assignedPublic?.mode === "disabled" : assignedPublic === undefined;
8082
- if (JSON.stringify(assignedPrivate) !== JSON.stringify(expectedPrivate) || !publicMatches) {
8173
+ if (!topologyMatches || JSON.stringify(assignedPrivate) !== JSON.stringify(expectedPrivate) || !publicMatches) {
8083
8174
  throw new DeploymentError("PIPELINE_FAILED", "the control-plane connectivity assignment does not match this target slot");
8084
8175
  }
8085
8176
  targetNames = new Set([assigned.targetName]);
@@ -8096,7 +8187,24 @@ function createDeploymentManager(options) {
8096
8187
  FZ_TARGET_CPU_CORES: String(assigned.resources.cpuCores),
8097
8188
  FZ_TARGET_MEMORY_MIB: String(assigned.resources.memoryMiB),
8098
8189
  FZ_TARGET_STORAGE_GIB: String(assigned.resources.storageGiB),
8099
- FZ_TARGET_EXECUTION: assigned.execution
8190
+ FZ_TARGET_EXECUTION: assigned.execution,
8191
+ ...assigned.topology ? {
8192
+ FZ_TOPOLOGY_SITE: assigned.topology.site,
8193
+ FZ_TOPOLOGY_NODE_IDENTITY: assigned.topology.nodeIdentity,
8194
+ FZ_TOPOLOGY_SITE_CIDR: assigned.topology.siteCidr,
8195
+ FZ_TOPOLOGY_ROLE: assigned.topology.role,
8196
+ FZ_TOPOLOGY_TRANSPORT: assigned.topology.transport,
8197
+ FZ_TOPOLOGY_LOCAL_RELAY: assigned.topology.localRelayAddress,
8198
+ FZ_TOPOLOGY_LOCAL_PEERS: assigned.topology.localPeerAddresses.join(","),
8199
+ FZ_TOPOLOGY_LOCAL_PEER_IDENTITIES: assigned.topology.localPeerIdentities.join(","),
8200
+ FZ_TOPOLOGY_REMOTE_RELAYS: assigned.topology.remoteRelayAddresses.join(","),
8201
+ FZ_TOPOLOGY_REMOTE_RELAY_IDENTITIES: assigned.topology.remoteRelayIdentities.join(","),
8202
+ FZ_TOPOLOGY_REMOTE_CIDRS: assigned.topology.remoteSiteCidrs.join(","),
8203
+ FZ_TOPOLOGY_MEMBERS: assigned.topology.memberIdentities.join(","),
8204
+ FZ_TOPOLOGY_HEALTH_PORT: String(assigned.topology.healthPort),
8205
+ FZ_TOPOLOGY_ROUTED_TCP_PORTS: assigned.topology.routedTcpPorts.join(","),
8206
+ FZ_TOPOLOGY_GENERATION: assigned.topology.generation
8207
+ } : {}
8100
8208
  });
8101
8209
  for (const targetName of targetNames) {
8102
8210
  const capacity2 = resolvedTargets.find(({ name }) => name === targetName)?.allocation.resources;
@@ -8152,11 +8260,15 @@ function createDeploymentManager(options) {
8152
8260
  return { changed: true };
8153
8261
  },
8154
8262
  "forgezero.network/ensure@1": async ({ resolved }) => {
8263
+ if (!assigned && bootstrapBundle && typeof resolved.target === "string" && targetNames.has(resolved.target)) {
8264
+ return { changed: false, evidence: { target: resolved.target, authority: "attended-bootstrap" } };
8265
+ }
8155
8266
  if (!assigned || typeof resolved.target !== "string" || resolved.target !== assigned.targetName) {
8156
8267
  throw new DeploymentActionError("refused", "network.ensure must target this exact control-plane assignment");
8157
8268
  }
8158
- if (!assigned.connectivity)
8159
- return { changed: false, evidence: { target: assigned.targetName } };
8269
+ if (!assigned.connectivity) {
8270
+ return { changed: false, evidence: { target: assigned.targetName, authority: "none" } };
8271
+ }
8160
8272
  if (!options.ensureSoftware || !options.applyConnectivity) {
8161
8273
  throw new DeploymentActionError("provider-unavailable", "the supervised connectivity helper is unavailable");
8162
8274
  }
@@ -8165,12 +8277,31 @@ function createDeploymentManager(options) {
8165
8277
  requirements.push({ id: "cloudflared", version: "latest" });
8166
8278
  if (assigned.connectivity.private?.mode === "cloudflare-warp")
8167
8279
  requirements.push({ id: "cloudflare-warp", version: "latest" });
8280
+ if (assigned.topology)
8281
+ requirements.push({ id: "ufw", version: "latest" });
8168
8282
  if (requirements.length > 0)
8169
8283
  await options.ensureSoftware(requirements);
8170
8284
  const evidence = await options.applyConnectivity({
8171
8285
  key: `${options.key}:${assigned.targetName}:${assigned.slot}`,
8172
8286
  intent: assigned.connectivity,
8173
- capabilities: request.connectivityCapabilities ?? {}
8287
+ capabilities: request.connectivityCapabilities ?? {},
8288
+ ...assigned.topology ? { topology: {
8289
+ site: assigned.topology.site,
8290
+ nodeIdentity: assigned.topology.nodeIdentity,
8291
+ siteCidr: assigned.topology.siteCidr,
8292
+ role: assigned.topology.role,
8293
+ transport: assigned.topology.transport,
8294
+ localRelayAddress: assigned.topology.localRelayAddress,
8295
+ localPeerAddresses: assigned.topology.localPeerAddresses,
8296
+ localPeerIdentities: assigned.topology.localPeerIdentities,
8297
+ remoteRelayAddresses: assigned.topology.remoteRelayAddresses,
8298
+ remoteRelayIdentities: assigned.topology.remoteRelayIdentities,
8299
+ remoteSiteCidrs: assigned.topology.remoteSiteCidrs,
8300
+ memberIdentities: assigned.topology.memberIdentities,
8301
+ healthPort: assigned.topology.healthPort,
8302
+ routedTcpPorts: assigned.topology.routedTcpPorts,
8303
+ generation: assigned.topology.generation
8304
+ } } : {}
8174
8305
  });
8175
8306
  return { changed: true, evidence: structuredClone(evidence) };
8176
8307
  },
@@ -8279,7 +8410,13 @@ function createDeploymentManager(options) {
8279
8410
  runtime: assigned.execution,
8280
8411
  cpuCores: assigned.resources.cpuCores,
8281
8412
  memoryMiB: assigned.resources.memoryMiB,
8282
- storageGiB: assigned.resources.storageGiB
8413
+ storageGiB: assigned.resources.storageGiB,
8414
+ ...assigned.topology ? {
8415
+ site: assigned.topology.site,
8416
+ topologyRole: assigned.topology.role,
8417
+ topologyTransport: assigned.topology.transport,
8418
+ localRelayAddress: assigned.topology.localRelayAddress
8419
+ } : {}
8283
8420
  } : {
8284
8421
  targetName: matchingTargetNames[0],
8285
8422
  slot: 0,
@@ -8289,7 +8426,7 @@ function createDeploymentManager(options) {
8289
8426
  memoryMiB: resolvedTargets[0].allocation.resources.memoryMiB,
8290
8427
  storageGiB: resolvedTargets[0].allocation.resources.storageGiB
8291
8428
  },
8292
- shouldRun: (step2) => step2.scope.kind === "release-executor" || step2.scope.kind === "elected-one" ? request.releaseExecutor === true : targetNames.has(step2.scope.target)
8429
+ shouldRun: (step2) => step2.scope.kind === "release-executor" || step2.scope.kind === "elected-one" ? request.releaseExecutor === true : step2.scope.kind === "topology-role" ? targetNames.has(step2.scope.target) && assigned?.topology?.role === step2.scope.role : targetNames.has(step2.scope.target)
8293
8430
  });
8294
8431
  const phase = {
8295
8432
  pipeline: `${plan.name}:deploy`,
@@ -8729,7 +8866,8 @@ async function pullDeploymentOnce(options) {
8729
8866
  labels: { ...target.labels },
8730
8867
  allocation: { ...target.allocation, resources: { ...target.allocation.resources } },
8731
8868
  ...target.provisioning ? { provisioning: { ...target.provisioning } } : {},
8732
- ...target.connectivity ? { connectivity: structuredClone(target.connectivity) } : {}
8869
+ ...target.connectivity ? { connectivity: structuredClone(target.connectivity) } : {},
8870
+ ...target.topology ? { topology: structuredClone(target.topology) } : {}
8733
8871
  })) } : {},
8734
8872
  ...result.capacity ? { capacity: {
8735
8873
  recommendedConcurrency: result.capacity.recommendedConcurrency,
@@ -9193,7 +9331,7 @@ async function writeAndCloseProcessInput(input, value) {
9193
9331
  }
9194
9332
 
9195
9333
  // src/version.ts
9196
- var VERSION2 = "0.1.78";
9334
+ var VERSION2 = "0.1.80";
9197
9335
 
9198
9336
  // src/ssh-bootstrap.ts
9199
9337
  class SshBootstrapError extends Error {
@@ -12838,6 +12976,7 @@ async function activateContainer(requestValue, host = defaultHost2) {
12838
12976
  // src/deployment-connectivity.ts
12839
12977
  import { createHash as createHash11 } from "crypto";
12840
12978
  import { mkdirSync as mkdirSync12, renameSync as renameSync10, writeFileSync as writeFileSync13 } from "fs";
12979
+ import { createConnection as createConnection2 } from "net";
12841
12980
  import { dirname as dirname9 } from "path";
12842
12981
  var TOKEN2 = /^[A-Za-z0-9._-]{40,16384}$/;
12843
12982
  var UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
@@ -12882,6 +13021,21 @@ var defaultHost3 = {
12882
13021
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
12883
13022
  return { exitCode, output: `${stdout}${stderr}` };
12884
13023
  },
13024
+ probe: (address, port, timeoutMs) => new Promise((resolve6) => {
13025
+ const socket = createConnection2({ host: address, port });
13026
+ let settled = false;
13027
+ const finish = (value) => {
13028
+ if (settled)
13029
+ return;
13030
+ settled = true;
13031
+ socket.destroy();
13032
+ resolve6(value);
13033
+ };
13034
+ socket.setTimeout(timeoutMs);
13035
+ socket.once("connect", () => finish(true));
13036
+ socket.once("timeout", () => finish(false));
13037
+ socket.once("error", () => finish(false));
13038
+ }),
12885
13039
  sleep: (ms) => Bun.sleep(ms)
12886
13040
  };
12887
13041
  function validate2(request) {
@@ -12896,7 +13050,7 @@ function validate2(request) {
12896
13050
  } else if (request.capabilities.public)
12897
13051
  throw new Error("unexpected public deployment capability");
12898
13052
  if (privateIntent?.mode === "cloudflare-warp") {
12899
- if (!NAME3.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !TOKEN2.test(request.capabilities.private.connectorToken)) {
13053
+ if (!NAME3.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !UUID.test(request.capabilities.private.siteRouteId) || !TOKEN2.test(request.capabilities.private.connectorToken)) {
12900
13054
  throw new Error("private deployment connectivity is malformed");
12901
13055
  }
12902
13056
  } else if (request.capabilities.private)
@@ -12906,6 +13060,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost3) {
12906
13060
  validate2(request);
12907
13061
  const id2 = idFor3(request.key);
12908
13062
  const evidence = { key: request.key };
13063
+ const topology = request.topology;
13064
+ if (topology) {
13065
+ const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
13066
+ const identities = [
13067
+ topology.nodeIdentity,
13068
+ ...topology.localPeerIdentities,
13069
+ ...topology.remoteRelayIdentities,
13070
+ ...topology.memberIdentities
13071
+ ];
13072
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => !/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
13073
+ throw new Error("deployment topology is malformed");
13074
+ }
13075
+ if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
13076
+ throw new Error("deployment topology transport differs from private connectivity intent");
13077
+ }
13078
+ }
12909
13079
  if (request.intent.public?.mode === "cloudflare-tunnel") {
12910
13080
  const capability = request.capabilities.public;
12911
13081
  const credentialName = `FZ_TUNNEL_${id2.toUpperCase()}`;
@@ -12943,6 +13113,12 @@ WantedBy=multi-user.target
12943
13113
  const capability = request.capabilities.private;
12944
13114
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
12945
13115
  const unit2 = "forgezero-deployment-mesh.service";
13116
+ const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
13117
+ host.write(forwarding, `net.ipv4.ip_forward = 1
13118
+ net.ipv6.conf.all.forwarding = 1
13119
+ net.ipv6.conf.all.accept_ra = 2
13120
+ `, 420);
13121
+ await checked7(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
12946
13122
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
12947
13123
  host.write(`/etc/systemd/system/${unit2}`, `[Unit]
12948
13124
  Description=ForgeZero deployment Mesh/WARP connector
@@ -12977,6 +13153,95 @@ WantedBy=multi-user.target
12977
13153
  if (!evidence.private)
12978
13154
  throw new Error("WARP connector readiness failed");
12979
13155
  }
13156
+ if (topology) {
13157
+ const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
13158
+ const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
13159
+ host.write("/etc/forgezero/deployment-topology.env", [
13160
+ `FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
13161
+ `FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
13162
+ `FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
13163
+ `FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
13164
+ `FZ_TOPOLOGY_SITE=${topology.site}`,
13165
+ `FZ_TOPOLOGY_ROLE=${topology.role}`,
13166
+ `FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
13167
+ ""
13168
+ ].join(`
13169
+ `), 384);
13170
+ const routeUnit = "forgezero-deployment-topology.service";
13171
+ const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
13172
+ const routePairs = topology.role === "member" ? topology.remoteSiteCidrs.map((network) => ({ network, via: topology.localRelayAddress })) : topology.transport === "private-lan" ? topology.remoteSiteCidrs.map((network, index) => ({ network, via: topology.remoteRelayAddresses[index] })) : [];
13173
+ for (const { via } of routePairs) {
13174
+ await checked7(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
13175
+ }
13176
+ await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
13177
+ const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
13178
+ ` : "";
13179
+ const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
13180
+ `);
13181
+ const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
13182
+ `);
13183
+ const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
13184
+ ` : "";
13185
+ host.write(routeUnitPath, `[Unit]
13186
+ Description=ForgeZero fenced deployment site routing
13187
+ After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
13188
+ Wants=network-online.target
13189
+
13190
+ [Service]
13191
+ Type=oneshot
13192
+ RemainAfterExit=yes
13193
+ ${forwarding}${noOp}${starts}${starts ? `
13194
+ ` : ""}${stops}${stops ? `
13195
+ ` : ""}
13196
+ [Install]
13197
+ WantedBy=multi-user.target
13198
+ `, 420);
13199
+ for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
13200
+ for (const port of topology.routedTcpPorts) {
13201
+ await checked7(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
13202
+ }
13203
+ }
13204
+ if (topology.role !== "member")
13205
+ for (const remoteCidr of topology.remoteSiteCidrs) {
13206
+ for (const port of topology.routedTcpPorts) {
13207
+ await checked7(host, [
13208
+ "/usr/sbin/ufw",
13209
+ "route",
13210
+ "allow",
13211
+ "proto",
13212
+ "tcp",
13213
+ "from",
13214
+ topology.siteCidr,
13215
+ "to",
13216
+ remoteCidr,
13217
+ "port",
13218
+ String(port)
13219
+ ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
13220
+ }
13221
+ }
13222
+ await checked7(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
13223
+ await checked7(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
13224
+ const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
13225
+ for (const address of [...new Set(probes)]) {
13226
+ await checked7(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
13227
+ let ready = false;
13228
+ for (let attempt = 0;attempt < 10; attempt += 1) {
13229
+ if (await host.probe(address, topology.healthPort, 2000)) {
13230
+ ready = true;
13231
+ break;
13232
+ }
13233
+ await host.sleep(1000);
13234
+ }
13235
+ if (!ready)
13236
+ throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
13237
+ }
13238
+ evidence.topology = {
13239
+ site: topology.site,
13240
+ siteCidr: topology.siteCidr,
13241
+ role: topology.role,
13242
+ probed: [...new Set(probes)]
13243
+ };
13244
+ }
12980
13245
  return evidence;
12981
13246
  }
12982
13247
 
@@ -16417,6 +16682,115 @@ async function readBoundedStdin(maxBytes) {
16417
16682
  throw new Error(`stdin request exceeds ${maxBytes} bytes`);
16418
16683
  return Buffer.from(stdout).toString("utf8");
16419
16684
  }
16685
+ // src/deployment-topology.ts
16686
+ import { isIP as isIP6 } from "net";
16687
+
16688
+ class DeploymentTopologyError extends Error {
16689
+ constructor(message) {
16690
+ super(message);
16691
+ this.name = "DeploymentTopologyError";
16692
+ }
16693
+ }
16694
+ var SAFE = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;
16695
+ var PRIVATE_V4 = (value) => {
16696
+ const parts = value.split(".").map(Number);
16697
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
16698
+ };
16699
+ var validSiteCidr = (value) => {
16700
+ const match = value.match(/^((?:\d{1,3}\.){3})0\/24$/);
16701
+ return Boolean(match && PRIVATE_V4(`${match[1]}1`));
16702
+ };
16703
+ function planDeploymentTopology(args) {
16704
+ const topology = args.target.topology;
16705
+ if (!topology)
16706
+ throw new DeploymentTopologyError(`target ${args.target.name} has no per-metal topology`);
16707
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(args.target.name) || !/^[A-Za-z0-9._:-]{1,128}$/.test(args.generation)) {
16708
+ throw new DeploymentTopologyError("topology identity is invalid");
16709
+ }
16710
+ if (args.placements.length < 1 || args.placements.length > 1024)
16711
+ throw new DeploymentTopologyError("topology requires 1 to 1024 placements");
16712
+ const references = new Set;
16713
+ const slots = new Set;
16714
+ for (const placement of args.placements) {
16715
+ if (!SAFE.test(placement.computeReference) || !SAFE.test(placement.nodeIdentity) || !SAFE.test(placement.metalHostname) || isIP6(placement.privateAddress) !== 4 || !PRIVATE_V4(placement.privateAddress) || !validSiteCidr(placement.siteCidr) || !placement.privateAddress.startsWith(placement.siteCidr.slice(0, -4)) || !Number.isSafeInteger(placement.slot) || placement.slot < 0 || placement.slot > 1e6 || references.has(placement.computeReference) || slots.has(placement.slot)) {
16716
+ throw new DeploymentTopologyError("topology placement is malformed or duplicated");
16717
+ }
16718
+ references.add(placement.computeReference);
16719
+ slots.add(placement.slot);
16720
+ }
16721
+ const sites = new Map;
16722
+ for (const placement of args.placements) {
16723
+ const members2 = sites.get(placement.metalHostname) ?? [];
16724
+ members2.push({ ...placement });
16725
+ sites.set(placement.metalHostname, members2);
16726
+ }
16727
+ for (const members2 of sites.values())
16728
+ members2.sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference));
16729
+ const siteCidrs = new Map;
16730
+ for (const [site, members2] of sites) {
16731
+ const cidrs = new Set(members2.map(({ siteCidr }) => siteCidr));
16732
+ if (cidrs.size !== 1)
16733
+ throw new DeploymentTopologyError(`metal ${site} has inconsistent private CIDRs`);
16734
+ siteCidrs.set(site, members2[0].siteCidr);
16735
+ }
16736
+ if (new Set(siteCidrs.values()).size !== siteCidrs.size) {
16737
+ throw new DeploymentTopologyError("deployment metals require non-overlapping private CIDRs");
16738
+ }
16739
+ const required = 1 + topology.relays.standbyPerMetal;
16740
+ for (const [site, members2] of sites)
16741
+ if (members2.length < required) {
16742
+ throw new DeploymentTopologyError(`metal ${site} cannot satisfy its relay and standby topology`);
16743
+ }
16744
+ const siteNames = [...sites.keys()].sort();
16745
+ let crossTransport;
16746
+ if (siteNames.length <= 2)
16747
+ crossTransport = "private-lan";
16748
+ else {
16749
+ if (topology.crossMetal.mode === "private-lan") {
16750
+ throw new DeploymentTopologyError("three or more metals require the declared Mesh/WARP authority");
16751
+ }
16752
+ crossTransport = "cloudflare-warp";
16753
+ }
16754
+ const active = new Map(siteNames.map((site) => [site, sites.get(site)[0]]));
16755
+ const memberIdentities = [...args.placements].sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference)).map(({ nodeIdentity }) => nodeIdentity);
16756
+ if (new Set(memberIdentities).size !== memberIdentities.length) {
16757
+ throw new DeploymentTopologyError("deployment node identities must be unique");
16758
+ }
16759
+ if (!Number.isSafeInteger(topology.relays.healthPort) || topology.relays.healthPort < 1 || topology.relays.healthPort > 65535 || !Array.isArray(topology.relays.routedTcpPorts) || topology.relays.routedTcpPorts.length < 1 || topology.relays.routedTcpPorts.length > 64 || new Set(topology.relays.routedTcpPorts).size !== topology.relays.routedTcpPorts.length || !topology.relays.routedTcpPorts.includes(topology.relays.healthPort) || topology.relays.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535)) {
16760
+ throw new DeploymentTopologyError("relay health and routed TCP ports are malformed");
16761
+ }
16762
+ return siteNames.flatMap((site) => {
16763
+ const members2 = sites.get(site);
16764
+ const relay = active.get(site);
16765
+ const remoteRelayAddresses = siteNames.filter((other) => other !== site).map((other) => active.get(other).privateAddress);
16766
+ const remoteRelayIdentities = siteNames.filter((other) => other !== site).map((other) => active.get(other).nodeIdentity);
16767
+ const remoteSiteCidrs = siteNames.filter((other) => other !== site).map((other) => siteCidrs.get(other));
16768
+ return members2.map((placement, index) => {
16769
+ const role = index === 0 ? "relay" : index === 1 && topology.relays.standbyPerMetal === 1 ? "standby" : "member";
16770
+ const crossSite = role !== "member" && siteNames.length > 1;
16771
+ const localPeers = role === "relay" ? members2.filter(({ privateAddress }) => privateAddress !== placement.privateAddress) : [relay];
16772
+ const localPeerAddresses = role === "relay" ? localPeers.map(({ privateAddress }) => privateAddress) : [relay.privateAddress];
16773
+ const localPeerIdentities = role === "relay" ? localPeers.map(({ nodeIdentity }) => nodeIdentity) : [relay.nodeIdentity];
16774
+ return {
16775
+ ...placement,
16776
+ role,
16777
+ transport: crossSite ? crossTransport : "private-lan",
16778
+ localRelayReference: relay.computeReference,
16779
+ localRelayAddress: relay.privateAddress,
16780
+ localPeerAddresses,
16781
+ localPeerIdentities,
16782
+ remoteRelayAddresses: crossSite ? remoteRelayAddresses : [],
16783
+ remoteRelayIdentities: crossSite ? remoteRelayIdentities : [],
16784
+ remoteSiteCidrs: siteNames.length > 1 ? remoteSiteCidrs : [],
16785
+ healthPort: topology.relays.healthPort,
16786
+ routedTcpPorts: [...topology.relays.routedTcpPorts],
16787
+ memberIdentities,
16788
+ warpRequired: crossSite && crossTransport === "cloudflare-warp",
16789
+ generation: args.generation
16790
+ };
16791
+ });
16792
+ });
16793
+ }
16420
16794
 
16421
16795
  // src/index.ts
16422
16796
  function agentCommandArgs(args, command3) {
@@ -17500,6 +17874,7 @@ export {
17500
17874
  projectVaultCoordinate,
17501
17875
  projectVaultCacheKey,
17502
17876
  probeAgentSocket,
17877
+ planDeploymentTopology,
17503
17878
  observeSoftwareHost,
17504
17879
  observeAgentHost,
17505
17880
  normalizeEgressTcpPorts,
@@ -17555,6 +17930,7 @@ export {
17555
17930
  OS_CATALOG,
17556
17931
  METAL_SYSTEMD_CREDENTIALS,
17557
17932
  MAX_AGENT_TARBALL_BYTES,
17933
+ DeploymentTopologyError,
17558
17934
  DeploymentInputError,
17559
17935
  DeploymentError,
17560
17936
  DEFAULT_SOFTWARE_HELPER_SOCKET,