@forgezero/agent 0.1.79 → 0.1.81
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 +59 -7
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +21 -6
- package/dist/cli/app-build.d.ts +10 -0
- 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 +423 -30
- package/dist/fz.js +155 -26
- package/dist/index.d.ts +1 -0
- package/dist/metal-bootstrap.js +13 -1
- package/dist/metal-helper-socket.js +55 -16
- package/dist/metal-provision.d.ts +6 -3
- package/dist/metal-provision.js +55 -16
- package/dist/operator-bootstrap.js +33 -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
package/dist/metal-provision.js
CHANGED
|
@@ -748,6 +748,32 @@ function membersOfLinuxList(value, label) {
|
|
|
748
748
|
throw new MetalProvisionError(`${label} list overlaps itself`);
|
|
749
749
|
return members;
|
|
750
750
|
}
|
|
751
|
+
function linuxList(members) {
|
|
752
|
+
const sorted = [...new Set(members)].sort((left, right) => left - right);
|
|
753
|
+
const ranges = [];
|
|
754
|
+
for (let index = 0;index < sorted.length; ) {
|
|
755
|
+
const start = sorted[index];
|
|
756
|
+
let end = start;
|
|
757
|
+
while (index + 1 < sorted.length && sorted[index + 1] === end + 1) {
|
|
758
|
+
index += 1;
|
|
759
|
+
end = sorted[index];
|
|
760
|
+
}
|
|
761
|
+
ranges.push(start === end ? String(start) : `${start}-${end}`);
|
|
762
|
+
index += 1;
|
|
763
|
+
}
|
|
764
|
+
return ranges.join(",");
|
|
765
|
+
}
|
|
766
|
+
function physicalCoreGroups(pool) {
|
|
767
|
+
const cpus = membersOfLinuxList(pool.cpus, "CPU");
|
|
768
|
+
if (cpus.length % pool.physicalCores !== 0) {
|
|
769
|
+
throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
|
|
770
|
+
}
|
|
771
|
+
const threadsPerCore = cpus.length / pool.physicalCores;
|
|
772
|
+
if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
|
|
773
|
+
throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
|
|
774
|
+
}
|
|
775
|
+
return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
|
|
776
|
+
}
|
|
751
777
|
var guestNameFor = (computeKey) => `fzg-${createHash2("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
|
|
752
778
|
var tapNameFor = (computeKey) => `fzt${createHash2("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
|
|
753
779
|
var macForAddress = (address) => {
|
|
@@ -805,6 +831,7 @@ function validateMetalProfile(profile) {
|
|
|
805
831
|
if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
|
|
806
832
|
throw new MetalProvisionError("invalid CPU pool physical-core count");
|
|
807
833
|
}
|
|
834
|
+
physicalCoreGroups(pool);
|
|
808
835
|
for (const cpu of cpus) {
|
|
809
836
|
if (assigned.has(cpu))
|
|
810
837
|
throw new MetalProvisionError("CPU pools overlap");
|
|
@@ -874,26 +901,38 @@ function allocateCpuPool(profile, claim, rows) {
|
|
|
874
901
|
const prior = rows.find((row) => row.computeKey === claim.computeKey);
|
|
875
902
|
if (prior) {
|
|
876
903
|
const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
|
|
877
|
-
|
|
904
|
+
const groups2 = retained ? physicalCoreGroups(retained) : [];
|
|
905
|
+
const retainedCpus = new Set(membersOfLinuxList(prior.allowedCpus, "persisted guest CPU"));
|
|
906
|
+
const matchedGroups = groups2.filter((group) => group.every((cpu) => retainedCpus.has(cpu)));
|
|
907
|
+
if (!retained || retained.memoryNodes !== prior.allowedMemoryNodes || matchedGroups.length !== claim.spec.physicalCores || matchedGroups.flat().length !== retainedCpus.size || retainedCpus.size < claim.spec.vcpu || claim.spec.cpuPoolKey !== undefined && claim.spec.cpuPoolKey !== retained.key) {
|
|
878
908
|
throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
|
|
879
909
|
}
|
|
880
|
-
return
|
|
910
|
+
return {
|
|
911
|
+
key: retained.key,
|
|
912
|
+
cpus: prior.allowedCpus,
|
|
913
|
+
physicalCores: matchedGroups.length,
|
|
914
|
+
memoryNodes: prior.allowedMemoryNodes
|
|
915
|
+
};
|
|
881
916
|
}
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
return requested;
|
|
891
|
-
}
|
|
892
|
-
const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
|
|
917
|
+
const usedCpus = new Set(rows.flatMap((row) => membersOfLinuxList(row.allowedCpus, "persisted guest CPU")));
|
|
918
|
+
const candidates = profile.cpuPools.filter((pool) => !claim.spec.cpuPoolKey || pool.key === claim.spec.cpuPoolKey).map((pool) => ({
|
|
919
|
+
pool,
|
|
920
|
+
available: physicalCoreGroups(pool).filter((group) => group.every((cpu) => !usedCpus.has(cpu)))
|
|
921
|
+
})).filter(({ available }) => {
|
|
922
|
+
const selected2 = available.slice(0, claim.spec.physicalCores);
|
|
923
|
+
return selected2.length === claim.spec.physicalCores && selected2.flat().length >= claim.spec.vcpu;
|
|
924
|
+
}).sort((left, right) => left.available.length - right.available.length || left.pool.key.localeCompare(right.pool.key));
|
|
893
925
|
const selected = candidates[0];
|
|
894
|
-
if (!selected)
|
|
895
|
-
throw new MetalProvisionError("no exclusive CPU
|
|
896
|
-
|
|
926
|
+
if (!selected) {
|
|
927
|
+
throw new MetalProvisionError(claim.spec.cpuPoolKey ? "requested CPU pool cannot satisfy this guest" : "no exclusive CPU capacity can satisfy this guest");
|
|
928
|
+
}
|
|
929
|
+
const groups = selected.available.slice(0, claim.spec.physicalCores);
|
|
930
|
+
return {
|
|
931
|
+
key: selected.pool.key,
|
|
932
|
+
cpus: linuxList(groups.flat()),
|
|
933
|
+
physicalCores: groups.length,
|
|
934
|
+
memoryNodes: selected.pool.memoryNodes
|
|
935
|
+
};
|
|
897
936
|
}
|
|
898
937
|
var base64 = (value) => Buffer.from(value).toString("base64");
|
|
899
938
|
var yamlFile = (path2, content, permissions) => ` - path: ${JSON.stringify(path2)}
|
|
@@ -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.81";
|
|
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,
|
|
@@ -5012,6 +5027,17 @@ function membersOfLinuxList(value, label) {
|
|
|
5012
5027
|
throw new MetalProvisionError(`${label} list overlaps itself`);
|
|
5013
5028
|
return members;
|
|
5014
5029
|
}
|
|
5030
|
+
function physicalCoreGroups(pool) {
|
|
5031
|
+
const cpus = membersOfLinuxList(pool.cpus, "CPU");
|
|
5032
|
+
if (cpus.length % pool.physicalCores !== 0) {
|
|
5033
|
+
throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
|
|
5034
|
+
}
|
|
5035
|
+
const threadsPerCore = cpus.length / pool.physicalCores;
|
|
5036
|
+
if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
|
|
5037
|
+
throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
|
|
5038
|
+
}
|
|
5039
|
+
return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
|
|
5040
|
+
}
|
|
5015
5041
|
function validateMetalProfile(profile) {
|
|
5016
5042
|
if (!SAFE_NAME2.test(profile.volumeGroup))
|
|
5017
5043
|
throw new MetalProvisionError("invalid volume group");
|
|
@@ -5060,6 +5086,7 @@ function validateMetalProfile(profile) {
|
|
|
5060
5086
|
if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
|
|
5061
5087
|
throw new MetalProvisionError("invalid CPU pool physical-core count");
|
|
5062
5088
|
}
|
|
5089
|
+
physicalCoreGroups(pool);
|
|
5063
5090
|
for (const cpu of cpus) {
|
|
5064
5091
|
if (assigned.has(cpu))
|
|
5065
5092
|
throw new MetalProvisionError("CPU pools overlap");
|
|
@@ -62,7 +62,8 @@ export interface PlatformSharedEnvironment {
|
|
|
62
62
|
publicApiPort: number;
|
|
63
63
|
sharedDirectory: string;
|
|
64
64
|
seedSyncPeers: string[];
|
|
65
|
-
|
|
65
|
+
/** Exact physical API host identities participating in this seed epoch. */
|
|
66
|
+
seedSyncMembers: string[];
|
|
66
67
|
seedSyncEpoch: string;
|
|
67
68
|
concurrencyLimit: number;
|
|
68
69
|
drainDeadlineMs: number;
|
|
@@ -120,6 +121,8 @@ export interface ApiRuntimeRenderOptions {
|
|
|
120
121
|
credentials: SystemdCredentialSpec[];
|
|
121
122
|
/** Root-owned calibration override, loaded after the ordinary shared environment. */
|
|
122
123
|
capacityEnvironmentFile?: string;
|
|
124
|
+
/** Root-owned signed deployment topology, absent until post-genesis reconciliation. */
|
|
125
|
+
topologyEnvironmentFile?: string;
|
|
123
126
|
}
|
|
124
127
|
export declare function renderPlatformApiUnits(input: ApiRuntimeRenderOptions): {
|
|
125
128
|
template: string;
|
|
@@ -811,7 +811,14 @@ function validatePlatformSharedEnvironment(input) {
|
|
|
811
811
|
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
812
812
|
}
|
|
813
813
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
814
|
-
|
|
814
|
+
if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
|
|
815
|
+
throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
|
|
816
|
+
}
|
|
817
|
+
for (const member of input.seedSyncMembers)
|
|
818
|
+
safeAtom("seedSyncMembers item", member);
|
|
819
|
+
if (!input.seedSyncMembers.includes(input.nodeHostname)) {
|
|
820
|
+
throw new Error("seedSyncMembers must include this nodeHostname.");
|
|
821
|
+
}
|
|
815
822
|
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
816
823
|
boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
|
|
817
824
|
boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
|
|
@@ -891,7 +898,7 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
891
898
|
FZ_DB_MASTER: value.databaseMaster ?? "",
|
|
892
899
|
FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
|
|
893
900
|
FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
|
|
894
|
-
FZ_SEED_SYNC_MEMBERS:
|
|
901
|
+
FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
|
|
895
902
|
FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
|
|
896
903
|
FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
|
|
897
904
|
FZ_SHARED_DIR: value.sharedDirectory,
|
|
@@ -960,7 +967,12 @@ function platformApiCredentialSpecs(options) {
|
|
|
960
967
|
];
|
|
961
968
|
}
|
|
962
969
|
function renderPlatformApiUnits(input) {
|
|
963
|
-
for (const path2 of [
|
|
970
|
+
for (const path2 of [
|
|
971
|
+
input.sharedDirectory,
|
|
972
|
+
input.sharedEnvironmentFile,
|
|
973
|
+
input.slotsDirectory,
|
|
974
|
+
...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
|
|
975
|
+
]) {
|
|
964
976
|
if (!path2.startsWith("/") || /[\r\n]/.test(path2))
|
|
965
977
|
throw new Error("Runtime paths must be absolute and single-line.");
|
|
966
978
|
}
|
|
@@ -974,6 +986,8 @@ function renderPlatformApiUnits(input) {
|
|
|
974
986
|
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
975
987
|
`);
|
|
976
988
|
const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
|
|
989
|
+
` : "";
|
|
990
|
+
const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
|
|
977
991
|
` : "";
|
|
978
992
|
const template = `[Unit]
|
|
979
993
|
Description=ForgeZero (%i slot)
|
|
@@ -987,7 +1001,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
|
|
|
987
1001
|
Environment=NODE_ENV=production
|
|
988
1002
|
Environment=FZ_SLOT=%i
|
|
989
1003
|
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
990
|
-
${capacityEnvironment}${credentials}
|
|
1004
|
+
${capacityEnvironment}${topologyEnvironment}${credentials}
|
|
991
1005
|
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
992
1006
|
Restart=always
|
|
993
1007
|
RestartSec=2
|
|
@@ -1649,6 +1649,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
|
1649
1649
|
// src/deployment-connectivity.ts
|
|
1650
1650
|
import { createHash as createHash3 } from "node:crypto";
|
|
1651
1651
|
import { mkdirSync as mkdirSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1652
|
+
import { createConnection } from "node:net";
|
|
1652
1653
|
import { dirname as dirname4 } from "node:path";
|
|
1653
1654
|
|
|
1654
1655
|
// src/process-input.ts
|
|
@@ -1701,6 +1702,21 @@ var defaultHost = {
|
|
|
1701
1702
|
const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
1702
1703
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
1703
1704
|
},
|
|
1705
|
+
probe: (address, port, timeoutMs) => new Promise((resolve3) => {
|
|
1706
|
+
const socket = createConnection({ host: address, port });
|
|
1707
|
+
let settled = false;
|
|
1708
|
+
const finish = (value) => {
|
|
1709
|
+
if (settled)
|
|
1710
|
+
return;
|
|
1711
|
+
settled = true;
|
|
1712
|
+
socket.destroy();
|
|
1713
|
+
resolve3(value);
|
|
1714
|
+
};
|
|
1715
|
+
socket.setTimeout(timeoutMs);
|
|
1716
|
+
socket.once("connect", () => finish(true));
|
|
1717
|
+
socket.once("timeout", () => finish(false));
|
|
1718
|
+
socket.once("error", () => finish(false));
|
|
1719
|
+
}),
|
|
1704
1720
|
sleep: (ms) => Bun.sleep(ms)
|
|
1705
1721
|
};
|
|
1706
1722
|
function validate(request) {
|
|
@@ -1715,7 +1731,7 @@ function validate(request) {
|
|
|
1715
1731
|
} else if (request.capabilities.public)
|
|
1716
1732
|
throw new Error("unexpected public deployment capability");
|
|
1717
1733
|
if (privateIntent?.mode === "cloudflare-warp") {
|
|
1718
|
-
if (!NAME2.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !TOKEN.test(request.capabilities.private.connectorToken)) {
|
|
1734
|
+
if (!NAME2.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !UUID.test(request.capabilities.private.siteRouteId) || !TOKEN.test(request.capabilities.private.connectorToken)) {
|
|
1719
1735
|
throw new Error("private deployment connectivity is malformed");
|
|
1720
1736
|
}
|
|
1721
1737
|
} else if (request.capabilities.private)
|
|
@@ -1725,6 +1741,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
|
|
|
1725
1741
|
validate(request);
|
|
1726
1742
|
const id = idFor(request.key);
|
|
1727
1743
|
const evidence = { key: request.key };
|
|
1744
|
+
const topology = request.topology;
|
|
1745
|
+
if (topology) {
|
|
1746
|
+
const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
|
|
1747
|
+
const identities = [
|
|
1748
|
+
topology.nodeIdentity,
|
|
1749
|
+
...topology.localPeerIdentities,
|
|
1750
|
+
...topology.remoteRelayIdentities,
|
|
1751
|
+
...topology.memberIdentities
|
|
1752
|
+
];
|
|
1753
|
+
if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => !/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
|
|
1754
|
+
throw new Error("deployment topology is malformed");
|
|
1755
|
+
}
|
|
1756
|
+
if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
|
|
1757
|
+
throw new Error("deployment topology transport differs from private connectivity intent");
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1728
1760
|
if (request.intent.public?.mode === "cloudflare-tunnel") {
|
|
1729
1761
|
const capability = request.capabilities.public;
|
|
1730
1762
|
const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
|
|
@@ -1762,6 +1794,12 @@ WantedBy=multi-user.target
|
|
|
1762
1794
|
const capability = request.capabilities.private;
|
|
1763
1795
|
const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
|
|
1764
1796
|
const unit = "forgezero-deployment-mesh.service";
|
|
1797
|
+
const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
|
|
1798
|
+
host.write(forwarding, `net.ipv4.ip_forward = 1
|
|
1799
|
+
net.ipv6.conf.all.forwarding = 1
|
|
1800
|
+
net.ipv6.conf.all.accept_ra = 2
|
|
1801
|
+
`, 420);
|
|
1802
|
+
await checked2(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
|
|
1765
1803
|
await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
|
|
1766
1804
|
host.write(`/etc/systemd/system/${unit}`, `[Unit]
|
|
1767
1805
|
Description=ForgeZero deployment Mesh/WARP connector
|
|
@@ -1796,6 +1834,95 @@ WantedBy=multi-user.target
|
|
|
1796
1834
|
if (!evidence.private)
|
|
1797
1835
|
throw new Error("WARP connector readiness failed");
|
|
1798
1836
|
}
|
|
1837
|
+
if (topology) {
|
|
1838
|
+
const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
|
|
1839
|
+
const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
|
|
1840
|
+
host.write("/etc/forgezero/deployment-topology.env", [
|
|
1841
|
+
`FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
|
|
1842
|
+
`FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
|
|
1843
|
+
`FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
|
|
1844
|
+
`FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
|
|
1845
|
+
`FZ_TOPOLOGY_SITE=${topology.site}`,
|
|
1846
|
+
`FZ_TOPOLOGY_ROLE=${topology.role}`,
|
|
1847
|
+
`FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
|
|
1848
|
+
""
|
|
1849
|
+
].join(`
|
|
1850
|
+
`), 384);
|
|
1851
|
+
const routeUnit = "forgezero-deployment-topology.service";
|
|
1852
|
+
const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
|
|
1853
|
+
const routePairs = topology.role === "member" ? topology.remoteSiteCidrs.map((network) => ({ network, via: topology.localRelayAddress })) : topology.transport === "private-lan" ? topology.remoteSiteCidrs.map((network, index) => ({ network, via: topology.remoteRelayAddresses[index] })) : [];
|
|
1854
|
+
for (const { via } of routePairs) {
|
|
1855
|
+
await checked2(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
|
|
1856
|
+
}
|
|
1857
|
+
await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
|
|
1858
|
+
const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
|
|
1859
|
+
` : "";
|
|
1860
|
+
const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
|
|
1861
|
+
`);
|
|
1862
|
+
const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
|
|
1863
|
+
`);
|
|
1864
|
+
const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
|
|
1865
|
+
` : "";
|
|
1866
|
+
host.write(routeUnitPath, `[Unit]
|
|
1867
|
+
Description=ForgeZero fenced deployment site routing
|
|
1868
|
+
After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
|
|
1869
|
+
Wants=network-online.target
|
|
1870
|
+
|
|
1871
|
+
[Service]
|
|
1872
|
+
Type=oneshot
|
|
1873
|
+
RemainAfterExit=yes
|
|
1874
|
+
${forwarding}${noOp}${starts}${starts ? `
|
|
1875
|
+
` : ""}${stops}${stops ? `
|
|
1876
|
+
` : ""}
|
|
1877
|
+
[Install]
|
|
1878
|
+
WantedBy=multi-user.target
|
|
1879
|
+
`, 420);
|
|
1880
|
+
for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
|
|
1881
|
+
for (const port of topology.routedTcpPorts) {
|
|
1882
|
+
await checked2(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
if (topology.role !== "member")
|
|
1886
|
+
for (const remoteCidr of topology.remoteSiteCidrs) {
|
|
1887
|
+
for (const port of topology.routedTcpPorts) {
|
|
1888
|
+
await checked2(host, [
|
|
1889
|
+
"/usr/sbin/ufw",
|
|
1890
|
+
"route",
|
|
1891
|
+
"allow",
|
|
1892
|
+
"proto",
|
|
1893
|
+
"tcp",
|
|
1894
|
+
"from",
|
|
1895
|
+
topology.siteCidr,
|
|
1896
|
+
"to",
|
|
1897
|
+
remoteCidr,
|
|
1898
|
+
"port",
|
|
1899
|
+
String(port)
|
|
1900
|
+
], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
|
|
1904
|
+
await checked2(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
|
|
1905
|
+
const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
|
|
1906
|
+
for (const address of [...new Set(probes)]) {
|
|
1907
|
+
await checked2(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
|
|
1908
|
+
let ready = false;
|
|
1909
|
+
for (let attempt = 0;attempt < 10; attempt += 1) {
|
|
1910
|
+
if (await host.probe(address, topology.healthPort, 2000)) {
|
|
1911
|
+
ready = true;
|
|
1912
|
+
break;
|
|
1913
|
+
}
|
|
1914
|
+
await host.sleep(1000);
|
|
1915
|
+
}
|
|
1916
|
+
if (!ready)
|
|
1917
|
+
throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
|
|
1918
|
+
}
|
|
1919
|
+
evidence.topology = {
|
|
1920
|
+
site: topology.site,
|
|
1921
|
+
siteCidr: topology.siteCidr,
|
|
1922
|
+
role: topology.role,
|
|
1923
|
+
probed: [...new Set(probes)]
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1799
1926
|
return evidence;
|
|
1800
1927
|
}
|
|
1801
1928
|
|
|
@@ -2520,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
2520
2647
|
}
|
|
2521
2648
|
|
|
2522
2649
|
// src/version.ts
|
|
2523
|
-
var VERSION3 = "0.1.
|
|
2650
|
+
var VERSION3 = "0.1.81";
|
|
2524
2651
|
|
|
2525
2652
|
// src/egress-policy.ts
|
|
2526
2653
|
import { realpathSync as realpathSync3 } from "node:fs";
|
|
@@ -3780,7 +3907,14 @@ function validatePlatformSharedEnvironment(input) {
|
|
|
3780
3907
|
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
3781
3908
|
}
|
|
3782
3909
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
3783
|
-
|
|
3910
|
+
if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
|
|
3911
|
+
throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
|
|
3912
|
+
}
|
|
3913
|
+
for (const member of input.seedSyncMembers)
|
|
3914
|
+
safeAtom("seedSyncMembers item", member);
|
|
3915
|
+
if (!input.seedSyncMembers.includes(input.nodeHostname)) {
|
|
3916
|
+
throw new Error("seedSyncMembers must include this nodeHostname.");
|
|
3917
|
+
}
|
|
3784
3918
|
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
3785
3919
|
boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
|
|
3786
3920
|
boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
|
|
@@ -3860,7 +3994,7 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
3860
3994
|
FZ_DB_MASTER: value.databaseMaster ?? "",
|
|
3861
3995
|
FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
|
|
3862
3996
|
FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
|
|
3863
|
-
FZ_SEED_SYNC_MEMBERS:
|
|
3997
|
+
FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
|
|
3864
3998
|
FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
|
|
3865
3999
|
FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
|
|
3866
4000
|
FZ_SHARED_DIR: value.sharedDirectory,
|
|
@@ -3929,7 +4063,12 @@ function platformApiCredentialSpecs(options) {
|
|
|
3929
4063
|
];
|
|
3930
4064
|
}
|
|
3931
4065
|
function renderPlatformApiUnits(input) {
|
|
3932
|
-
for (const path2 of [
|
|
4066
|
+
for (const path2 of [
|
|
4067
|
+
input.sharedDirectory,
|
|
4068
|
+
input.sharedEnvironmentFile,
|
|
4069
|
+
input.slotsDirectory,
|
|
4070
|
+
...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
|
|
4071
|
+
]) {
|
|
3933
4072
|
if (!path2.startsWith("/") || /[\r\n]/.test(path2))
|
|
3934
4073
|
throw new Error("Runtime paths must be absolute and single-line.");
|
|
3935
4074
|
}
|
|
@@ -3943,6 +4082,8 @@ function renderPlatformApiUnits(input) {
|
|
|
3943
4082
|
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
3944
4083
|
`);
|
|
3945
4084
|
const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
|
|
4085
|
+
` : "";
|
|
4086
|
+
const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
|
|
3946
4087
|
` : "";
|
|
3947
4088
|
const template = `[Unit]
|
|
3948
4089
|
Description=ForgeZero (%i slot)
|
|
@@ -3956,7 +4097,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
|
|
|
3956
4097
|
Environment=NODE_ENV=production
|
|
3957
4098
|
Environment=FZ_SLOT=%i
|
|
3958
4099
|
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
3959
|
-
${capacityEnvironment}${credentials}
|
|
4100
|
+
${capacityEnvironment}${topologyEnvironment}${credentials}
|
|
3960
4101
|
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
3961
4102
|
Restart=always
|
|
3962
4103
|
RestartSec=2
|