@forgezero/agent 0.1.79 → 0.1.81

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
@@ -6384,7 +6384,7 @@ function reference(value, where) {
6384
6384
  const row2 = object(value, where);
6385
6385
  exact3(row2, ["$ref"], where);
6386
6386
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
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))$/.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)) {
6388
6388
  fail(where, "contains an unsupported reference.");
6389
6389
  }
6390
6390
  return { $ref: coordinate };
@@ -6816,6 +6816,12 @@ function validateStep(value, where, targets, context) {
6816
6816
  if (!targets.has(String(scope.target)))
6817
6817
  fail(`${where}.scope.target`, "must name an existing target.");
6818
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.");
6819
6825
  } else
6820
6826
  fail(`${where}.scope.kind`, "is unsupported.");
6821
6827
  if (row2.timeoutMs !== undefined)
@@ -6970,7 +6976,7 @@ function compileDeployment(sourceValue) {
6970
6976
  const targetDefinitions = new Map;
6971
6977
  for (const [targetName, value] of targetEntries) {
6972
6978
  const target = object(value, `deployment.spec.targets.${targetName}`);
6973
- 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}`);
6974
6980
  if (target.kind !== "compute")
6975
6981
  fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
6976
6982
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -7092,6 +7098,45 @@ function compileDeployment(sourceValue) {
7092
7098
  }
7093
7099
  }
7094
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
+ }
7095
7140
  if (target.placement !== undefined)
7096
7141
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
7097
7142
  targetDefinitions.set(targetName, target);
@@ -7115,6 +7160,9 @@ function compileDeployment(sourceValue) {
7115
7160
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
7116
7161
  fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
7117
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
+ }
7118
7166
  }
7119
7167
  const components = object(spec.components, "deployment.spec.components");
7120
7168
  const componentEntries = Object.entries(components);
@@ -7372,6 +7420,19 @@ function resolveDeploymentTargets(plan, supplied = {}) {
7372
7420
  } : { mode: "disabled" } } : {}
7373
7421
  };
7374
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
+ }
7375
7436
  return {
7376
7437
  name,
7377
7438
  profiles: [...target.selector.profiles],
@@ -7390,7 +7451,8 @@ function resolveDeploymentTargets(plan, supplied = {}) {
7390
7451
  }
7391
7452
  },
7392
7453
  ...target.provisioning ? { provisioning: { ...target.provisioning } } : {},
7393
- ...connectivity ? { connectivity } : {}
7454
+ ...connectivity ? { connectivity } : {},
7455
+ ...topology ? { topology } : {}
7394
7456
  };
7395
7457
  });
7396
7458
  }
@@ -8096,12 +8158,19 @@ function createDeploymentManager(options) {
8096
8158
  if (!selected || mismatches.length > 0) {
8097
8159
  throw new DeploymentError("PIPELINE_FAILED", `the control-plane target assignment does not match this exact deployment plan (${mismatches.join(", ")})`);
8098
8160
  }
8099
- 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;
8100
8169
  const expectedPublic = selected.connectivity?.public;
8101
8170
  const assignedPrivate = assigned.connectivity?.private;
8102
8171
  const assignedPublic = assigned.connectivity?.public;
8103
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;
8104
- if (JSON.stringify(assignedPrivate) !== JSON.stringify(expectedPrivate) || !publicMatches) {
8173
+ if (!topologyMatches || JSON.stringify(assignedPrivate) !== JSON.stringify(expectedPrivate) || !publicMatches) {
8105
8174
  throw new DeploymentError("PIPELINE_FAILED", "the control-plane connectivity assignment does not match this target slot");
8106
8175
  }
8107
8176
  targetNames = new Set([assigned.targetName]);
@@ -8118,7 +8187,24 @@ function createDeploymentManager(options) {
8118
8187
  FZ_TARGET_CPU_CORES: String(assigned.resources.cpuCores),
8119
8188
  FZ_TARGET_MEMORY_MIB: String(assigned.resources.memoryMiB),
8120
8189
  FZ_TARGET_STORAGE_GIB: String(assigned.resources.storageGiB),
8121
- 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
+ } : {}
8122
8208
  });
8123
8209
  for (const targetName of targetNames) {
8124
8210
  const capacity2 = resolvedTargets.find(({ name }) => name === targetName)?.allocation.resources;
@@ -8174,11 +8260,15 @@ function createDeploymentManager(options) {
8174
8260
  return { changed: true };
8175
8261
  },
8176
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
+ }
8177
8266
  if (!assigned || typeof resolved.target !== "string" || resolved.target !== assigned.targetName) {
8178
8267
  throw new DeploymentActionError("refused", "network.ensure must target this exact control-plane assignment");
8179
8268
  }
8180
- if (!assigned.connectivity)
8181
- return { changed: false, evidence: { target: assigned.targetName } };
8269
+ if (!assigned.connectivity) {
8270
+ return { changed: false, evidence: { target: assigned.targetName, authority: "none" } };
8271
+ }
8182
8272
  if (!options.ensureSoftware || !options.applyConnectivity) {
8183
8273
  throw new DeploymentActionError("provider-unavailable", "the supervised connectivity helper is unavailable");
8184
8274
  }
@@ -8187,12 +8277,31 @@ function createDeploymentManager(options) {
8187
8277
  requirements.push({ id: "cloudflared", version: "latest" });
8188
8278
  if (assigned.connectivity.private?.mode === "cloudflare-warp")
8189
8279
  requirements.push({ id: "cloudflare-warp", version: "latest" });
8280
+ if (assigned.topology)
8281
+ requirements.push({ id: "ufw", version: "latest" });
8190
8282
  if (requirements.length > 0)
8191
8283
  await options.ensureSoftware(requirements);
8192
8284
  const evidence = await options.applyConnectivity({
8193
8285
  key: `${options.key}:${assigned.targetName}:${assigned.slot}`,
8194
8286
  intent: assigned.connectivity,
8195
- 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
+ } } : {}
8196
8305
  });
8197
8306
  return { changed: true, evidence: structuredClone(evidence) };
8198
8307
  },
@@ -8301,7 +8410,13 @@ function createDeploymentManager(options) {
8301
8410
  runtime: assigned.execution,
8302
8411
  cpuCores: assigned.resources.cpuCores,
8303
8412
  memoryMiB: assigned.resources.memoryMiB,
8304
- 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
+ } : {}
8305
8420
  } : {
8306
8421
  targetName: matchingTargetNames[0],
8307
8422
  slot: 0,
@@ -8311,7 +8426,7 @@ function createDeploymentManager(options) {
8311
8426
  memoryMiB: resolvedTargets[0].allocation.resources.memoryMiB,
8312
8427
  storageGiB: resolvedTargets[0].allocation.resources.storageGiB
8313
8428
  },
8314
- 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)
8315
8430
  });
8316
8431
  const phase = {
8317
8432
  pipeline: `${plan.name}:deploy`,
@@ -8751,7 +8866,8 @@ async function pullDeploymentOnce(options) {
8751
8866
  labels: { ...target.labels },
8752
8867
  allocation: { ...target.allocation, resources: { ...target.allocation.resources } },
8753
8868
  ...target.provisioning ? { provisioning: { ...target.provisioning } } : {},
8754
- ...target.connectivity ? { connectivity: structuredClone(target.connectivity) } : {}
8869
+ ...target.connectivity ? { connectivity: structuredClone(target.connectivity) } : {},
8870
+ ...target.topology ? { topology: structuredClone(target.topology) } : {}
8755
8871
  })) } : {},
8756
8872
  ...result.capacity ? { capacity: {
8757
8873
  recommendedConcurrency: result.capacity.recommendedConcurrency,
@@ -9215,7 +9331,7 @@ async function writeAndCloseProcessInput(input, value) {
9215
9331
  }
9216
9332
 
9217
9333
  // src/version.ts
9218
- var VERSION2 = "0.1.79";
9334
+ var VERSION2 = "0.1.81";
9219
9335
 
9220
9336
  // src/ssh-bootstrap.ts
9221
9337
  class SshBootstrapError extends Error {
@@ -10092,6 +10208,32 @@ function membersOfLinuxList(value, label) {
10092
10208
  throw new MetalProvisionError(`${label} list overlaps itself`);
10093
10209
  return members;
10094
10210
  }
10211
+ function linuxList(members) {
10212
+ const sorted = [...new Set(members)].sort((left, right) => left - right);
10213
+ const ranges = [];
10214
+ for (let index = 0;index < sorted.length; ) {
10215
+ const start = sorted[index];
10216
+ let end = start;
10217
+ while (index + 1 < sorted.length && sorted[index + 1] === end + 1) {
10218
+ index += 1;
10219
+ end = sorted[index];
10220
+ }
10221
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
10222
+ index += 1;
10223
+ }
10224
+ return ranges.join(",");
10225
+ }
10226
+ function physicalCoreGroups(pool) {
10227
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
10228
+ if (cpus.length % pool.physicalCores !== 0) {
10229
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
10230
+ }
10231
+ const threadsPerCore = cpus.length / pool.physicalCores;
10232
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
10233
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
10234
+ }
10235
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
10236
+ }
10095
10237
  var guestNameFor = (computeKey) => `fzg-${createHash7("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
10096
10238
  var tapNameFor = (computeKey) => `fzt${createHash7("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
10097
10239
  var macForAddress = (address) => {
@@ -10149,6 +10291,7 @@ function validateMetalProfile(profile) {
10149
10291
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
10150
10292
  throw new MetalProvisionError("invalid CPU pool physical-core count");
10151
10293
  }
10294
+ physicalCoreGroups(pool);
10152
10295
  for (const cpu of cpus) {
10153
10296
  if (assigned.has(cpu))
10154
10297
  throw new MetalProvisionError("CPU pools overlap");
@@ -10218,26 +10361,38 @@ function allocateCpuPool(profile, claim, rows) {
10218
10361
  const prior = rows.find((row2) => row2.computeKey === claim.computeKey);
10219
10362
  if (prior) {
10220
10363
  const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
10221
- if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
10364
+ const groups2 = retained ? physicalCoreGroups(retained) : [];
10365
+ const retainedCpus = new Set(membersOfLinuxList(prior.allowedCpus, "persisted guest CPU"));
10366
+ const matchedGroups = groups2.filter((group) => group.every((cpu) => retainedCpus.has(cpu)));
10367
+ if (!retained || retained.memoryNodes !== prior.allowedMemoryNodes || matchedGroups.length !== claim.spec.physicalCores || matchedGroups.flat().length !== retainedCpus.size || retainedCpus.size < claim.spec.vcpu || claim.spec.cpuPoolKey !== undefined && claim.spec.cpuPoolKey !== retained.key) {
10222
10368
  throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
10223
10369
  }
10224
- return retained;
10225
- }
10226
- const used = new Set(rows.map((row2) => row2.cpuPoolKey));
10227
- if (claim.spec.cpuPoolKey) {
10228
- const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
10229
- if (!requested || used.has(requested.key)) {
10230
- throw new MetalProvisionError("requested CPU pool is unavailable");
10231
- }
10232
- if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
10233
- throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
10234
- return requested;
10370
+ return {
10371
+ key: retained.key,
10372
+ cpus: prior.allowedCpus,
10373
+ physicalCores: matchedGroups.length,
10374
+ memoryNodes: prior.allowedMemoryNodes
10375
+ };
10235
10376
  }
10236
- const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
10377
+ const usedCpus = new Set(rows.flatMap((row2) => membersOfLinuxList(row2.allowedCpus, "persisted guest CPU")));
10378
+ const candidates = profile.cpuPools.filter((pool) => !claim.spec.cpuPoolKey || pool.key === claim.spec.cpuPoolKey).map((pool) => ({
10379
+ pool,
10380
+ available: physicalCoreGroups(pool).filter((group) => group.every((cpu) => !usedCpus.has(cpu)))
10381
+ })).filter(({ available }) => {
10382
+ const selected2 = available.slice(0, claim.spec.physicalCores);
10383
+ return selected2.length === claim.spec.physicalCores && selected2.flat().length >= claim.spec.vcpu;
10384
+ }).sort((left, right) => left.available.length - right.available.length || left.pool.key.localeCompare(right.pool.key));
10237
10385
  const selected = candidates[0];
10238
- if (!selected)
10239
- throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
10240
- return selected;
10386
+ if (!selected) {
10387
+ throw new MetalProvisionError(claim.spec.cpuPoolKey ? "requested CPU pool cannot satisfy this guest" : "no exclusive CPU capacity can satisfy this guest");
10388
+ }
10389
+ const groups = selected.available.slice(0, claim.spec.physicalCores);
10390
+ return {
10391
+ key: selected.pool.key,
10392
+ cpus: linuxList(groups.flat()),
10393
+ physicalCores: groups.length,
10394
+ memoryNodes: selected.pool.memoryNodes
10395
+ };
10241
10396
  }
10242
10397
  var base64 = (value) => Buffer.from(value).toString("base64");
10243
10398
  var yamlFile = (path2, content, permissions) => ` - path: ${JSON.stringify(path2)}
@@ -12860,6 +13015,7 @@ async function activateContainer(requestValue, host = defaultHost2) {
12860
13015
  // src/deployment-connectivity.ts
12861
13016
  import { createHash as createHash11 } from "crypto";
12862
13017
  import { mkdirSync as mkdirSync12, renameSync as renameSync10, writeFileSync as writeFileSync13 } from "fs";
13018
+ import { createConnection as createConnection2 } from "net";
12863
13019
  import { dirname as dirname9 } from "path";
12864
13020
  var TOKEN2 = /^[A-Za-z0-9._-]{40,16384}$/;
12865
13021
  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;
@@ -12904,6 +13060,21 @@ var defaultHost3 = {
12904
13060
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
12905
13061
  return { exitCode, output: `${stdout}${stderr}` };
12906
13062
  },
13063
+ probe: (address, port, timeoutMs) => new Promise((resolve6) => {
13064
+ const socket = createConnection2({ host: address, port });
13065
+ let settled = false;
13066
+ const finish = (value) => {
13067
+ if (settled)
13068
+ return;
13069
+ settled = true;
13070
+ socket.destroy();
13071
+ resolve6(value);
13072
+ };
13073
+ socket.setTimeout(timeoutMs);
13074
+ socket.once("connect", () => finish(true));
13075
+ socket.once("timeout", () => finish(false));
13076
+ socket.once("error", () => finish(false));
13077
+ }),
12907
13078
  sleep: (ms) => Bun.sleep(ms)
12908
13079
  };
12909
13080
  function validate2(request) {
@@ -12918,7 +13089,7 @@ function validate2(request) {
12918
13089
  } else if (request.capabilities.public)
12919
13090
  throw new Error("unexpected public deployment capability");
12920
13091
  if (privateIntent?.mode === "cloudflare-warp") {
12921
- 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)) {
13092
+ 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)) {
12922
13093
  throw new Error("private deployment connectivity is malformed");
12923
13094
  }
12924
13095
  } else if (request.capabilities.private)
@@ -12928,6 +13099,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost3) {
12928
13099
  validate2(request);
12929
13100
  const id2 = idFor3(request.key);
12930
13101
  const evidence = { key: request.key };
13102
+ const topology = request.topology;
13103
+ if (topology) {
13104
+ const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
13105
+ const identities = [
13106
+ topology.nodeIdentity,
13107
+ ...topology.localPeerIdentities,
13108
+ ...topology.remoteRelayIdentities,
13109
+ ...topology.memberIdentities
13110
+ ];
13111
+ 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") {
13112
+ throw new Error("deployment topology is malformed");
13113
+ }
13114
+ if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
13115
+ throw new Error("deployment topology transport differs from private connectivity intent");
13116
+ }
13117
+ }
12931
13118
  if (request.intent.public?.mode === "cloudflare-tunnel") {
12932
13119
  const capability = request.capabilities.public;
12933
13120
  const credentialName = `FZ_TUNNEL_${id2.toUpperCase()}`;
@@ -12965,6 +13152,12 @@ WantedBy=multi-user.target
12965
13152
  const capability = request.capabilities.private;
12966
13153
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
12967
13154
  const unit2 = "forgezero-deployment-mesh.service";
13155
+ const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
13156
+ host.write(forwarding, `net.ipv4.ip_forward = 1
13157
+ net.ipv6.conf.all.forwarding = 1
13158
+ net.ipv6.conf.all.accept_ra = 2
13159
+ `, 420);
13160
+ await checked7(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
12968
13161
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
12969
13162
  host.write(`/etc/systemd/system/${unit2}`, `[Unit]
12970
13163
  Description=ForgeZero deployment Mesh/WARP connector
@@ -12999,6 +13192,95 @@ WantedBy=multi-user.target
12999
13192
  if (!evidence.private)
13000
13193
  throw new Error("WARP connector readiness failed");
13001
13194
  }
13195
+ if (topology) {
13196
+ const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
13197
+ const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
13198
+ host.write("/etc/forgezero/deployment-topology.env", [
13199
+ `FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
13200
+ `FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
13201
+ `FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
13202
+ `FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
13203
+ `FZ_TOPOLOGY_SITE=${topology.site}`,
13204
+ `FZ_TOPOLOGY_ROLE=${topology.role}`,
13205
+ `FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
13206
+ ""
13207
+ ].join(`
13208
+ `), 384);
13209
+ const routeUnit = "forgezero-deployment-topology.service";
13210
+ const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
13211
+ 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] })) : [];
13212
+ for (const { via } of routePairs) {
13213
+ await checked7(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
13214
+ }
13215
+ await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
13216
+ const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
13217
+ ` : "";
13218
+ const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
13219
+ `);
13220
+ const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
13221
+ `);
13222
+ const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
13223
+ ` : "";
13224
+ host.write(routeUnitPath, `[Unit]
13225
+ Description=ForgeZero fenced deployment site routing
13226
+ After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
13227
+ Wants=network-online.target
13228
+
13229
+ [Service]
13230
+ Type=oneshot
13231
+ RemainAfterExit=yes
13232
+ ${forwarding}${noOp}${starts}${starts ? `
13233
+ ` : ""}${stops}${stops ? `
13234
+ ` : ""}
13235
+ [Install]
13236
+ WantedBy=multi-user.target
13237
+ `, 420);
13238
+ for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
13239
+ for (const port of topology.routedTcpPorts) {
13240
+ await checked7(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
13241
+ }
13242
+ }
13243
+ if (topology.role !== "member")
13244
+ for (const remoteCidr of topology.remoteSiteCidrs) {
13245
+ for (const port of topology.routedTcpPorts) {
13246
+ await checked7(host, [
13247
+ "/usr/sbin/ufw",
13248
+ "route",
13249
+ "allow",
13250
+ "proto",
13251
+ "tcp",
13252
+ "from",
13253
+ topology.siteCidr,
13254
+ "to",
13255
+ remoteCidr,
13256
+ "port",
13257
+ String(port)
13258
+ ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
13259
+ }
13260
+ }
13261
+ await checked7(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
13262
+ await checked7(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
13263
+ const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
13264
+ for (const address of [...new Set(probes)]) {
13265
+ await checked7(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
13266
+ let ready = false;
13267
+ for (let attempt = 0;attempt < 10; attempt += 1) {
13268
+ if (await host.probe(address, topology.healthPort, 2000)) {
13269
+ ready = true;
13270
+ break;
13271
+ }
13272
+ await host.sleep(1000);
13273
+ }
13274
+ if (!ready)
13275
+ throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
13276
+ }
13277
+ evidence.topology = {
13278
+ site: topology.site,
13279
+ siteCidr: topology.siteCidr,
13280
+ role: topology.role,
13281
+ probed: [...new Set(probes)]
13282
+ };
13283
+ }
13002
13284
  return evidence;
13003
13285
  }
13004
13286
 
@@ -16439,6 +16721,115 @@ async function readBoundedStdin(maxBytes) {
16439
16721
  throw new Error(`stdin request exceeds ${maxBytes} bytes`);
16440
16722
  return Buffer.from(stdout).toString("utf8");
16441
16723
  }
16724
+ // src/deployment-topology.ts
16725
+ import { isIP as isIP6 } from "net";
16726
+
16727
+ class DeploymentTopologyError extends Error {
16728
+ constructor(message) {
16729
+ super(message);
16730
+ this.name = "DeploymentTopologyError";
16731
+ }
16732
+ }
16733
+ var SAFE = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;
16734
+ var PRIVATE_V4 = (value) => {
16735
+ const parts = value.split(".").map(Number);
16736
+ 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);
16737
+ };
16738
+ var validSiteCidr = (value) => {
16739
+ const match = value.match(/^((?:\d{1,3}\.){3})0\/24$/);
16740
+ return Boolean(match && PRIVATE_V4(`${match[1]}1`));
16741
+ };
16742
+ function planDeploymentTopology(args) {
16743
+ const topology = args.target.topology;
16744
+ if (!topology)
16745
+ throw new DeploymentTopologyError(`target ${args.target.name} has no per-metal topology`);
16746
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(args.target.name) || !/^[A-Za-z0-9._:-]{1,128}$/.test(args.generation)) {
16747
+ throw new DeploymentTopologyError("topology identity is invalid");
16748
+ }
16749
+ if (args.placements.length < 1 || args.placements.length > 1024)
16750
+ throw new DeploymentTopologyError("topology requires 1 to 1024 placements");
16751
+ const references = new Set;
16752
+ const slots = new Set;
16753
+ for (const placement of args.placements) {
16754
+ 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)) {
16755
+ throw new DeploymentTopologyError("topology placement is malformed or duplicated");
16756
+ }
16757
+ references.add(placement.computeReference);
16758
+ slots.add(placement.slot);
16759
+ }
16760
+ const sites = new Map;
16761
+ for (const placement of args.placements) {
16762
+ const members2 = sites.get(placement.metalHostname) ?? [];
16763
+ members2.push({ ...placement });
16764
+ sites.set(placement.metalHostname, members2);
16765
+ }
16766
+ for (const members2 of sites.values())
16767
+ members2.sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference));
16768
+ const siteCidrs = new Map;
16769
+ for (const [site, members2] of sites) {
16770
+ const cidrs = new Set(members2.map(({ siteCidr }) => siteCidr));
16771
+ if (cidrs.size !== 1)
16772
+ throw new DeploymentTopologyError(`metal ${site} has inconsistent private CIDRs`);
16773
+ siteCidrs.set(site, members2[0].siteCidr);
16774
+ }
16775
+ if (new Set(siteCidrs.values()).size !== siteCidrs.size) {
16776
+ throw new DeploymentTopologyError("deployment metals require non-overlapping private CIDRs");
16777
+ }
16778
+ const required = 1 + topology.relays.standbyPerMetal;
16779
+ for (const [site, members2] of sites)
16780
+ if (members2.length < required) {
16781
+ throw new DeploymentTopologyError(`metal ${site} cannot satisfy its relay and standby topology`);
16782
+ }
16783
+ const siteNames = [...sites.keys()].sort();
16784
+ let crossTransport;
16785
+ if (siteNames.length <= 2)
16786
+ crossTransport = "private-lan";
16787
+ else {
16788
+ if (topology.crossMetal.mode === "private-lan") {
16789
+ throw new DeploymentTopologyError("three or more metals require the declared Mesh/WARP authority");
16790
+ }
16791
+ crossTransport = "cloudflare-warp";
16792
+ }
16793
+ const active = new Map(siteNames.map((site) => [site, sites.get(site)[0]]));
16794
+ const memberIdentities = [...args.placements].sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference)).map(({ nodeIdentity }) => nodeIdentity);
16795
+ if (new Set(memberIdentities).size !== memberIdentities.length) {
16796
+ throw new DeploymentTopologyError("deployment node identities must be unique");
16797
+ }
16798
+ 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)) {
16799
+ throw new DeploymentTopologyError("relay health and routed TCP ports are malformed");
16800
+ }
16801
+ return siteNames.flatMap((site) => {
16802
+ const members2 = sites.get(site);
16803
+ const relay = active.get(site);
16804
+ const remoteRelayAddresses = siteNames.filter((other) => other !== site).map((other) => active.get(other).privateAddress);
16805
+ const remoteRelayIdentities = siteNames.filter((other) => other !== site).map((other) => active.get(other).nodeIdentity);
16806
+ const remoteSiteCidrs = siteNames.filter((other) => other !== site).map((other) => siteCidrs.get(other));
16807
+ return members2.map((placement, index) => {
16808
+ const role = index === 0 ? "relay" : index === 1 && topology.relays.standbyPerMetal === 1 ? "standby" : "member";
16809
+ const crossSite = role !== "member" && siteNames.length > 1;
16810
+ const localPeers = role === "relay" ? members2.filter(({ privateAddress }) => privateAddress !== placement.privateAddress) : [relay];
16811
+ const localPeerAddresses = role === "relay" ? localPeers.map(({ privateAddress }) => privateAddress) : [relay.privateAddress];
16812
+ const localPeerIdentities = role === "relay" ? localPeers.map(({ nodeIdentity }) => nodeIdentity) : [relay.nodeIdentity];
16813
+ return {
16814
+ ...placement,
16815
+ role,
16816
+ transport: crossSite ? crossTransport : "private-lan",
16817
+ localRelayReference: relay.computeReference,
16818
+ localRelayAddress: relay.privateAddress,
16819
+ localPeerAddresses,
16820
+ localPeerIdentities,
16821
+ remoteRelayAddresses: crossSite ? remoteRelayAddresses : [],
16822
+ remoteRelayIdentities: crossSite ? remoteRelayIdentities : [],
16823
+ remoteSiteCidrs: siteNames.length > 1 ? remoteSiteCidrs : [],
16824
+ healthPort: topology.relays.healthPort,
16825
+ routedTcpPorts: [...topology.relays.routedTcpPorts],
16826
+ memberIdentities,
16827
+ warpRequired: crossSite && crossTransport === "cloudflare-warp",
16828
+ generation: args.generation
16829
+ };
16830
+ });
16831
+ });
16832
+ }
16442
16833
 
16443
16834
  // src/index.ts
16444
16835
  function agentCommandArgs(args, command3) {
@@ -17522,6 +17913,7 @@ export {
17522
17913
  projectVaultCoordinate,
17523
17914
  projectVaultCacheKey,
17524
17915
  probeAgentSocket,
17916
+ planDeploymentTopology,
17525
17917
  observeSoftwareHost,
17526
17918
  observeAgentHost,
17527
17919
  normalizeEgressTcpPorts,
@@ -17577,6 +17969,7 @@ export {
17577
17969
  OS_CATALOG,
17578
17970
  METAL_SYSTEMD_CREDENTIALS,
17579
17971
  MAX_AGENT_TARBALL_BYTES,
17972
+ DeploymentTopologyError,
17580
17973
  DeploymentInputError,
17581
17974
  DeploymentError,
17582
17975
  DEFAULT_SOFTWARE_HELPER_SOCKET,