@forgezero/agent 0.1.78 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -6
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +21 -6
- package/dist/community-rehearsal-host.js +29 -7
- package/dist/definition.js +29 -7
- package/dist/deploy-compiler.js +50 -2
- package/dist/deploy-file.js +29 -7
- 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 +397 -21
- package/dist/fz.js +75 -12
- package/dist/index.d.ts +1 -0
- package/dist/metal-bootstrap.js +1 -1
- package/dist/metal-helper-socket.js +29 -7
- package/dist/metal-provision.js +29 -7
- package/dist/operator-bootstrap.js +21 -6
- package/dist/platform-bootstrap-runtime.d.ts +4 -1
- package/dist/platform-bootstrap-runtime.js +47 -11
- package/dist/platform-fleet-verification.js +176 -13
- package/dist/platform-genesis.js +29 -7
- package/dist/provision.js +158 -9
- package/dist/software-helper.js +157 -8
- package/dist/software.d.ts +1 -0
- package/dist/software.js +30 -7
- package/dist/ubuntu.js +29 -7
- package/dist/version.d.ts +1 -1
- package/package.json +5 -1
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
|
+
};
|
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.
|