@forgezero/agent 0.1.79 → 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.79";
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.79";
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";
@@ -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.79";
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;
@@ -811,7 +811,14 @@ function validatePlatformSharedEnvironment(input) {
811
811
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
812
812
  }
813
813
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
814
- 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
+ }
815
822
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
816
823
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
817
824
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -891,7 +898,7 @@ function renderPlatformSharedEnvironment(input) {
891
898
  FZ_DB_MASTER: value.databaseMaster ?? "",
892
899
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
893
900
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
894
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
901
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
895
902
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
896
903
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
897
904
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -960,7 +967,12 @@ function platformApiCredentialSpecs(options) {
960
967
  ];
961
968
  }
962
969
  function renderPlatformApiUnits(input) {
963
- 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
+ ]) {
964
976
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
965
977
  throw new Error("Runtime paths must be absolute and single-line.");
966
978
  }
@@ -974,6 +986,8 @@ function renderPlatformApiUnits(input) {
974
986
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
975
987
  `);
976
988
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
989
+ ` : "";
990
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
977
991
  ` : "";
978
992
  const template = `[Unit]
979
993
  Description=ForgeZero (%i slot)
@@ -987,7 +1001,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
987
1001
  Environment=NODE_ENV=production
988
1002
  Environment=FZ_SLOT=%i
989
1003
  EnvironmentFile=${input.sharedEnvironmentFile}
990
- ${capacityEnvironment}${credentials}
1004
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
991
1005
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
992
1006
  Restart=always
993
1007
  RestartSec=2
@@ -1649,6 +1649,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1649
1649
  // src/deployment-connectivity.ts
1650
1650
  import { createHash as createHash3 } from "node:crypto";
1651
1651
  import { mkdirSync as mkdirSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
1652
+ import { createConnection } from "node:net";
1652
1653
  import { dirname as dirname4 } from "node:path";
1653
1654
 
1654
1655
  // src/process-input.ts
@@ -1701,6 +1702,21 @@ var defaultHost = {
1701
1702
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
1702
1703
  return { exitCode, output: `${stdout}${stderr}` };
1703
1704
  },
1705
+ probe: (address, port, timeoutMs) => new Promise((resolve3) => {
1706
+ const socket = createConnection({ host: address, port });
1707
+ let settled = false;
1708
+ const finish = (value) => {
1709
+ if (settled)
1710
+ return;
1711
+ settled = true;
1712
+ socket.destroy();
1713
+ resolve3(value);
1714
+ };
1715
+ socket.setTimeout(timeoutMs);
1716
+ socket.once("connect", () => finish(true));
1717
+ socket.once("timeout", () => finish(false));
1718
+ socket.once("error", () => finish(false));
1719
+ }),
1704
1720
  sleep: (ms) => Bun.sleep(ms)
1705
1721
  };
1706
1722
  function validate(request) {
@@ -1715,7 +1731,7 @@ function validate(request) {
1715
1731
  } else if (request.capabilities.public)
1716
1732
  throw new Error("unexpected public deployment capability");
1717
1733
  if (privateIntent?.mode === "cloudflare-warp") {
1718
- if (!NAME2.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !TOKEN.test(request.capabilities.private.connectorToken)) {
1734
+ if (!NAME2.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) || !TOKEN.test(request.capabilities.private.connectorToken)) {
1719
1735
  throw new Error("private deployment connectivity is malformed");
1720
1736
  }
1721
1737
  } else if (request.capabilities.private)
@@ -1725,6 +1741,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
1725
1741
  validate(request);
1726
1742
  const id = idFor(request.key);
1727
1743
  const evidence = { key: request.key };
1744
+ const topology = request.topology;
1745
+ if (topology) {
1746
+ const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
1747
+ const identities = [
1748
+ topology.nodeIdentity,
1749
+ ...topology.localPeerIdentities,
1750
+ ...topology.remoteRelayIdentities,
1751
+ ...topology.memberIdentities
1752
+ ];
1753
+ 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") {
1754
+ throw new Error("deployment topology is malformed");
1755
+ }
1756
+ if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
1757
+ throw new Error("deployment topology transport differs from private connectivity intent");
1758
+ }
1759
+ }
1728
1760
  if (request.intent.public?.mode === "cloudflare-tunnel") {
1729
1761
  const capability = request.capabilities.public;
1730
1762
  const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
@@ -1762,6 +1794,12 @@ WantedBy=multi-user.target
1762
1794
  const capability = request.capabilities.private;
1763
1795
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
1764
1796
  const unit = "forgezero-deployment-mesh.service";
1797
+ const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
1798
+ host.write(forwarding, `net.ipv4.ip_forward = 1
1799
+ net.ipv6.conf.all.forwarding = 1
1800
+ net.ipv6.conf.all.accept_ra = 2
1801
+ `, 420);
1802
+ await checked2(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
1765
1803
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
1766
1804
  host.write(`/etc/systemd/system/${unit}`, `[Unit]
1767
1805
  Description=ForgeZero deployment Mesh/WARP connector
@@ -1796,6 +1834,95 @@ WantedBy=multi-user.target
1796
1834
  if (!evidence.private)
1797
1835
  throw new Error("WARP connector readiness failed");
1798
1836
  }
1837
+ if (topology) {
1838
+ const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
1839
+ const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
1840
+ host.write("/etc/forgezero/deployment-topology.env", [
1841
+ `FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
1842
+ `FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
1843
+ `FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
1844
+ `FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
1845
+ `FZ_TOPOLOGY_SITE=${topology.site}`,
1846
+ `FZ_TOPOLOGY_ROLE=${topology.role}`,
1847
+ `FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
1848
+ ""
1849
+ ].join(`
1850
+ `), 384);
1851
+ const routeUnit = "forgezero-deployment-topology.service";
1852
+ const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
1853
+ 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] })) : [];
1854
+ for (const { via } of routePairs) {
1855
+ await checked2(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
1856
+ }
1857
+ await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
1858
+ const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
1859
+ ` : "";
1860
+ const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
1861
+ `);
1862
+ const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
1863
+ `);
1864
+ const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
1865
+ ` : "";
1866
+ host.write(routeUnitPath, `[Unit]
1867
+ Description=ForgeZero fenced deployment site routing
1868
+ After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
1869
+ Wants=network-online.target
1870
+
1871
+ [Service]
1872
+ Type=oneshot
1873
+ RemainAfterExit=yes
1874
+ ${forwarding}${noOp}${starts}${starts ? `
1875
+ ` : ""}${stops}${stops ? `
1876
+ ` : ""}
1877
+ [Install]
1878
+ WantedBy=multi-user.target
1879
+ `, 420);
1880
+ for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
1881
+ for (const port of topology.routedTcpPorts) {
1882
+ await checked2(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
1883
+ }
1884
+ }
1885
+ if (topology.role !== "member")
1886
+ for (const remoteCidr of topology.remoteSiteCidrs) {
1887
+ for (const port of topology.routedTcpPorts) {
1888
+ await checked2(host, [
1889
+ "/usr/sbin/ufw",
1890
+ "route",
1891
+ "allow",
1892
+ "proto",
1893
+ "tcp",
1894
+ "from",
1895
+ topology.siteCidr,
1896
+ "to",
1897
+ remoteCidr,
1898
+ "port",
1899
+ String(port)
1900
+ ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
1901
+ }
1902
+ }
1903
+ await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
1904
+ await checked2(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
1905
+ const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
1906
+ for (const address of [...new Set(probes)]) {
1907
+ await checked2(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
1908
+ let ready = false;
1909
+ for (let attempt = 0;attempt < 10; attempt += 1) {
1910
+ if (await host.probe(address, topology.healthPort, 2000)) {
1911
+ ready = true;
1912
+ break;
1913
+ }
1914
+ await host.sleep(1000);
1915
+ }
1916
+ if (!ready)
1917
+ throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
1918
+ }
1919
+ evidence.topology = {
1920
+ site: topology.site,
1921
+ siteCidr: topology.siteCidr,
1922
+ role: topology.role,
1923
+ probed: [...new Set(probes)]
1924
+ };
1925
+ }
1799
1926
  return evidence;
1800
1927
  }
1801
1928
 
@@ -2520,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2520
2647
  }
2521
2648
 
2522
2649
  // src/version.ts
2523
- var VERSION3 = "0.1.79";
2650
+ var VERSION3 = "0.1.80";
2524
2651
 
2525
2652
  // src/egress-policy.ts
2526
2653
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -3780,7 +3907,14 @@ function validatePlatformSharedEnvironment(input) {
3780
3907
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3781
3908
  }
3782
3909
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3783
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
3910
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
3911
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
3912
+ }
3913
+ for (const member of input.seedSyncMembers)
3914
+ safeAtom("seedSyncMembers item", member);
3915
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
3916
+ throw new Error("seedSyncMembers must include this nodeHostname.");
3917
+ }
3784
3918
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
3785
3919
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
3786
3920
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -3860,7 +3994,7 @@ function renderPlatformSharedEnvironment(input) {
3860
3994
  FZ_DB_MASTER: value.databaseMaster ?? "",
3861
3995
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
3862
3996
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
3863
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
3997
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
3864
3998
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
3865
3999
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
3866
4000
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -3929,7 +4063,12 @@ function platformApiCredentialSpecs(options) {
3929
4063
  ];
3930
4064
  }
3931
4065
  function renderPlatformApiUnits(input) {
3932
- for (const path2 of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
4066
+ for (const path2 of [
4067
+ input.sharedDirectory,
4068
+ input.sharedEnvironmentFile,
4069
+ input.slotsDirectory,
4070
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
4071
+ ]) {
3933
4072
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
3934
4073
  throw new Error("Runtime paths must be absolute and single-line.");
3935
4074
  }
@@ -3943,6 +4082,8 @@ function renderPlatformApiUnits(input) {
3943
4082
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
3944
4083
  `);
3945
4084
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
4085
+ ` : "";
4086
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
3946
4087
  ` : "";
3947
4088
  const template = `[Unit]
3948
4089
  Description=ForgeZero (%i slot)
@@ -3956,7 +4097,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
3956
4097
  Environment=NODE_ENV=production
3957
4098
  Environment=FZ_SLOT=%i
3958
4099
  EnvironmentFile=${input.sharedEnvironmentFile}
3959
- ${capacityEnvironment}${credentials}
4100
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
3960
4101
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
3961
4102
  Restart=always
3962
4103
  RestartSec=2