@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/README.md +57 -6
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +21 -6
- package/dist/deploy-compiler.js +50 -2
- package/dist/deploy-plan-runner.js +50 -2
- package/dist/deploy-plan.js +50 -2
- package/dist/deploy.d.ts +37 -1
- package/dist/deploy.js +1 -0
- package/dist/deployment-connectivity.d.ts +26 -0
- package/dist/deployment-connectivity.js +128 -1
- package/dist/deployment-targets.js +15 -1
- package/dist/deployment-topology.d.ts +46 -0
- package/dist/deployment-topology.js +113 -0
- package/dist/deployment.d.ts +40 -0
- package/dist/fz-agent.js +368 -14
- package/dist/fz.js +75 -12
- package/dist/index.d.ts +1 -0
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.js +21 -6
- package/dist/platform-bootstrap-runtime.d.ts +4 -1
- package/dist/platform-bootstrap-runtime.js +18 -4
- package/dist/platform-fleet-verification.js +147 -6
- package/dist/provision.js +129 -2
- package/dist/software-helper.js +128 -1
- package/dist/version.d.ts +1 -1
- package/package.json +5 -1
|
@@ -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
|
+
};
|
package/dist/deployment.d.ts
CHANGED
|
@@ -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.
|