@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/README.md CHANGED
@@ -64,7 +64,8 @@ Every row links to the detailed explanation and named-import/example area below.
64
64
  | @forgezero/agent/definition | Legacy version 3 JSON deployment compatibility contract. | Bun/Node host | [Reference + usage](#forgezero-agent-definition) |
65
65
  | @forgezero/agent/deploy | Typed deployment authoring builders, built-in provider requirements and versioned action constructors. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy) |
66
66
  | @forgezero/agent/deployment-targets | Bounded input resolution and stable compute-target assignment for typed plans; emits requirements and slots rather than physical hostnames. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-targets) |
67
- | @forgezero/agent/deployment-connectivity | Root-owned application of per-target Cloudflare Tunnel or WARP connector capabilities without exposing the project management token to a compute. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-connectivity) |
67
+ | @forgezero/agent/deployment-connectivity | Root-owned convergence of exact private routes, routed-port firewall rules and per-target Cloudflare Tunnel or Mesh connector capabilities without exposing the project management token to a compute. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-connectivity) |
68
+ | @forgezero/agent/deployment-topology | Pure per-Metal relay election and hierarchical peer assignment: one or two Metals stay direct-private, while three or more grant Mesh/WARP only to relay and standby. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-topology) |
68
69
  | @forgezero/agent/deploy-plan | Runtime validation, canonicalization and semantic digest for inert deployment plans. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-plan) |
69
70
  | @forgezero/agent/deploy-plan-runner | Conditional DAG execution with bounded retry, compensation and failure policy over injected action handlers. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-plan-runner) |
70
71
  | @forgezero/agent/deploy-compiler | Explicit developer/CI compiler from forgezero.deploy.ts to canonical .fz/deploy.plan.json; never used to execute project TypeScript in production. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-compiler) |
@@ -1067,7 +1068,7 @@ export const selectedCapability = DEPLOY_SCHEMA_URL;
1067
1068
  <a id="forgezero-agent-deploy"></a>
1068
1069
  ## @forgezero/agent/deploy
1069
1070
 
1070
- Typed deployment authoring builders, built-in provider requirements and versioned action constructors. This entry exposes 14 named value exports and 41 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1071
+ Typed deployment authoring builders, built-in provider requirements and versioned action constructors. This entry exposes 14 named value exports and 42 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1071
1072
 
1072
1073
  ```text
1073
1074
  import {
@@ -1096,6 +1097,7 @@ import type {
1096
1097
  ComponentDefinition,
1097
1098
  ComputeConnectivityDefinition,
1098
1099
  ComputePoolDefinition,
1100
+ ComputeTopologyDefinition,
1099
1101
  Condition,
1100
1102
  ConditionOperand,
1101
1103
  ContainerdProviderConfig,
@@ -1134,7 +1136,7 @@ import type {
1134
1136
 
1135
1137
  ## @forgezero/agent/deploy — Author a typed, placed containerd deployment
1136
1138
 
1137
- TypeScript is authoring only. The checked-in canonical plan is what Agents validate and execute. Targets declare bounded resource/cardinality intent, not hostnames. The API reserves eligible project capacity or provisions only under the reviewed plan and spend ceiling, assigns stable slots, and the Agent runs only its exact assignment. runc containers must declare hard CPU, memory and PID limits, bounded storage, loopback ingress and a health-gated rollout; select Kata QEMU SNP when the workload needs host isolation.
1139
+ TypeScript is authoring only. The checked-in canonical plan is what Agents validate and execute. Targets declare bounded resource/cardinality intent, not hostnames. The API reserves eligible project capacity or provisions only under the reviewed plan and spend ceiling, assigns stable slots, and the Agent runs only its exact assignment. An optional per-metal topology elects one active relay and one standby without naming computes: one or two Metals use proven private relay links and never install WARP, while three or more give Mesh/WARP authority only to relay and standby. runc containers must declare hard CPU, memory and PID limits, bounded storage, loopback ingress and a health-gated rollout; select Kata QEMU SNP when the workload needs host isolation.
1138
1140
 
1139
1141
  ```text
1140
1142
  import {
@@ -1174,7 +1176,23 @@ export default defineDeployment({
1174
1176
  connectivity: {
1175
1177
  private: { mode: 'private-lan' },
1176
1178
  public: { mode: 'cloudflare-tunnel', credential: 'cloudflare', zone: 'example.com', hostname: { mode: 'indexed', prefix: 'api' } }
1177
- }
1179
+ },
1180
+ topology: {
1181
+ groupBy: 'metal',
1182
+ relays: {
1183
+ activePerMetal: 1,
1184
+ standbyPerMetal: 1,
1185
+ healthPort: 3000,
1186
+ routedTcpPorts: [3000]
1187
+ },
1188
+ crossMetal: {
1189
+ mode: 'auto',
1190
+ directPrivateMaximumMetals: 2,
1191
+ credential: 'cloudflare',
1192
+ network: 'orders-api'
1193
+ }
1194
+ },
1195
+ placement: { spreadBy: ['metal'], antiAffinity: ['orders-api'] }
1178
1196
  }) },
1179
1197
  requirements: {
1180
1198
  containerd: providers.containerd.require(), nginx: providers.nginx.require(),
@@ -1237,7 +1255,7 @@ export const selectedCapability = DeploymentInputError;
1237
1255
  <a id="forgezero-agent-deployment-connectivity"></a>
1238
1256
  ## @forgezero/agent/deployment-connectivity
1239
1257
 
1240
- Root-owned application of per-target Cloudflare Tunnel or WARP connector capabilities without exposing the project management token to a compute. This entry exposes 1 named value export and 5 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1258
+ Root-owned convergence of exact private routes, routed-port firewall rules and per-target Cloudflare Tunnel or Mesh connector capabilities without exposing the project management token to a compute. This entry exposes 1 named value export and 6 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1241
1259
 
1242
1260
  ```text
1243
1261
  import {
@@ -1250,6 +1268,7 @@ import type {
1250
1268
  DeploymentConnectivityHost,
1251
1269
  DeploymentConnectivityIntent,
1252
1270
  DeploymentConnectivityRequest,
1271
+ DeploymentConnectivityTopology,
1253
1272
  } from '@forgezero/agent/deployment-connectivity';
1254
1273
  ```
1255
1274
 
@@ -1265,6 +1284,37 @@ import {
1265
1284
  export const selectedCapability = applyDeploymentConnectivity;
1266
1285
  ```
1267
1286
 
1287
+ <a id="forgezero-agent-deployment-topology"></a>
1288
+ ## @forgezero/agent/deployment-topology
1289
+
1290
+ Pure per-Metal relay election and hierarchical peer assignment: one or two Metals stay direct-private, while three or more grant Mesh/WARP only to relay and standby. This entry exposes 2 named value exports and 4 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1291
+
1292
+ ```text
1293
+ import {
1294
+ DeploymentTopologyError,
1295
+ planDeploymentTopology,
1296
+ } from '@forgezero/agent/deployment-topology';
1297
+
1298
+ import type {
1299
+ DeploymentTopologyAssignment,
1300
+ DeploymentTopologyPlacement,
1301
+ DeploymentTopologyRole,
1302
+ DeploymentTopologyTransport,
1303
+ } from '@forgezero/agent/deployment-topology';
1304
+ ```
1305
+
1306
+ ## @forgezero/agent/deployment-topology — Use this entry point
1307
+
1308
+ This minimal executable use imports one concrete value from this exact entry point without a wildcard or package-root detour. Keep the value or values the application actually needs; the full named inventory remains directly above it.
1309
+
1310
+ ```text
1311
+ import {
1312
+ DeploymentTopologyError,
1313
+ } from '@forgezero/agent/deployment-topology';
1314
+
1315
+ export const selectedCapability = DeploymentTopologyError;
1316
+ ```
1317
+
1268
1318
  <a id="forgezero-agent-deploy-plan"></a>
1269
1319
  ## @forgezero/agent/deploy-plan
1270
1320
 
@@ -1645,7 +1695,7 @@ if (!validate(deploymentDefinition)) throw new Error(JSON.stringify(validate.err
1645
1695
  <a id="forgezero-agent-software"></a>
1646
1696
  ## @forgezero/agent/software
1647
1697
 
1648
- Exact managed-software inventory, requirement and evidence contracts. This entry exposes 13 named value exports and 10 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1698
+ Exact managed-software inventory, requirement and evidence contracts. This entry exposes 14 named value exports and 10 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1649
1699
 
1650
1700
  ```text
1651
1701
  import {
@@ -1660,6 +1710,7 @@ import {
1660
1710
  executeSoftwareOperation,
1661
1711
  observeSoftwareHost,
1662
1712
  runSoftwareCommand,
1713
+ softwareDownloadCommand,
1663
1714
  softwareSpawnFailure,
1664
1715
  validateSoftwareRequirements,
1665
1716
  } from '@forgezero/agent/software';
@@ -750,7 +750,7 @@ async function postSignedNode(options, path, body) {
750
750
  }
751
751
 
752
752
  // src/version.ts
753
- var VERSION3 = "0.1.78";
753
+ var VERSION3 = "0.1.80";
754
754
 
755
755
  // src/agent-heartbeat.ts
756
756
  function readAgentHostMetrics() {
package/dist/bootstrap.js CHANGED
@@ -1416,7 +1416,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1416
1416
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1417
1417
 
1418
1418
  // src/version.ts
1419
- var VERSION = "0.1.78";
1419
+ var VERSION = "0.1.80";
1420
1420
 
1421
1421
  // src/software.ts
1422
1422
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3089,7 +3089,14 @@ function validatePlatformSharedEnvironment(input) {
3089
3089
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3090
3090
  }
3091
3091
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3092
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
3092
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
3093
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
3094
+ }
3095
+ for (const member of input.seedSyncMembers)
3096
+ safeAtom("seedSyncMembers item", member);
3097
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
3098
+ throw new Error("seedSyncMembers must include this nodeHostname.");
3099
+ }
3093
3100
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
3094
3101
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
3095
3102
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -3169,7 +3176,7 @@ function renderPlatformSharedEnvironment(input) {
3169
3176
  FZ_DB_MASTER: value.databaseMaster ?? "",
3170
3177
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
3171
3178
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
3172
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
3179
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
3173
3180
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
3174
3181
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
3175
3182
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -3238,7 +3245,12 @@ function platformApiCredentialSpecs(options) {
3238
3245
  ];
3239
3246
  }
3240
3247
  function renderPlatformApiUnits(input) {
3241
- for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
3248
+ for (const path of [
3249
+ input.sharedDirectory,
3250
+ input.sharedEnvironmentFile,
3251
+ input.slotsDirectory,
3252
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
3253
+ ]) {
3242
3254
  if (!path.startsWith("/") || /[\r\n]/.test(path))
3243
3255
  throw new Error("Runtime paths must be absolute and single-line.");
3244
3256
  }
@@ -3252,6 +3264,8 @@ function renderPlatformApiUnits(input) {
3252
3264
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
3253
3265
  `);
3254
3266
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
3267
+ ` : "";
3268
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
3255
3269
  ` : "";
3256
3270
  const template = `[Unit]
3257
3271
  Description=ForgeZero (%i slot)
@@ -3265,7 +3279,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
3265
3279
  Environment=NODE_ENV=production
3266
3280
  Environment=FZ_SLOT=%i
3267
3281
  EnvironmentFile=${input.sharedEnvironmentFile}
3268
- ${capacityEnvironment}${credentials}
3282
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
3269
3283
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
3270
3284
  Restart=always
3271
3285
  RestartSec=2
@@ -4517,7 +4531,8 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4517
4531
  greenPort: runtime.greenPort,
4518
4532
  collectorUnit: runtime.environment.otlpCollectorUnit,
4519
4533
  credentials,
4520
- capacityEnvironmentFile: "/etc/forgezero/capacity.env"
4534
+ capacityEnvironmentFile: "/etc/forgezero/capacity.env",
4535
+ topologyEnvironmentFile: "/etc/forgezero/deployment-topology.env"
4521
4536
  });
4522
4537
  const edge = renderPlatformNginx({
4523
4538
  publicPort: runtime.environment.publicApiPort,
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
165
165
  return { exitCode, output: `${stdout}${stderr}` };
166
166
  };
167
167
  var run = runSoftwareCommand;
168
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
169
+ const source = new URL(url);
170
+ if (source.protocol !== "https:" || source.username || source.password)
171
+ throw new Error("software download must use credential-free HTTPS");
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
173
+ throw new Error("software download size bound is invalid");
174
+ return [
175
+ "/usr/bin/curl",
176
+ "--fail",
177
+ "--location",
178
+ "--silent",
179
+ "--show-error",
180
+ "--proto",
181
+ "=https",
182
+ "--tlsv1.2",
183
+ "--max-time",
184
+ "1800",
185
+ "--max-filesize",
186
+ String(maximumBytes),
187
+ "--output",
188
+ destination,
189
+ source.href
190
+ ];
191
+ };
168
192
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
169
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
170
- if (!response.ok)
171
- throw new Error(`download failed with HTTP ${response.status}`);
172
- const declared = Number(response.headers.get("content-length") ?? 0);
173
- if (declared > maximumBytes)
174
- throw new Error("download exceeds reviewed size bound");
175
193
  if (existsSync(destination))
176
194
  throw new Error("download destination already exists");
177
- await Bun.write(destination, response);
195
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
196
+ if (received.exitCode !== 0) {
197
+ rmSync(destination, { force: true });
198
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
199
+ }
178
200
  chmodSync(destination, 384);
179
201
  if (statSync(destination).size > maximumBytes) {
180
202
  rmSync(destination, { force: true });
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
165
165
  return { exitCode, output: `${stdout}${stderr}` };
166
166
  };
167
167
  var run = runSoftwareCommand;
168
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
169
+ const source = new URL(url);
170
+ if (source.protocol !== "https:" || source.username || source.password)
171
+ throw new Error("software download must use credential-free HTTPS");
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
173
+ throw new Error("software download size bound is invalid");
174
+ return [
175
+ "/usr/bin/curl",
176
+ "--fail",
177
+ "--location",
178
+ "--silent",
179
+ "--show-error",
180
+ "--proto",
181
+ "=https",
182
+ "--tlsv1.2",
183
+ "--max-time",
184
+ "1800",
185
+ "--max-filesize",
186
+ String(maximumBytes),
187
+ "--output",
188
+ destination,
189
+ source.href
190
+ ];
191
+ };
168
192
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
169
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
170
- if (!response.ok)
171
- throw new Error(`download failed with HTTP ${response.status}`);
172
- const declared = Number(response.headers.get("content-length") ?? 0);
173
- if (declared > maximumBytes)
174
- throw new Error("download exceeds reviewed size bound");
175
193
  if (existsSync(destination))
176
194
  throw new Error("download destination already exists");
177
- await Bun.write(destination, response);
195
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
196
+ if (received.exitCode !== 0) {
197
+ rmSync(destination, { force: true });
198
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
199
+ }
178
200
  chmodSync(destination, 384);
179
201
  if (statSync(destination).size > maximumBytes) {
180
202
  rmSync(destination, { force: true });
@@ -203,7 +203,7 @@ function reference(value, where) {
203
203
  const row2 = object(value, where);
204
204
  exact3(row2, ["$ref"], where);
205
205
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
206
- 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)) {
206
+ 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)) {
207
207
  fail(where, "contains an unsupported reference.");
208
208
  }
209
209
  return { $ref: coordinate };
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
635
635
  if (!targets.has(String(scope.target)))
636
636
  fail(`${where}.scope.target`, "must name an existing target.");
637
637
  integer(scope.size, `${where}.scope.size`, 1, 32);
638
+ } else if (scope.kind === "topology-role") {
639
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
640
+ if (!targets.has(String(scope.target)))
641
+ fail(`${where}.scope.target`, "must name an existing target.");
642
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
643
+ fail(`${where}.scope.role`, "is unsupported.");
638
644
  } else
639
645
  fail(`${where}.scope.kind`, "is unsupported.");
640
646
  if (row2.timeoutMs !== undefined)
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
789
795
  const targetDefinitions = new Map;
790
796
  for (const [targetName, value] of targetEntries) {
791
797
  const target = object(value, `deployment.spec.targets.${targetName}`);
792
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
798
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
793
799
  if (target.kind !== "compute")
794
800
  fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
795
801
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
911
917
  }
912
918
  }
913
919
  }
920
+ if (target.topology !== undefined) {
921
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
922
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
923
+ if (topology.groupBy !== "metal")
924
+ fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
925
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
926
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
927
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
928
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
929
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
930
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
931
+ fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
932
+ }
933
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
934
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
935
+ }
936
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
937
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
938
+ }
939
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
940
+ if (crossMetal.mode === "private-lan")
941
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
942
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
943
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
944
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
945
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
946
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
947
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
948
+ if (typeof crossMetal.network === "string")
949
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
950
+ else
951
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
952
+ } else
953
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
954
+ if (allocation.sharing !== "exclusive")
955
+ fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
956
+ if (minimum < 1 + Number(relays.standbyPerMetal))
957
+ fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
958
+ }
914
959
  if (target.placement !== undefined)
915
960
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
916
961
  targetDefinitions.set(targetName, target);
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
934
979
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
935
980
  fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
936
981
  }
982
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
983
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
984
+ }
937
985
  }
938
986
  const components = object(spec.components, "deployment.spec.components");
939
987
  const componentEntries = Object.entries(components);
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
165
165
  return { exitCode, output: `${stdout}${stderr}` };
166
166
  };
167
167
  var run = runSoftwareCommand;
168
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
169
+ const source = new URL(url);
170
+ if (source.protocol !== "https:" || source.username || source.password)
171
+ throw new Error("software download must use credential-free HTTPS");
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
173
+ throw new Error("software download size bound is invalid");
174
+ return [
175
+ "/usr/bin/curl",
176
+ "--fail",
177
+ "--location",
178
+ "--silent",
179
+ "--show-error",
180
+ "--proto",
181
+ "=https",
182
+ "--tlsv1.2",
183
+ "--max-time",
184
+ "1800",
185
+ "--max-filesize",
186
+ String(maximumBytes),
187
+ "--output",
188
+ destination,
189
+ source.href
190
+ ];
191
+ };
168
192
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
169
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
170
- if (!response.ok)
171
- throw new Error(`download failed with HTTP ${response.status}`);
172
- const declared = Number(response.headers.get("content-length") ?? 0);
173
- if (declared > maximumBytes)
174
- throw new Error("download exceeds reviewed size bound");
175
193
  if (existsSync(destination))
176
194
  throw new Error("download destination already exists");
177
- await Bun.write(destination, response);
195
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
196
+ if (received.exitCode !== 0) {
197
+ rmSync(destination, { force: true });
198
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
199
+ }
178
200
  chmodSync(destination, 384);
179
201
  if (statSync(destination).size > maximumBytes) {
180
202
  rmSync(destination, { force: true });
@@ -203,7 +203,7 @@ function reference(value, where) {
203
203
  const row2 = object(value, where);
204
204
  exact3(row2, ["$ref"], where);
205
205
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
206
- 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)) {
206
+ 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)) {
207
207
  fail(where, "contains an unsupported reference.");
208
208
  }
209
209
  return { $ref: coordinate };
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
635
635
  if (!targets.has(String(scope.target)))
636
636
  fail(`${where}.scope.target`, "must name an existing target.");
637
637
  integer(scope.size, `${where}.scope.size`, 1, 32);
638
+ } else if (scope.kind === "topology-role") {
639
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
640
+ if (!targets.has(String(scope.target)))
641
+ fail(`${where}.scope.target`, "must name an existing target.");
642
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
643
+ fail(`${where}.scope.role`, "is unsupported.");
638
644
  } else
639
645
  fail(`${where}.scope.kind`, "is unsupported.");
640
646
  if (row2.timeoutMs !== undefined)
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
789
795
  const targetDefinitions = new Map;
790
796
  for (const [targetName, value] of targetEntries) {
791
797
  const target = object(value, `deployment.spec.targets.${targetName}`);
792
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
798
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
793
799
  if (target.kind !== "compute")
794
800
  fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
795
801
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
911
917
  }
912
918
  }
913
919
  }
920
+ if (target.topology !== undefined) {
921
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
922
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
923
+ if (topology.groupBy !== "metal")
924
+ fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
925
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
926
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
927
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
928
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
929
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
930
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
931
+ fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
932
+ }
933
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
934
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
935
+ }
936
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
937
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
938
+ }
939
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
940
+ if (crossMetal.mode === "private-lan")
941
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
942
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
943
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
944
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
945
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
946
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
947
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
948
+ if (typeof crossMetal.network === "string")
949
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
950
+ else
951
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
952
+ } else
953
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
954
+ if (allocation.sharing !== "exclusive")
955
+ fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
956
+ if (minimum < 1 + Number(relays.standbyPerMetal))
957
+ fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
958
+ }
914
959
  if (target.placement !== undefined)
915
960
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
916
961
  targetDefinitions.set(targetName, target);
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
934
979
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
935
980
  fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
936
981
  }
982
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
983
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
984
+ }
937
985
  }
938
986
  const components = object(spec.components, "deployment.spec.components");
939
987
  const componentEntries = Object.entries(components);
@@ -203,7 +203,7 @@ function reference(value, where) {
203
203
  const row2 = object(value, where);
204
204
  exact3(row2, ["$ref"], where);
205
205
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
206
- 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)) {
206
+ 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)) {
207
207
  fail(where, "contains an unsupported reference.");
208
208
  }
209
209
  return { $ref: coordinate };
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
635
635
  if (!targets.has(String(scope.target)))
636
636
  fail(`${where}.scope.target`, "must name an existing target.");
637
637
  integer(scope.size, `${where}.scope.size`, 1, 32);
638
+ } else if (scope.kind === "topology-role") {
639
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
640
+ if (!targets.has(String(scope.target)))
641
+ fail(`${where}.scope.target`, "must name an existing target.");
642
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
643
+ fail(`${where}.scope.role`, "is unsupported.");
638
644
  } else
639
645
  fail(`${where}.scope.kind`, "is unsupported.");
640
646
  if (row2.timeoutMs !== undefined)
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
789
795
  const targetDefinitions = new Map;
790
796
  for (const [targetName, value] of targetEntries) {
791
797
  const target = object(value, `deployment.spec.targets.${targetName}`);
792
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
798
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
793
799
  if (target.kind !== "compute")
794
800
  fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
795
801
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
911
917
  }
912
918
  }
913
919
  }
920
+ if (target.topology !== undefined) {
921
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
922
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
923
+ if (topology.groupBy !== "metal")
924
+ fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
925
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
926
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
927
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
928
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
929
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
930
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
931
+ fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
932
+ }
933
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
934
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
935
+ }
936
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
937
+ fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
938
+ }
939
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
940
+ if (crossMetal.mode === "private-lan")
941
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
942
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
943
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
944
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
945
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
946
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
947
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
948
+ if (typeof crossMetal.network === "string")
949
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
950
+ else
951
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
952
+ } else
953
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
954
+ if (allocation.sharing !== "exclusive")
955
+ fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
956
+ if (minimum < 1 + Number(relays.standbyPerMetal))
957
+ fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
958
+ }
914
959
  if (target.placement !== undefined)
915
960
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
916
961
  targetDefinitions.set(targetName, target);
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
934
979
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
935
980
  fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
936
981
  }
982
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
983
+ fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
984
+ }
937
985
  }
938
986
  const components = object(spec.components, "deployment.spec.components");
939
987
  const componentEntries = Object.entries(components);