@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.js CHANGED
@@ -4837,7 +4837,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
4837
4837
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
4838
4838
 
4839
4839
  // src/version.ts
4840
- var VERSION2 = "0.1.78";
4840
+ var VERSION2 = "0.1.80";
4841
4841
 
4842
4842
  // src/software.ts
4843
4843
  var PINNED_BUN_VERSION = "1.3.14";
@@ -10573,7 +10573,7 @@ function reference(value, where) {
10573
10573
  const row2 = object(value, where);
10574
10574
  exact3(row2, ["$ref"], where);
10575
10575
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
10576
- 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)) {
10576
+ 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)) {
10577
10577
  fail2(where, "contains an unsupported reference.");
10578
10578
  }
10579
10579
  return { $ref: coordinate };
@@ -11005,6 +11005,12 @@ function validateStep(value, where, targets, context) {
11005
11005
  if (!targets.has(String(scope.target)))
11006
11006
  fail2(`${where}.scope.target`, "must name an existing target.");
11007
11007
  integer(scope.size, `${where}.scope.size`, 1, 32);
11008
+ } else if (scope.kind === "topology-role") {
11009
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
11010
+ if (!targets.has(String(scope.target)))
11011
+ fail2(`${where}.scope.target`, "must name an existing target.");
11012
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
11013
+ fail2(`${where}.scope.role`, "is unsupported.");
11008
11014
  } else
11009
11015
  fail2(`${where}.scope.kind`, "is unsupported.");
11010
11016
  if (row2.timeoutMs !== undefined)
@@ -11159,7 +11165,7 @@ function compileDeployment(sourceValue) {
11159
11165
  const targetDefinitions = new Map;
11160
11166
  for (const [targetName, value] of targetEntries) {
11161
11167
  const target = object(value, `deployment.spec.targets.${targetName}`);
11162
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
11168
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
11163
11169
  if (target.kind !== "compute")
11164
11170
  fail2(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
11165
11171
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -11281,6 +11287,45 @@ function compileDeployment(sourceValue) {
11281
11287
  }
11282
11288
  }
11283
11289
  }
11290
+ if (target.topology !== undefined) {
11291
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
11292
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
11293
+ if (topology.groupBy !== "metal")
11294
+ fail2(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
11295
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
11296
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
11297
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
11298
+ fail2(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
11299
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
11300
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
11301
+ fail2(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
11302
+ }
11303
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
11304
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
11305
+ }
11306
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
11307
+ fail2(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
11308
+ }
11309
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
11310
+ if (crossMetal.mode === "private-lan")
11311
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
11312
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
11313
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
11314
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
11315
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
11316
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
11317
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
11318
+ if (typeof crossMetal.network === "string")
11319
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
11320
+ else
11321
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
11322
+ } else
11323
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
11324
+ if (allocation.sharing !== "exclusive")
11325
+ fail2(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
11326
+ if (minimum < 1 + Number(relays.standbyPerMetal))
11327
+ fail2(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
11328
+ }
11284
11329
  if (target.placement !== undefined)
11285
11330
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
11286
11331
  targetDefinitions.set(targetName, target);
@@ -11304,6 +11349,9 @@ function compileDeployment(sourceValue) {
11304
11349
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
11305
11350
  fail2(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
11306
11351
  }
11352
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
11353
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
11354
+ }
11307
11355
  }
11308
11356
  const components = object(spec.components, "deployment.spec.components");
11309
11357
  const componentEntries = Object.entries(components);
@@ -12058,7 +12106,14 @@ function validatePlatformSharedEnvironment(input) {
12058
12106
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
12059
12107
  }
12060
12108
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
12061
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
12109
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
12110
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
12111
+ }
12112
+ for (const member of input.seedSyncMembers)
12113
+ safeAtom("seedSyncMembers item", member);
12114
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
12115
+ throw new Error("seedSyncMembers must include this nodeHostname.");
12116
+ }
12062
12117
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
12063
12118
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
12064
12119
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -12138,7 +12193,7 @@ function renderPlatformSharedEnvironment(input) {
12138
12193
  FZ_DB_MASTER: value.databaseMaster ?? "",
12139
12194
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
12140
12195
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
12141
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
12196
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
12142
12197
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
12143
12198
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
12144
12199
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -12207,7 +12262,12 @@ function platformApiCredentialSpecs(options) {
12207
12262
  ];
12208
12263
  }
12209
12264
  function renderPlatformApiUnits(input) {
12210
- for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
12265
+ for (const path of [
12266
+ input.sharedDirectory,
12267
+ input.sharedEnvironmentFile,
12268
+ input.slotsDirectory,
12269
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
12270
+ ]) {
12211
12271
  if (!path.startsWith("/") || /[\r\n]/.test(path))
12212
12272
  throw new Error("Runtime paths must be absolute and single-line.");
12213
12273
  }
@@ -12221,6 +12281,8 @@ function renderPlatformApiUnits(input) {
12221
12281
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
12222
12282
  `);
12223
12283
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
12284
+ ` : "";
12285
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
12224
12286
  ` : "";
12225
12287
  const template = `[Unit]
12226
12288
  Description=ForgeZero (%i slot)
@@ -12234,7 +12296,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
12234
12296
  Environment=NODE_ENV=production
12235
12297
  Environment=FZ_SLOT=%i
12236
12298
  EnvironmentFile=${input.sharedEnvironmentFile}
12237
- ${capacityEnvironment}${credentials}
12299
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
12238
12300
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
12239
12301
  Restart=always
12240
12302
  RestartSec=2
@@ -14722,7 +14784,8 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
14722
14784
  greenPort: runtime.greenPort,
14723
14785
  collectorUnit: runtime.environment.otlpCollectorUnit,
14724
14786
  credentials,
14725
- capacityEnvironmentFile: "/etc/forgezero/capacity.env"
14787
+ capacityEnvironmentFile: "/etc/forgezero/capacity.env",
14788
+ topologyEnvironmentFile: "/etc/forgezero/deployment-topology.env"
14726
14789
  });
14727
14790
  const edge = renderPlatformNginx({
14728
14791
  publicPort: runtime.environment.publicApiPort,
@@ -17298,8 +17361,8 @@ function platformGenesisBootstrapConfigs(template, guests, nodes, metalHostname,
17298
17361
  databaseAddress: guest.address,
17299
17362
  databaseMaster: role === "joiner" ? master : undefined,
17300
17363
  nodeHostname: node.nodeHostname,
17301
- seedSyncPeers: fleet.filter((_, peer) => peer !== index).map((peer) => `ws://${peer.address}:${config.runtime.bluePort}/internal/seed`),
17302
- seedSyncMembers: 3,
17364
+ seedSyncPeers: fleet.filter((_, peer) => peer !== index).flatMap((peer) => [config.runtime.bluePort, config.runtime.greenPort].map((port) => `ws://${peer.address}:${port}/_internal/forgezero/seed-mesh/v2`)),
17365
+ seedSyncMembers: nodes.map(({ nodeHostname }) => nodeHostname),
17303
17366
  initialInventory,
17304
17367
  custodianEmail: index === 0 ? template.runtime.environment.custodianEmail : undefined
17305
17368
  };
@@ -18642,7 +18705,7 @@ function interactiveBootstrap(kind, genesisGuests, genesisCloudflareCheckpoint)
18642
18705
  const collectorUnit = FORGEZERO_OTEL_COLLECTOR_UNIT;
18643
18706
  const replicationFactor = Number(bootstrapAnswer("Database replication factor", "3"));
18644
18707
  const writeConcern = Number(bootstrapAnswer("Database write concern", "2"));
18645
- const seedPeers = genesis ? fleet.slice(1).map((peer) => `ws://${peer.address}:3001/internal/seed`) : bootstrapAnswer("Seed WebSocket peers (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
18708
+ const seedPeers = genesis ? fleet.slice(1).flatMap((peer) => [3001, 3002].map((port) => `ws://${peer.address}:${port}/_internal/forgezero/seed-mesh/v2`)) : bootstrapAnswer("Seed WebSocket peers (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
18646
18709
  const emailProvider = bootstrapAnswer("Bootstrap email provider (smtp/jetemail)", "smtp");
18647
18710
  if (!["smtp", "jetemail"].includes(emailProvider))
18648
18711
  throw new Error("Bootstrap email provider must be smtp or jetemail");
@@ -18698,7 +18761,7 @@ function interactiveBootstrap(kind, genesisGuests, genesisCloudflareCheckpoint)
18698
18761
  publicApiPort: Number(bootstrapAnswer("Loopback public API port", "3000")),
18699
18762
  sharedDirectory: "/opt/forgezero/shared",
18700
18763
  seedSyncPeers: seedPeers,
18701
- seedSyncMembers: Number(bootstrapAnswer("Seed quorum members", "3")),
18764
+ seedSyncMembers: bootstrapList("Seed member hostnames (comma-separated)"),
18702
18765
  seedSyncEpoch: bootstrapAnswer("Seed epoch"),
18703
18766
  concurrencyLimit: Number(bootstrapAnswer("Initial concurrency limit", "128")),
18704
18767
  drainDeadlineMs: Number(bootstrapAnswer("Drain deadline milliseconds", "30000")),
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { AgentCredentialLocation, DeploymentCredentialBinding, DeploymentCr
14
14
  export { createDeploymentManager, DeploymentError } from './deployment';
15
15
  export type { DeploymentManager, DeploymentOptions, DeploymentRequest, DeploymentResult } from './deployment';
16
16
  export { DeploymentInputError, resolveDeploymentInputs, resolveDeploymentTargets } from './deployment-targets';
17
+ export { DeploymentTopologyError, planDeploymentTopology, type DeploymentTopologyAssignment, type DeploymentTopologyPlacement, type DeploymentTopologyRole, type DeploymentTopologyTransport } from './deployment-topology';
17
18
  export { DEFAULT_CONTROL_SOCKET, requestControl, startControlServer } from './control';
18
19
  export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
19
20
  export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
@@ -354,7 +354,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
354
354
  }
355
355
 
356
356
  // src/version.ts
357
- var VERSION = "0.1.78";
357
+ var VERSION = "0.1.80";
358
358
 
359
359
  // src/otel-collector.ts
360
360
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -305,16 +305,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
305
305
  return { exitCode, output: `${stdout}${stderr}` };
306
306
  };
307
307
  var run = runSoftwareCommand;
308
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
309
+ const source = new URL(url);
310
+ if (source.protocol !== "https:" || source.username || source.password)
311
+ throw new Error("software download must use credential-free HTTPS");
312
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
313
+ throw new Error("software download size bound is invalid");
314
+ return [
315
+ "/usr/bin/curl",
316
+ "--fail",
317
+ "--location",
318
+ "--silent",
319
+ "--show-error",
320
+ "--proto",
321
+ "=https",
322
+ "--tlsv1.2",
323
+ "--max-time",
324
+ "1800",
325
+ "--max-filesize",
326
+ String(maximumBytes),
327
+ "--output",
328
+ destination,
329
+ source.href
330
+ ];
331
+ };
308
332
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
309
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
310
- if (!response.ok)
311
- throw new Error(`download failed with HTTP ${response.status}`);
312
- const declared = Number(response.headers.get("content-length") ?? 0);
313
- if (declared > maximumBytes)
314
- throw new Error("download exceeds reviewed size bound");
315
333
  if (existsSync(destination))
316
334
  throw new Error("download destination already exists");
317
- await Bun.write(destination, response);
335
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
336
+ if (received.exitCode !== 0) {
337
+ rmSync(destination, { force: true });
338
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
339
+ }
318
340
  chmodSync(destination, 384);
319
341
  if (statSync(destination).size > maximumBytes) {
320
342
  rmSync(destination, { force: true });
@@ -305,16 +305,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
305
305
  return { exitCode, output: `${stdout}${stderr}` };
306
306
  };
307
307
  var run = runSoftwareCommand;
308
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
309
+ const source = new URL(url);
310
+ if (source.protocol !== "https:" || source.username || source.password)
311
+ throw new Error("software download must use credential-free HTTPS");
312
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
313
+ throw new Error("software download size bound is invalid");
314
+ return [
315
+ "/usr/bin/curl",
316
+ "--fail",
317
+ "--location",
318
+ "--silent",
319
+ "--show-error",
320
+ "--proto",
321
+ "=https",
322
+ "--tlsv1.2",
323
+ "--max-time",
324
+ "1800",
325
+ "--max-filesize",
326
+ String(maximumBytes),
327
+ "--output",
328
+ destination,
329
+ source.href
330
+ ];
331
+ };
308
332
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
309
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
310
- if (!response.ok)
311
- throw new Error(`download failed with HTTP ${response.status}`);
312
- const declared = Number(response.headers.get("content-length") ?? 0);
313
- if (declared > maximumBytes)
314
- throw new Error("download exceeds reviewed size bound");
315
333
  if (existsSync(destination))
316
334
  throw new Error("download destination already exists");
317
- await Bun.write(destination, response);
335
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
336
+ if (received.exitCode !== 0) {
337
+ rmSync(destination, { force: true });
338
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
339
+ }
318
340
  chmodSync(destination, 384);
319
341
  if (statSync(destination).size > maximumBytes) {
320
342
  rmSync(destination, { force: true });
@@ -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,
@@ -62,7 +62,8 @@ export interface PlatformSharedEnvironment {
62
62
  publicApiPort: number;
63
63
  sharedDirectory: string;
64
64
  seedSyncPeers: string[];
65
- seedSyncMembers: number;
65
+ /** Exact physical API host identities participating in this seed epoch. */
66
+ seedSyncMembers: string[];
66
67
  seedSyncEpoch: string;
67
68
  concurrencyLimit: number;
68
69
  drainDeadlineMs: number;
@@ -120,6 +121,8 @@ export interface ApiRuntimeRenderOptions {
120
121
  credentials: SystemdCredentialSpec[];
121
122
  /** Root-owned calibration override, loaded after the ordinary shared environment. */
122
123
  capacityEnvironmentFile?: string;
124
+ /** Root-owned signed deployment topology, absent until post-genesis reconciliation. */
125
+ topologyEnvironmentFile?: string;
123
126
  }
124
127
  export declare function renderPlatformApiUnits(input: ApiRuntimeRenderOptions): {
125
128
  template: string;
@@ -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 });
@@ -789,7 +811,14 @@ function validatePlatformSharedEnvironment(input) {
789
811
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
790
812
  }
791
813
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
792
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
814
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
815
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
816
+ }
817
+ for (const member of input.seedSyncMembers)
818
+ safeAtom("seedSyncMembers item", member);
819
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
820
+ throw new Error("seedSyncMembers must include this nodeHostname.");
821
+ }
793
822
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
794
823
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
795
824
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -869,7 +898,7 @@ function renderPlatformSharedEnvironment(input) {
869
898
  FZ_DB_MASTER: value.databaseMaster ?? "",
870
899
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
871
900
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
872
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
901
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
873
902
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
874
903
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
875
904
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -938,7 +967,12 @@ function platformApiCredentialSpecs(options) {
938
967
  ];
939
968
  }
940
969
  function renderPlatformApiUnits(input) {
941
- for (const path2 of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
970
+ for (const path2 of [
971
+ input.sharedDirectory,
972
+ input.sharedEnvironmentFile,
973
+ input.slotsDirectory,
974
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
975
+ ]) {
942
976
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
943
977
  throw new Error("Runtime paths must be absolute and single-line.");
944
978
  }
@@ -952,6 +986,8 @@ function renderPlatformApiUnits(input) {
952
986
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
953
987
  `);
954
988
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
989
+ ` : "";
990
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
955
991
  ` : "";
956
992
  const template = `[Unit]
957
993
  Description=ForgeZero (%i slot)
@@ -965,7 +1001,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
965
1001
  Environment=NODE_ENV=production
966
1002
  Environment=FZ_SLOT=%i
967
1003
  EnvironmentFile=${input.sharedEnvironmentFile}
968
- ${capacityEnvironment}${credentials}
1004
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
969
1005
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
970
1006
  Restart=always
971
1007
  RestartSec=2