@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/deploy.d.ts CHANGED
@@ -74,6 +74,36 @@ export interface ComputeConnectivityDefinition {
74
74
  };
75
75
  };
76
76
  }
77
+ /**
78
+ * A deployment may require one stable coordination role per physical site.
79
+ * The control plane groups computes by their admitted Metal identity; users do
80
+ * not name a particular machine as "master". A failed relay is replaced by
81
+ * the declared standby and the assignment generation is fenced by the API.
82
+ */
83
+ export interface ComputeTopologyDefinition {
84
+ groupBy: 'metal';
85
+ relays: {
86
+ activePerMetal: 1;
87
+ standbyPerMetal: 0 | 1;
88
+ /** Exact TCP endpoint used to prove the selected relay is actually serving. */
89
+ healthPort: number;
90
+ /** Exact private TCP services routed and admitted between deployment sites. */
91
+ routedTcpPorts: readonly number[];
92
+ };
93
+ crossMetal: {
94
+ mode: 'private-lan';
95
+ } | {
96
+ mode: 'cloudflare-warp';
97
+ credential: string;
98
+ network: StringValue;
99
+ } | {
100
+ mode: 'auto';
101
+ /** One or two directly reachable sites never install WARP. */
102
+ directPrivateMaximumMetals: 2;
103
+ credential: string;
104
+ network: StringValue;
105
+ };
106
+ }
77
107
  export type ResolvableJsonValue = JsonPrimitive | ValueReference | readonly ResolvableJsonValue[] | {
78
108
  [key: string]: ResolvableJsonValue;
79
109
  };
@@ -165,6 +195,7 @@ export interface TargetDefinition {
165
195
  };
166
196
  /** Per-slot connectivity intent. Credential names resolve through the scoped Vault, never to plan values. */
167
197
  connectivity?: ComputeConnectivityDefinition;
198
+ topology?: ComputeTopologyDefinition;
168
199
  placement?: {
169
200
  spreadBy?: readonly ('metal' | 'region' | 'zone')[];
170
201
  antiAffinity?: readonly string[];
@@ -190,6 +221,7 @@ export interface ComputePoolDefinition {
190
221
  reuse?: 'prefer' | 'require';
191
222
  provisioning?: TargetDefinition['provisioning'];
192
223
  connectivity?: ComputeConnectivityDefinition;
224
+ topology?: ComputeTopologyDefinition;
193
225
  labels?: Readonly<Record<string, string>>;
194
226
  placement?: TargetDefinition['placement'];
195
227
  }
@@ -354,6 +386,10 @@ export type StepScope = {
354
386
  kind: 'target-batches';
355
387
  target: string;
356
388
  size: number;
389
+ } | {
390
+ kind: 'topology-role';
391
+ target: string;
392
+ role: 'relay' | 'standby' | 'member';
357
393
  };
358
394
  export interface RetryDefinition {
359
395
  attempts: number;
@@ -480,7 +516,7 @@ export declare const ref: {
480
516
  target: (target: string, property: string) => ValueReference;
481
517
  deployment: (property: string) => ValueReference;
482
518
  /** Secret-free control-plane assignment of the compute executing this step. */
483
- execution: (property: "targetName" | "slot" | "releaseExecutor" | "runtime" | "cpuCores" | "memoryMiB" | "storageGiB") => ValueReference;
519
+ execution: (property: "targetName" | "slot" | "releaseExecutor" | "runtime" | "cpuCores" | "memoryMiB" | "storageGiB" | "site" | "topologyRole" | "topologyTransport" | "localRelayAddress") => ValueReference;
484
520
  };
485
521
  export declare const conditions: {
486
522
  all: (...values: Condition[]) => Condition;
package/dist/deploy.js CHANGED
@@ -173,6 +173,7 @@ var target = {
173
173
  },
174
174
  ...value.provisioning ? { provisioning: value.provisioning } : {},
175
175
  ...value.connectivity ? { connectivity: value.connectivity } : {},
176
+ ...value.topology ? { topology: value.topology } : {},
176
177
  ...value.placement ? { placement: value.placement } : {}
177
178
  };
178
179
  }
@@ -28,12 +28,31 @@ export interface DeploymentConnectivityCapabilities {
28
28
  private?: {
29
29
  connectorId: string;
30
30
  connectorToken: string;
31
+ siteRouteId: string;
31
32
  };
32
33
  }
34
+ export interface DeploymentConnectivityTopology {
35
+ site: string;
36
+ nodeIdentity: string;
37
+ siteCidr: string;
38
+ role: 'relay' | 'standby' | 'member';
39
+ transport: 'private-lan' | 'cloudflare-warp';
40
+ localRelayAddress: string;
41
+ localPeerAddresses: readonly string[];
42
+ localPeerIdentities: readonly string[];
43
+ remoteRelayAddresses: readonly string[];
44
+ remoteRelayIdentities: readonly string[];
45
+ remoteSiteCidrs: readonly string[];
46
+ memberIdentities: readonly string[];
47
+ healthPort: number;
48
+ routedTcpPorts: readonly number[];
49
+ generation: string;
50
+ }
33
51
  export interface DeploymentConnectivityRequest {
34
52
  key: string;
35
53
  intent: DeploymentConnectivityIntent;
36
54
  capabilities: DeploymentConnectivityCapabilities;
55
+ topology?: DeploymentConnectivityTopology;
37
56
  }
38
57
  export interface DeploymentConnectivityEvidence {
39
58
  key: string;
@@ -49,6 +68,12 @@ export interface DeploymentConnectivityEvidence {
49
68
  unit: string;
50
69
  active: true;
51
70
  };
71
+ topology?: {
72
+ site: string;
73
+ siteCidr: string;
74
+ role: DeploymentConnectivityTopology['role'];
75
+ probed: string[];
76
+ };
52
77
  }
53
78
  export interface DeploymentConnectivityHost {
54
79
  seal(name: string, path: string, value: string): Promise<void>;
@@ -57,6 +82,7 @@ export interface DeploymentConnectivityHost {
57
82
  exitCode: number;
58
83
  output: string;
59
84
  }>;
85
+ probe(address: string, port: number, timeoutMs: number): Promise<boolean>;
60
86
  sleep(ms: number): Promise<void>;
61
87
  }
62
88
  /** Apply only connector-scoped capabilities; the broad Cloudflare token never reaches this helper. */
@@ -1,6 +1,7 @@
1
1
  // src/deployment-connectivity.ts
2
2
  import { createHash } from "node:crypto";
3
3
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { createConnection } from "node:net";
4
5
  import { dirname } from "node:path";
5
6
 
6
7
  // src/process-input.ts
@@ -53,6 +54,21 @@ var defaultHost = {
53
54
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
54
55
  return { exitCode, output: `${stdout}${stderr}` };
55
56
  },
57
+ probe: (address, port, timeoutMs) => new Promise((resolve) => {
58
+ const socket = createConnection({ host: address, port });
59
+ let settled = false;
60
+ const finish = (value) => {
61
+ if (settled)
62
+ return;
63
+ settled = true;
64
+ socket.destroy();
65
+ resolve(value);
66
+ };
67
+ socket.setTimeout(timeoutMs);
68
+ socket.once("connect", () => finish(true));
69
+ socket.once("timeout", () => finish(false));
70
+ socket.once("error", () => finish(false));
71
+ }),
56
72
  sleep: (ms) => Bun.sleep(ms)
57
73
  };
58
74
  function validate(request) {
@@ -67,7 +83,7 @@ function validate(request) {
67
83
  } else if (request.capabilities.public)
68
84
  throw new Error("unexpected public deployment capability");
69
85
  if (privateIntent?.mode === "cloudflare-warp") {
70
- if (!NAME.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)) {
86
+ if (!NAME.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)) {
71
87
  throw new Error("private deployment connectivity is malformed");
72
88
  }
73
89
  } else if (request.capabilities.private)
@@ -77,6 +93,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
77
93
  validate(request);
78
94
  const id = idFor(request.key);
79
95
  const evidence = { key: request.key };
96
+ const topology = request.topology;
97
+ if (topology) {
98
+ const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
99
+ const identities = [
100
+ topology.nodeIdentity,
101
+ ...topology.localPeerIdentities,
102
+ ...topology.remoteRelayIdentities,
103
+ ...topology.memberIdentities
104
+ ];
105
+ 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") {
106
+ throw new Error("deployment topology is malformed");
107
+ }
108
+ if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
109
+ throw new Error("deployment topology transport differs from private connectivity intent");
110
+ }
111
+ }
80
112
  if (request.intent.public?.mode === "cloudflare-tunnel") {
81
113
  const capability = request.capabilities.public;
82
114
  const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
@@ -114,6 +146,12 @@ WantedBy=multi-user.target
114
146
  const capability = request.capabilities.private;
115
147
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
116
148
  const unit = "forgezero-deployment-mesh.service";
149
+ const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
150
+ host.write(forwarding, `net.ipv4.ip_forward = 1
151
+ net.ipv6.conf.all.forwarding = 1
152
+ net.ipv6.conf.all.accept_ra = 2
153
+ `, 420);
154
+ await checked(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
117
155
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
118
156
  host.write(`/etc/systemd/system/${unit}`, `[Unit]
119
157
  Description=ForgeZero deployment Mesh/WARP connector
@@ -148,6 +186,95 @@ WantedBy=multi-user.target
148
186
  if (!evidence.private)
149
187
  throw new Error("WARP connector readiness failed");
150
188
  }
189
+ if (topology) {
190
+ const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
191
+ const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
192
+ host.write("/etc/forgezero/deployment-topology.env", [
193
+ `FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
194
+ `FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
195
+ `FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
196
+ `FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
197
+ `FZ_TOPOLOGY_SITE=${topology.site}`,
198
+ `FZ_TOPOLOGY_ROLE=${topology.role}`,
199
+ `FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
200
+ ""
201
+ ].join(`
202
+ `), 384);
203
+ const routeUnit = "forgezero-deployment-topology.service";
204
+ const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
205
+ 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] })) : [];
206
+ for (const { via } of routePairs) {
207
+ await checked(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
208
+ }
209
+ await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
210
+ const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
211
+ ` : "";
212
+ const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
213
+ `);
214
+ const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
215
+ `);
216
+ const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
217
+ ` : "";
218
+ host.write(routeUnitPath, `[Unit]
219
+ Description=ForgeZero fenced deployment site routing
220
+ After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
221
+ Wants=network-online.target
222
+
223
+ [Service]
224
+ Type=oneshot
225
+ RemainAfterExit=yes
226
+ ${forwarding}${noOp}${starts}${starts ? `
227
+ ` : ""}${stops}${stops ? `
228
+ ` : ""}
229
+ [Install]
230
+ WantedBy=multi-user.target
231
+ `, 420);
232
+ for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
233
+ for (const port of topology.routedTcpPorts) {
234
+ await checked(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
235
+ }
236
+ }
237
+ if (topology.role !== "member")
238
+ for (const remoteCidr of topology.remoteSiteCidrs) {
239
+ for (const port of topology.routedTcpPorts) {
240
+ await checked(host, [
241
+ "/usr/sbin/ufw",
242
+ "route",
243
+ "allow",
244
+ "proto",
245
+ "tcp",
246
+ "from",
247
+ topology.siteCidr,
248
+ "to",
249
+ remoteCidr,
250
+ "port",
251
+ String(port)
252
+ ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
253
+ }
254
+ }
255
+ await checked(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
256
+ await checked(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
257
+ const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
258
+ for (const address of [...new Set(probes)]) {
259
+ await checked(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
260
+ let ready = false;
261
+ for (let attempt = 0;attempt < 10; attempt += 1) {
262
+ if (await host.probe(address, topology.healthPort, 2000)) {
263
+ ready = true;
264
+ break;
265
+ }
266
+ await host.sleep(1000);
267
+ }
268
+ if (!ready)
269
+ throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
270
+ }
271
+ evidence.topology = {
272
+ site: topology.site,
273
+ siteCidr: topology.siteCidr,
274
+ role: topology.role,
275
+ probed: [...new Set(probes)]
276
+ };
277
+ }
151
278
  return evidence;
152
279
  }
153
280
  export {
@@ -99,6 +99,19 @@ function resolveDeploymentTargets(plan, supplied = {}) {
99
99
  } : { mode: "disabled" } } : {}
100
100
  };
101
101
  }
102
+ let topology;
103
+ if (target.topology) {
104
+ const crossMetal = target.topology.crossMetal;
105
+ topology = {
106
+ groupBy: "metal",
107
+ relays: { ...target.topology.relays },
108
+ crossMetal: crossMetal.mode === "private-lan" ? { mode: "private-lan" } : {
109
+ ...crossMetal,
110
+ credential: credentialSource(crossMetal.credential, `target ${name} topology WARP credential`),
111
+ network: stringCoordinate(crossMetal.network, `target ${name} topology WARP network`)
112
+ }
113
+ };
114
+ }
102
115
  return {
103
116
  name,
104
117
  profiles: [...target.selector.profiles],
@@ -117,7 +130,8 @@ function resolveDeploymentTargets(plan, supplied = {}) {
117
130
  }
118
131
  },
119
132
  ...target.provisioning ? { provisioning: { ...target.provisioning } } : {},
120
- ...connectivity ? { connectivity } : {}
133
+ ...connectivity ? { connectivity } : {},
134
+ ...topology ? { topology } : {}
121
135
  };
122
136
  });
123
137
  }
@@ -0,0 +1,46 @@
1
+ import type { ResolvedDeploymentTarget } from './deployment';
2
+ export type DeploymentTopologyRole = 'relay' | 'standby' | 'member';
3
+ export type DeploymentTopologyTransport = 'private-lan' | 'cloudflare-warp';
4
+ export interface DeploymentTopologyPlacement {
5
+ computeReference: string;
6
+ /** Stable non-secret node identity generated by the control plane. */
7
+ nodeIdentity: string;
8
+ slot: number;
9
+ metalHostname: string;
10
+ privateAddress: string;
11
+ /** Exact non-overlapping private subnet owned by this admitted Metal. */
12
+ siteCidr: string;
13
+ }
14
+ export interface DeploymentTopologyAssignment extends DeploymentTopologyPlacement {
15
+ role: DeploymentTopologyRole;
16
+ transport: DeploymentTopologyTransport;
17
+ localRelayReference: string;
18
+ localRelayAddress: string;
19
+ /** Same-metal peers; ordinary members receive only the active relay. */
20
+ localPeerAddresses: readonly string[];
21
+ localPeerIdentities: readonly string[];
22
+ remoteRelayAddresses: readonly string[];
23
+ remoteRelayIdentities: readonly string[];
24
+ remoteSiteCidrs: readonly string[];
25
+ memberIdentities: readonly string[];
26
+ healthPort: number;
27
+ routedTcpPorts: readonly number[];
28
+ /** Only relay and standby computes may carry the cross-metal WARP connector. */
29
+ warpRequired: boolean;
30
+ generation: string;
31
+ }
32
+ export declare class DeploymentTopologyError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ /**
36
+ * Deterministically elect one relay per admitted Metal site. Auto transport
37
+ * keeps one- and two-metal deployments on their private addresses and uses
38
+ * WARP only after a third Metal site is admitted. Network-specific deploy
39
+ * steps still own readiness probes; a failed private route must stop the
40
+ * release, never silently broaden authority by installing WARP.
41
+ */
42
+ export declare function planDeploymentTopology(args: {
43
+ target: Pick<ResolvedDeploymentTarget, 'name' | 'topology'>;
44
+ placements: readonly DeploymentTopologyPlacement[];
45
+ generation: string;
46
+ }): DeploymentTopologyAssignment[];
@@ -0,0 +1,113 @@
1
+ // src/deployment-topology.ts
2
+ import { isIP } from "node:net";
3
+
4
+ class DeploymentTopologyError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "DeploymentTopologyError";
8
+ }
9
+ }
10
+ var SAFE = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;
11
+ var PRIVATE_V4 = (value) => {
12
+ const parts = value.split(".").map(Number);
13
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
14
+ };
15
+ var validSiteCidr = (value) => {
16
+ const match = value.match(/^((?:\d{1,3}\.){3})0\/24$/);
17
+ return Boolean(match && PRIVATE_V4(`${match[1]}1`));
18
+ };
19
+ function planDeploymentTopology(args) {
20
+ const topology = args.target.topology;
21
+ if (!topology)
22
+ throw new DeploymentTopologyError(`target ${args.target.name} has no per-metal topology`);
23
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(args.target.name) || !/^[A-Za-z0-9._:-]{1,128}$/.test(args.generation)) {
24
+ throw new DeploymentTopologyError("topology identity is invalid");
25
+ }
26
+ if (args.placements.length < 1 || args.placements.length > 1024)
27
+ throw new DeploymentTopologyError("topology requires 1 to 1024 placements");
28
+ const references = new Set;
29
+ const slots = new Set;
30
+ for (const placement of args.placements) {
31
+ if (!SAFE.test(placement.computeReference) || !SAFE.test(placement.nodeIdentity) || !SAFE.test(placement.metalHostname) || isIP(placement.privateAddress) !== 4 || !PRIVATE_V4(placement.privateAddress) || !validSiteCidr(placement.siteCidr) || !placement.privateAddress.startsWith(placement.siteCidr.slice(0, -4)) || !Number.isSafeInteger(placement.slot) || placement.slot < 0 || placement.slot > 1e6 || references.has(placement.computeReference) || slots.has(placement.slot)) {
32
+ throw new DeploymentTopologyError("topology placement is malformed or duplicated");
33
+ }
34
+ references.add(placement.computeReference);
35
+ slots.add(placement.slot);
36
+ }
37
+ const sites = new Map;
38
+ for (const placement of args.placements) {
39
+ const members = sites.get(placement.metalHostname) ?? [];
40
+ members.push({ ...placement });
41
+ sites.set(placement.metalHostname, members);
42
+ }
43
+ for (const members of sites.values())
44
+ members.sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference));
45
+ const siteCidrs = new Map;
46
+ for (const [site, members] of sites) {
47
+ const cidrs = new Set(members.map(({ siteCidr }) => siteCidr));
48
+ if (cidrs.size !== 1)
49
+ throw new DeploymentTopologyError(`metal ${site} has inconsistent private CIDRs`);
50
+ siteCidrs.set(site, members[0].siteCidr);
51
+ }
52
+ if (new Set(siteCidrs.values()).size !== siteCidrs.size) {
53
+ throw new DeploymentTopologyError("deployment metals require non-overlapping private CIDRs");
54
+ }
55
+ const required = 1 + topology.relays.standbyPerMetal;
56
+ for (const [site, members] of sites)
57
+ if (members.length < required) {
58
+ throw new DeploymentTopologyError(`metal ${site} cannot satisfy its relay and standby topology`);
59
+ }
60
+ const siteNames = [...sites.keys()].sort();
61
+ let crossTransport;
62
+ if (siteNames.length <= 2)
63
+ crossTransport = "private-lan";
64
+ else {
65
+ if (topology.crossMetal.mode === "private-lan") {
66
+ throw new DeploymentTopologyError("three or more metals require the declared Mesh/WARP authority");
67
+ }
68
+ crossTransport = "cloudflare-warp";
69
+ }
70
+ const active = new Map(siteNames.map((site) => [site, sites.get(site)[0]]));
71
+ const memberIdentities = [...args.placements].sort((left, right) => left.slot - right.slot || left.computeReference.localeCompare(right.computeReference)).map(({ nodeIdentity }) => nodeIdentity);
72
+ if (new Set(memberIdentities).size !== memberIdentities.length) {
73
+ throw new DeploymentTopologyError("deployment node identities must be unique");
74
+ }
75
+ if (!Number.isSafeInteger(topology.relays.healthPort) || topology.relays.healthPort < 1 || topology.relays.healthPort > 65535 || !Array.isArray(topology.relays.routedTcpPorts) || topology.relays.routedTcpPorts.length < 1 || topology.relays.routedTcpPorts.length > 64 || new Set(topology.relays.routedTcpPorts).size !== topology.relays.routedTcpPorts.length || !topology.relays.routedTcpPorts.includes(topology.relays.healthPort) || topology.relays.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535)) {
76
+ throw new DeploymentTopologyError("relay health and routed TCP ports are malformed");
77
+ }
78
+ return siteNames.flatMap((site) => {
79
+ const members = sites.get(site);
80
+ const relay = active.get(site);
81
+ const remoteRelayAddresses = siteNames.filter((other) => other !== site).map((other) => active.get(other).privateAddress);
82
+ const remoteRelayIdentities = siteNames.filter((other) => other !== site).map((other) => active.get(other).nodeIdentity);
83
+ const remoteSiteCidrs = siteNames.filter((other) => other !== site).map((other) => siteCidrs.get(other));
84
+ return members.map((placement, index) => {
85
+ const role = index === 0 ? "relay" : index === 1 && topology.relays.standbyPerMetal === 1 ? "standby" : "member";
86
+ const crossSite = role !== "member" && siteNames.length > 1;
87
+ const localPeers = role === "relay" ? members.filter(({ privateAddress }) => privateAddress !== placement.privateAddress) : [relay];
88
+ const localPeerAddresses = role === "relay" ? localPeers.map(({ privateAddress }) => privateAddress) : [relay.privateAddress];
89
+ const localPeerIdentities = role === "relay" ? localPeers.map(({ nodeIdentity }) => nodeIdentity) : [relay.nodeIdentity];
90
+ return {
91
+ ...placement,
92
+ role,
93
+ transport: crossSite ? crossTransport : "private-lan",
94
+ localRelayReference: relay.computeReference,
95
+ localRelayAddress: relay.privateAddress,
96
+ localPeerAddresses,
97
+ localPeerIdentities,
98
+ remoteRelayAddresses: crossSite ? remoteRelayAddresses : [],
99
+ remoteRelayIdentities: crossSite ? remoteRelayIdentities : [],
100
+ remoteSiteCidrs: siteNames.length > 1 ? remoteSiteCidrs : [],
101
+ healthPort: topology.relays.healthPort,
102
+ routedTcpPorts: [...topology.relays.routedTcpPorts],
103
+ memberIdentities,
104
+ warpRequired: crossSite && crossTransport === "cloudflare-warp",
105
+ generation: args.generation
106
+ };
107
+ });
108
+ });
109
+ }
110
+ export {
111
+ planDeploymentTopology,
112
+ DeploymentTopologyError
113
+ };
@@ -36,6 +36,25 @@ export interface DeploymentRequest {
36
36
  sharing: ResolvedDeploymentTarget['allocation']['sharing'];
37
37
  resources: ResolvedDeploymentTarget['allocation']['resources'];
38
38
  connectivity?: DeploymentConnectivityIntent;
39
+ topology?: {
40
+ site: string;
41
+ nodeIdentity: string;
42
+ role: 'relay' | 'standby' | 'member';
43
+ transport: 'private-lan' | 'cloudflare-warp';
44
+ siteCidr: string;
45
+ localRelayReference: string;
46
+ localRelayAddress: string;
47
+ localPeerAddresses: readonly string[];
48
+ localPeerIdentities: readonly string[];
49
+ remoteRelayAddresses: readonly string[];
50
+ remoteRelayIdentities: readonly string[];
51
+ remoteSiteCidrs: readonly string[];
52
+ memberIdentities: readonly string[];
53
+ healthPort: number;
54
+ routedTcpPorts: readonly number[];
55
+ warpRequired: boolean;
56
+ generation: string;
57
+ };
39
58
  };
40
59
  /** Connector-scoped, one-claim capabilities. Never the project Cloudflare management token. */
41
60
  connectivityCapabilities?: DeploymentConnectivityCapabilities;
@@ -121,6 +140,27 @@ export interface ResolvedDeploymentTarget {
121
140
  };
122
141
  };
123
142
  };
143
+ topology?: {
144
+ groupBy: 'metal';
145
+ relays: {
146
+ activePerMetal: 1;
147
+ standbyPerMetal: 0 | 1;
148
+ healthPort: number;
149
+ routedTcpPorts: readonly number[];
150
+ };
151
+ crossMetal: {
152
+ mode: 'private-lan';
153
+ } | {
154
+ mode: 'cloudflare-warp';
155
+ credential: string;
156
+ network: string;
157
+ } | {
158
+ mode: 'auto';
159
+ directPrivateMaximumMetals: 2;
160
+ credential: string;
161
+ network: string;
162
+ };
163
+ };
124
164
  }
125
165
  /**
126
166
  * How the credential-bearing agent may read one server-owned Git source.