@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/README.md
CHANGED
|
@@ -64,7 +64,8 @@ Every row links to the detailed explanation and named-import/example area below.
|
|
|
64
64
|
| @forgezero/agent/definition | Legacy version 3 JSON deployment compatibility contract. | Bun/Node host | [Reference + usage](#forgezero-agent-definition) |
|
|
65
65
|
| @forgezero/agent/deploy | Typed deployment authoring builders, built-in provider requirements and versioned action constructors. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy) |
|
|
66
66
|
| @forgezero/agent/deployment-targets | Bounded input resolution and stable compute-target assignment for typed plans; emits requirements and slots rather than physical hostnames. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-targets) |
|
|
67
|
-
| @forgezero/agent/deployment-connectivity | Root-owned
|
|
67
|
+
| @forgezero/agent/deployment-connectivity | Root-owned convergence of exact private routes, routed-port firewall rules and per-target Cloudflare Tunnel or Mesh connector capabilities without exposing the project management token to a compute. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-connectivity) |
|
|
68
|
+
| @forgezero/agent/deployment-topology | Pure per-Metal relay election and hierarchical peer assignment: one or two Metals stay direct-private, while three or more grant Mesh/WARP only to relay and standby. | Bun/Node host | [Reference + usage](#forgezero-agent-deployment-topology) |
|
|
68
69
|
| @forgezero/agent/deploy-plan | Runtime validation, canonicalization and semantic digest for inert deployment plans. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-plan) |
|
|
69
70
|
| @forgezero/agent/deploy-plan-runner | Conditional DAG execution with bounded retry, compensation and failure policy over injected action handlers. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-plan-runner) |
|
|
70
71
|
| @forgezero/agent/deploy-compiler | Explicit developer/CI compiler from forgezero.deploy.ts to canonical .fz/deploy.plan.json; never used to execute project TypeScript in production. | Bun/Node host | [Reference + usage](#forgezero-agent-deploy-compiler) |
|
|
@@ -1067,7 +1068,7 @@ export const selectedCapability = DEPLOY_SCHEMA_URL;
|
|
|
1067
1068
|
<a id="forgezero-agent-deploy"></a>
|
|
1068
1069
|
## @forgezero/agent/deploy
|
|
1069
1070
|
|
|
1070
|
-
Typed deployment authoring builders, built-in provider requirements and versioned action constructors. This entry exposes 14 named value exports and
|
|
1071
|
+
Typed deployment authoring builders, built-in provider requirements and versioned action constructors. This entry exposes 14 named value exports and 42 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
|
|
1071
1072
|
|
|
1072
1073
|
```text
|
|
1073
1074
|
import {
|
|
@@ -1096,6 +1097,7 @@ import type {
|
|
|
1096
1097
|
ComponentDefinition,
|
|
1097
1098
|
ComputeConnectivityDefinition,
|
|
1098
1099
|
ComputePoolDefinition,
|
|
1100
|
+
ComputeTopologyDefinition,
|
|
1099
1101
|
Condition,
|
|
1100
1102
|
ConditionOperand,
|
|
1101
1103
|
ContainerdProviderConfig,
|
|
@@ -1134,7 +1136,7 @@ import type {
|
|
|
1134
1136
|
|
|
1135
1137
|
## @forgezero/agent/deploy — Author a typed, placed containerd deployment
|
|
1136
1138
|
|
|
1137
|
-
TypeScript is authoring only. The checked-in canonical plan is what Agents validate and execute. Targets declare bounded resource/cardinality intent, not hostnames. The API reserves eligible project capacity or provisions only under the reviewed plan and spend ceiling, assigns stable slots, and the Agent runs only its exact assignment. runc containers must declare hard CPU, memory and PID limits, bounded storage, loopback ingress and a health-gated rollout; select Kata QEMU SNP when the workload needs host isolation.
|
|
1139
|
+
TypeScript is authoring only. The checked-in canonical plan is what Agents validate and execute. Targets declare bounded resource/cardinality intent, not hostnames. The API reserves eligible project capacity or provisions only under the reviewed plan and spend ceiling, assigns stable slots, and the Agent runs only its exact assignment. An optional per-metal topology elects one active relay and one standby without naming computes: one or two Metals use proven private relay links and never install WARP, while three or more give Mesh/WARP authority only to relay and standby. runc containers must declare hard CPU, memory and PID limits, bounded storage, loopback ingress and a health-gated rollout; select Kata QEMU SNP when the workload needs host isolation.
|
|
1138
1140
|
|
|
1139
1141
|
```text
|
|
1140
1142
|
import {
|
|
@@ -1174,7 +1176,23 @@ export default defineDeployment({
|
|
|
1174
1176
|
connectivity: {
|
|
1175
1177
|
private: { mode: 'private-lan' },
|
|
1176
1178
|
public: { mode: 'cloudflare-tunnel', credential: 'cloudflare', zone: 'example.com', hostname: { mode: 'indexed', prefix: 'api' } }
|
|
1177
|
-
}
|
|
1179
|
+
},
|
|
1180
|
+
topology: {
|
|
1181
|
+
groupBy: 'metal',
|
|
1182
|
+
relays: {
|
|
1183
|
+
activePerMetal: 1,
|
|
1184
|
+
standbyPerMetal: 1,
|
|
1185
|
+
healthPort: 3000,
|
|
1186
|
+
routedTcpPorts: [3000]
|
|
1187
|
+
},
|
|
1188
|
+
crossMetal: {
|
|
1189
|
+
mode: 'auto',
|
|
1190
|
+
directPrivateMaximumMetals: 2,
|
|
1191
|
+
credential: 'cloudflare',
|
|
1192
|
+
network: 'orders-api'
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
placement: { spreadBy: ['metal'], antiAffinity: ['orders-api'] }
|
|
1178
1196
|
}) },
|
|
1179
1197
|
requirements: {
|
|
1180
1198
|
containerd: providers.containerd.require(), nginx: providers.nginx.require(),
|
|
@@ -1237,7 +1255,7 @@ export const selectedCapability = DeploymentInputError;
|
|
|
1237
1255
|
<a id="forgezero-agent-deployment-connectivity"></a>
|
|
1238
1256
|
## @forgezero/agent/deployment-connectivity
|
|
1239
1257
|
|
|
1240
|
-
Root-owned
|
|
1258
|
+
Root-owned convergence of exact private routes, routed-port firewall rules and per-target Cloudflare Tunnel or Mesh connector capabilities without exposing the project management token to a compute. This entry exposes 1 named value export and 6 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
|
|
1241
1259
|
|
|
1242
1260
|
```text
|
|
1243
1261
|
import {
|
|
@@ -1250,6 +1268,7 @@ import type {
|
|
|
1250
1268
|
DeploymentConnectivityHost,
|
|
1251
1269
|
DeploymentConnectivityIntent,
|
|
1252
1270
|
DeploymentConnectivityRequest,
|
|
1271
|
+
DeploymentConnectivityTopology,
|
|
1253
1272
|
} from '@forgezero/agent/deployment-connectivity';
|
|
1254
1273
|
```
|
|
1255
1274
|
|
|
@@ -1265,6 +1284,37 @@ import {
|
|
|
1265
1284
|
export const selectedCapability = applyDeploymentConnectivity;
|
|
1266
1285
|
```
|
|
1267
1286
|
|
|
1287
|
+
<a id="forgezero-agent-deployment-topology"></a>
|
|
1288
|
+
## @forgezero/agent/deployment-topology
|
|
1289
|
+
|
|
1290
|
+
Pure per-Metal relay election and hierarchical peer assignment: one or two Metals stay direct-private, while three or more grant Mesh/WARP only to relay and standby. This entry exposes 2 named value exports and 4 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
|
|
1291
|
+
|
|
1292
|
+
```text
|
|
1293
|
+
import {
|
|
1294
|
+
DeploymentTopologyError,
|
|
1295
|
+
planDeploymentTopology,
|
|
1296
|
+
} from '@forgezero/agent/deployment-topology';
|
|
1297
|
+
|
|
1298
|
+
import type {
|
|
1299
|
+
DeploymentTopologyAssignment,
|
|
1300
|
+
DeploymentTopologyPlacement,
|
|
1301
|
+
DeploymentTopologyRole,
|
|
1302
|
+
DeploymentTopologyTransport,
|
|
1303
|
+
} from '@forgezero/agent/deployment-topology';
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
## @forgezero/agent/deployment-topology — Use this entry point
|
|
1307
|
+
|
|
1308
|
+
This minimal executable use imports one concrete value from this exact entry point without a wildcard or package-root detour. Keep the value or values the application actually needs; the full named inventory remains directly above it.
|
|
1309
|
+
|
|
1310
|
+
```text
|
|
1311
|
+
import {
|
|
1312
|
+
DeploymentTopologyError,
|
|
1313
|
+
} from '@forgezero/agent/deployment-topology';
|
|
1314
|
+
|
|
1315
|
+
export const selectedCapability = DeploymentTopologyError;
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1268
1318
|
<a id="forgezero-agent-deploy-plan"></a>
|
|
1269
1319
|
## @forgezero/agent/deploy-plan
|
|
1270
1320
|
|
|
@@ -1645,7 +1695,7 @@ if (!validate(deploymentDefinition)) throw new Error(JSON.stringify(validate.err
|
|
|
1645
1695
|
<a id="forgezero-agent-software"></a>
|
|
1646
1696
|
## @forgezero/agent/software
|
|
1647
1697
|
|
|
1648
|
-
Exact managed-software inventory, requirement and evidence contracts. This entry exposes
|
|
1698
|
+
Exact managed-software inventory, requirement and evidence contracts. This entry exposes 14 named value exports and 10 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
|
|
1649
1699
|
|
|
1650
1700
|
```text
|
|
1651
1701
|
import {
|
|
@@ -1660,6 +1710,7 @@ import {
|
|
|
1660
1710
|
executeSoftwareOperation,
|
|
1661
1711
|
observeSoftwareHost,
|
|
1662
1712
|
runSoftwareCommand,
|
|
1713
|
+
softwareDownloadCommand,
|
|
1663
1714
|
softwareSpawnFailure,
|
|
1664
1715
|
validateSoftwareRequirements,
|
|
1665
1716
|
} from '@forgezero/agent/software';
|
package/dist/agent-heartbeat.js
CHANGED
package/dist/bootstrap.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
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 [
|
|
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,
|
|
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
|
|
|
165
165
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
166
166
|
};
|
|
167
167
|
var run = runSoftwareCommand;
|
|
168
|
+
var softwareDownloadCommand = (url, destination, maximumBytes) => {
|
|
169
|
+
const source = new URL(url);
|
|
170
|
+
if (source.protocol !== "https:" || source.username || source.password)
|
|
171
|
+
throw new Error("software download must use credential-free HTTPS");
|
|
172
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
|
|
173
|
+
throw new Error("software download size bound is invalid");
|
|
174
|
+
return [
|
|
175
|
+
"/usr/bin/curl",
|
|
176
|
+
"--fail",
|
|
177
|
+
"--location",
|
|
178
|
+
"--silent",
|
|
179
|
+
"--show-error",
|
|
180
|
+
"--proto",
|
|
181
|
+
"=https",
|
|
182
|
+
"--tlsv1.2",
|
|
183
|
+
"--max-time",
|
|
184
|
+
"1800",
|
|
185
|
+
"--max-filesize",
|
|
186
|
+
String(maximumBytes),
|
|
187
|
+
"--output",
|
|
188
|
+
destination,
|
|
189
|
+
source.href
|
|
190
|
+
];
|
|
191
|
+
};
|
|
168
192
|
var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
|
|
169
|
-
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
|
|
170
|
-
if (!response.ok)
|
|
171
|
-
throw new Error(`download failed with HTTP ${response.status}`);
|
|
172
|
-
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
173
|
-
if (declared > maximumBytes)
|
|
174
|
-
throw new Error("download exceeds reviewed size bound");
|
|
175
193
|
if (existsSync(destination))
|
|
176
194
|
throw new Error("download destination already exists");
|
|
177
|
-
await
|
|
195
|
+
const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
|
|
196
|
+
if (received.exitCode !== 0) {
|
|
197
|
+
rmSync(destination, { force: true });
|
|
198
|
+
throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
|
|
199
|
+
}
|
|
178
200
|
chmodSync(destination, 384);
|
|
179
201
|
if (statSync(destination).size > maximumBytes) {
|
|
180
202
|
rmSync(destination, { force: true });
|
package/dist/definition.js
CHANGED
|
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
|
|
|
165
165
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
166
166
|
};
|
|
167
167
|
var run = runSoftwareCommand;
|
|
168
|
+
var softwareDownloadCommand = (url, destination, maximumBytes) => {
|
|
169
|
+
const source = new URL(url);
|
|
170
|
+
if (source.protocol !== "https:" || source.username || source.password)
|
|
171
|
+
throw new Error("software download must use credential-free HTTPS");
|
|
172
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
|
|
173
|
+
throw new Error("software download size bound is invalid");
|
|
174
|
+
return [
|
|
175
|
+
"/usr/bin/curl",
|
|
176
|
+
"--fail",
|
|
177
|
+
"--location",
|
|
178
|
+
"--silent",
|
|
179
|
+
"--show-error",
|
|
180
|
+
"--proto",
|
|
181
|
+
"=https",
|
|
182
|
+
"--tlsv1.2",
|
|
183
|
+
"--max-time",
|
|
184
|
+
"1800",
|
|
185
|
+
"--max-filesize",
|
|
186
|
+
String(maximumBytes),
|
|
187
|
+
"--output",
|
|
188
|
+
destination,
|
|
189
|
+
source.href
|
|
190
|
+
];
|
|
191
|
+
};
|
|
168
192
|
var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
|
|
169
|
-
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
|
|
170
|
-
if (!response.ok)
|
|
171
|
-
throw new Error(`download failed with HTTP ${response.status}`);
|
|
172
|
-
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
173
|
-
if (declared > maximumBytes)
|
|
174
|
-
throw new Error("download exceeds reviewed size bound");
|
|
175
193
|
if (existsSync(destination))
|
|
176
194
|
throw new Error("download destination already exists");
|
|
177
|
-
await
|
|
195
|
+
const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
|
|
196
|
+
if (received.exitCode !== 0) {
|
|
197
|
+
rmSync(destination, { force: true });
|
|
198
|
+
throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
|
|
199
|
+
}
|
|
178
200
|
chmodSync(destination, 384);
|
|
179
201
|
if (statSync(destination).size > maximumBytes) {
|
|
180
202
|
rmSync(destination, { force: true });
|
package/dist/deploy-compiler.js
CHANGED
|
@@ -203,7 +203,7 @@ function reference(value, where) {
|
|
|
203
203
|
const row2 = object(value, where);
|
|
204
204
|
exact3(row2, ["$ref"], where);
|
|
205
205
|
const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
|
|
206
|
-
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)) {
|
|
206
|
+
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)) {
|
|
207
207
|
fail(where, "contains an unsupported reference.");
|
|
208
208
|
}
|
|
209
209
|
return { $ref: coordinate };
|
|
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
|
|
|
635
635
|
if (!targets.has(String(scope.target)))
|
|
636
636
|
fail(`${where}.scope.target`, "must name an existing target.");
|
|
637
637
|
integer(scope.size, `${where}.scope.size`, 1, 32);
|
|
638
|
+
} else if (scope.kind === "topology-role") {
|
|
639
|
+
exact3(scope, ["kind", "target", "role"], `${where}.scope`);
|
|
640
|
+
if (!targets.has(String(scope.target)))
|
|
641
|
+
fail(`${where}.scope.target`, "must name an existing target.");
|
|
642
|
+
if (!["relay", "standby", "member"].includes(String(scope.role)))
|
|
643
|
+
fail(`${where}.scope.role`, "is unsupported.");
|
|
638
644
|
} else
|
|
639
645
|
fail(`${where}.scope.kind`, "is unsupported.");
|
|
640
646
|
if (row2.timeoutMs !== undefined)
|
|
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
|
|
|
789
795
|
const targetDefinitions = new Map;
|
|
790
796
|
for (const [targetName, value] of targetEntries) {
|
|
791
797
|
const target = object(value, `deployment.spec.targets.${targetName}`);
|
|
792
|
-
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
|
|
798
|
+
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
|
|
793
799
|
if (target.kind !== "compute")
|
|
794
800
|
fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
|
|
795
801
|
const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
|
|
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
|
|
|
911
917
|
}
|
|
912
918
|
}
|
|
913
919
|
}
|
|
920
|
+
if (target.topology !== undefined) {
|
|
921
|
+
const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
|
|
922
|
+
exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
|
|
923
|
+
if (topology.groupBy !== "metal")
|
|
924
|
+
fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
|
|
925
|
+
const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
|
|
926
|
+
exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
|
|
927
|
+
if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
|
|
928
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
|
|
929
|
+
integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
|
|
930
|
+
if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
|
|
931
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
|
|
932
|
+
}
|
|
933
|
+
for (const [index, port] of relays.routedTcpPorts.entries()) {
|
|
934
|
+
integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
|
|
935
|
+
}
|
|
936
|
+
if (!relays.routedTcpPorts.includes(relays.healthPort)) {
|
|
937
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
|
|
938
|
+
}
|
|
939
|
+
const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
940
|
+
if (crossMetal.mode === "private-lan")
|
|
941
|
+
exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
942
|
+
else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
|
|
943
|
+
exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
944
|
+
if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
|
|
945
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
|
|
946
|
+
if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
|
|
947
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
|
|
948
|
+
if (typeof crossMetal.network === "string")
|
|
949
|
+
named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
950
|
+
else
|
|
951
|
+
reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
952
|
+
} else
|
|
953
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
|
|
954
|
+
if (allocation.sharing !== "exclusive")
|
|
955
|
+
fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
|
|
956
|
+
if (minimum < 1 + Number(relays.standbyPerMetal))
|
|
957
|
+
fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
|
|
958
|
+
}
|
|
914
959
|
if (target.placement !== undefined)
|
|
915
960
|
jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
|
|
916
961
|
targetDefinitions.set(targetName, target);
|
|
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
|
|
|
934
979
|
if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
935
980
|
fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
|
|
936
981
|
}
|
|
982
|
+
if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
983
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
|
|
984
|
+
}
|
|
937
985
|
}
|
|
938
986
|
const components = object(spec.components, "deployment.spec.components");
|
|
939
987
|
const componentEntries = Object.entries(components);
|
package/dist/deploy-file.js
CHANGED
|
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
|
|
|
165
165
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
166
166
|
};
|
|
167
167
|
var run = runSoftwareCommand;
|
|
168
|
+
var softwareDownloadCommand = (url, destination, maximumBytes) => {
|
|
169
|
+
const source = new URL(url);
|
|
170
|
+
if (source.protocol !== "https:" || source.username || source.password)
|
|
171
|
+
throw new Error("software download must use credential-free HTTPS");
|
|
172
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
|
|
173
|
+
throw new Error("software download size bound is invalid");
|
|
174
|
+
return [
|
|
175
|
+
"/usr/bin/curl",
|
|
176
|
+
"--fail",
|
|
177
|
+
"--location",
|
|
178
|
+
"--silent",
|
|
179
|
+
"--show-error",
|
|
180
|
+
"--proto",
|
|
181
|
+
"=https",
|
|
182
|
+
"--tlsv1.2",
|
|
183
|
+
"--max-time",
|
|
184
|
+
"1800",
|
|
185
|
+
"--max-filesize",
|
|
186
|
+
String(maximumBytes),
|
|
187
|
+
"--output",
|
|
188
|
+
destination,
|
|
189
|
+
source.href
|
|
190
|
+
];
|
|
191
|
+
};
|
|
168
192
|
var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
|
|
169
|
-
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
|
|
170
|
-
if (!response.ok)
|
|
171
|
-
throw new Error(`download failed with HTTP ${response.status}`);
|
|
172
|
-
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
173
|
-
if (declared > maximumBytes)
|
|
174
|
-
throw new Error("download exceeds reviewed size bound");
|
|
175
193
|
if (existsSync(destination))
|
|
176
194
|
throw new Error("download destination already exists");
|
|
177
|
-
await
|
|
195
|
+
const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
|
|
196
|
+
if (received.exitCode !== 0) {
|
|
197
|
+
rmSync(destination, { force: true });
|
|
198
|
+
throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
|
|
199
|
+
}
|
|
178
200
|
chmodSync(destination, 384);
|
|
179
201
|
if (statSync(destination).size > maximumBytes) {
|
|
180
202
|
rmSync(destination, { force: true });
|
|
@@ -203,7 +203,7 @@ function reference(value, where) {
|
|
|
203
203
|
const row2 = object(value, where);
|
|
204
204
|
exact3(row2, ["$ref"], where);
|
|
205
205
|
const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
|
|
206
|
-
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)) {
|
|
206
|
+
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)) {
|
|
207
207
|
fail(where, "contains an unsupported reference.");
|
|
208
208
|
}
|
|
209
209
|
return { $ref: coordinate };
|
|
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
|
|
|
635
635
|
if (!targets.has(String(scope.target)))
|
|
636
636
|
fail(`${where}.scope.target`, "must name an existing target.");
|
|
637
637
|
integer(scope.size, `${where}.scope.size`, 1, 32);
|
|
638
|
+
} else if (scope.kind === "topology-role") {
|
|
639
|
+
exact3(scope, ["kind", "target", "role"], `${where}.scope`);
|
|
640
|
+
if (!targets.has(String(scope.target)))
|
|
641
|
+
fail(`${where}.scope.target`, "must name an existing target.");
|
|
642
|
+
if (!["relay", "standby", "member"].includes(String(scope.role)))
|
|
643
|
+
fail(`${where}.scope.role`, "is unsupported.");
|
|
638
644
|
} else
|
|
639
645
|
fail(`${where}.scope.kind`, "is unsupported.");
|
|
640
646
|
if (row2.timeoutMs !== undefined)
|
|
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
|
|
|
789
795
|
const targetDefinitions = new Map;
|
|
790
796
|
for (const [targetName, value] of targetEntries) {
|
|
791
797
|
const target = object(value, `deployment.spec.targets.${targetName}`);
|
|
792
|
-
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
|
|
798
|
+
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
|
|
793
799
|
if (target.kind !== "compute")
|
|
794
800
|
fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
|
|
795
801
|
const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
|
|
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
|
|
|
911
917
|
}
|
|
912
918
|
}
|
|
913
919
|
}
|
|
920
|
+
if (target.topology !== undefined) {
|
|
921
|
+
const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
|
|
922
|
+
exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
|
|
923
|
+
if (topology.groupBy !== "metal")
|
|
924
|
+
fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
|
|
925
|
+
const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
|
|
926
|
+
exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
|
|
927
|
+
if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
|
|
928
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
|
|
929
|
+
integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
|
|
930
|
+
if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
|
|
931
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
|
|
932
|
+
}
|
|
933
|
+
for (const [index, port] of relays.routedTcpPorts.entries()) {
|
|
934
|
+
integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
|
|
935
|
+
}
|
|
936
|
+
if (!relays.routedTcpPorts.includes(relays.healthPort)) {
|
|
937
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
|
|
938
|
+
}
|
|
939
|
+
const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
940
|
+
if (crossMetal.mode === "private-lan")
|
|
941
|
+
exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
942
|
+
else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
|
|
943
|
+
exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
944
|
+
if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
|
|
945
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
|
|
946
|
+
if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
|
|
947
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
|
|
948
|
+
if (typeof crossMetal.network === "string")
|
|
949
|
+
named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
950
|
+
else
|
|
951
|
+
reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
952
|
+
} else
|
|
953
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
|
|
954
|
+
if (allocation.sharing !== "exclusive")
|
|
955
|
+
fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
|
|
956
|
+
if (minimum < 1 + Number(relays.standbyPerMetal))
|
|
957
|
+
fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
|
|
958
|
+
}
|
|
914
959
|
if (target.placement !== undefined)
|
|
915
960
|
jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
|
|
916
961
|
targetDefinitions.set(targetName, target);
|
|
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
|
|
|
934
979
|
if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
935
980
|
fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
|
|
936
981
|
}
|
|
982
|
+
if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
983
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
|
|
984
|
+
}
|
|
937
985
|
}
|
|
938
986
|
const components = object(spec.components, "deployment.spec.components");
|
|
939
987
|
const componentEntries = Object.entries(components);
|
package/dist/deploy-plan.js
CHANGED
|
@@ -203,7 +203,7 @@ function reference(value, where) {
|
|
|
203
203
|
const row2 = object(value, where);
|
|
204
204
|
exact3(row2, ["$ref"], where);
|
|
205
205
|
const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
|
|
206
|
-
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)) {
|
|
206
|
+
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)) {
|
|
207
207
|
fail(where, "contains an unsupported reference.");
|
|
208
208
|
}
|
|
209
209
|
return { $ref: coordinate };
|
|
@@ -635,6 +635,12 @@ function validateStep(value, where, targets, context) {
|
|
|
635
635
|
if (!targets.has(String(scope.target)))
|
|
636
636
|
fail(`${where}.scope.target`, "must name an existing target.");
|
|
637
637
|
integer(scope.size, `${where}.scope.size`, 1, 32);
|
|
638
|
+
} else if (scope.kind === "topology-role") {
|
|
639
|
+
exact3(scope, ["kind", "target", "role"], `${where}.scope`);
|
|
640
|
+
if (!targets.has(String(scope.target)))
|
|
641
|
+
fail(`${where}.scope.target`, "must name an existing target.");
|
|
642
|
+
if (!["relay", "standby", "member"].includes(String(scope.role)))
|
|
643
|
+
fail(`${where}.scope.role`, "is unsupported.");
|
|
638
644
|
} else
|
|
639
645
|
fail(`${where}.scope.kind`, "is unsupported.");
|
|
640
646
|
if (row2.timeoutMs !== undefined)
|
|
@@ -789,7 +795,7 @@ function compileDeployment(sourceValue) {
|
|
|
789
795
|
const targetDefinitions = new Map;
|
|
790
796
|
for (const [targetName, value] of targetEntries) {
|
|
791
797
|
const target = object(value, `deployment.spec.targets.${targetName}`);
|
|
792
|
-
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
|
|
798
|
+
exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
|
|
793
799
|
if (target.kind !== "compute")
|
|
794
800
|
fail(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
|
|
795
801
|
const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
|
|
@@ -911,6 +917,45 @@ function compileDeployment(sourceValue) {
|
|
|
911
917
|
}
|
|
912
918
|
}
|
|
913
919
|
}
|
|
920
|
+
if (target.topology !== undefined) {
|
|
921
|
+
const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
|
|
922
|
+
exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
|
|
923
|
+
if (topology.groupBy !== "metal")
|
|
924
|
+
fail(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
|
|
925
|
+
const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
|
|
926
|
+
exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
|
|
927
|
+
if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
|
|
928
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
|
|
929
|
+
integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
|
|
930
|
+
if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
|
|
931
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
|
|
932
|
+
}
|
|
933
|
+
for (const [index, port] of relays.routedTcpPorts.entries()) {
|
|
934
|
+
integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
|
|
935
|
+
}
|
|
936
|
+
if (!relays.routedTcpPorts.includes(relays.healthPort)) {
|
|
937
|
+
fail(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
|
|
938
|
+
}
|
|
939
|
+
const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
940
|
+
if (crossMetal.mode === "private-lan")
|
|
941
|
+
exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
942
|
+
else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
|
|
943
|
+
exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
|
|
944
|
+
if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
|
|
945
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
|
|
946
|
+
if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
|
|
947
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
|
|
948
|
+
if (typeof crossMetal.network === "string")
|
|
949
|
+
named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
950
|
+
else
|
|
951
|
+
reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
|
|
952
|
+
} else
|
|
953
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
|
|
954
|
+
if (allocation.sharing !== "exclusive")
|
|
955
|
+
fail(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
|
|
956
|
+
if (minimum < 1 + Number(relays.standbyPerMetal))
|
|
957
|
+
fail(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
|
|
958
|
+
}
|
|
914
959
|
if (target.placement !== undefined)
|
|
915
960
|
jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
|
|
916
961
|
targetDefinitions.set(targetName, target);
|
|
@@ -934,6 +979,9 @@ function compileDeployment(sourceValue) {
|
|
|
934
979
|
if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
935
980
|
fail(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
|
|
936
981
|
}
|
|
982
|
+
if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
|
|
983
|
+
fail(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
|
|
984
|
+
}
|
|
937
985
|
}
|
|
938
986
|
const components = object(spec.components, "deployment.spec.components");
|
|
939
987
|
const componentEntries = Object.entries(components);
|