@forgezero/agent 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/agent-install.d.ts +5 -0
- package/dist/fz-agent.js +50 -10
- package/dist/fz.js +40 -2
- package/dist/guest-enrolment.d.ts +11 -0
- package/dist/guest-enrolment.js +23 -0
- package/dist/lifecycle-helper.d.ts +3 -3
- package/dist/lifecycle-helper.js +21 -7
- package/dist/migration-pull.d.ts +2 -0
- package/dist/provision.d.ts +7 -0
- package/dist/provision.js +34 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -56,6 +56,10 @@ export interface InstallOptions {
|
|
|
56
56
|
warpOrganization?: string;
|
|
57
57
|
warpClientIdCredentialPath?: string;
|
|
58
58
|
warpClientSecretCredentialPath?: string;
|
|
59
|
+
cloudflareAccountId?: string;
|
|
60
|
+
cloudflareTunnelId?: string;
|
|
61
|
+
cloudflareVirtualNetworkId?: string;
|
|
62
|
+
cloudflareWarpPolicyId?: string;
|
|
59
63
|
binPath?: string;
|
|
60
64
|
sourceBinPath?: string;
|
|
61
65
|
user?: string;
|
|
@@ -66,6 +70,7 @@ export interface InstallOptions {
|
|
|
66
70
|
enrolTokenCredentialPath?: string;
|
|
67
71
|
enrolStatePath?: string;
|
|
68
72
|
nodeLabel?: string;
|
|
73
|
+
nodeHostname?: string;
|
|
69
74
|
}
|
|
70
75
|
export declare function planInstall(options: InstallOptions): ProvisionPlan;
|
|
71
76
|
/** Execute the same ordered plan the control plane executes over SSH. */
|
package/dist/fz-agent.js
CHANGED
|
@@ -1158,6 +1158,26 @@ import {
|
|
|
1158
1158
|
writeFileSync as writeFileSync2
|
|
1159
1159
|
} from "fs";
|
|
1160
1160
|
import { dirname as dirname2 } from "path";
|
|
1161
|
+
function privateNetworkAttachmentFromEnvironment(env = process.env) {
|
|
1162
|
+
const values = {
|
|
1163
|
+
accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
|
|
1164
|
+
tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
|
|
1165
|
+
virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
|
|
1166
|
+
warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
|
|
1167
|
+
};
|
|
1168
|
+
const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
|
|
1169
|
+
if (!supplied)
|
|
1170
|
+
return;
|
|
1171
|
+
if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
|
|
1172
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
accountId: values.accountId,
|
|
1176
|
+
tunnelId: values.tunnelId,
|
|
1177
|
+
...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
|
|
1178
|
+
warpPolicyId: values.warpPolicyId
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1161
1181
|
var validBinding = (value, expectedNodeKey) => {
|
|
1162
1182
|
if (!value || typeof value !== "object")
|
|
1163
1183
|
return false;
|
|
@@ -1204,6 +1224,8 @@ async function enrolGuestIdentity(options) {
|
|
|
1204
1224
|
token,
|
|
1205
1225
|
label: options.label,
|
|
1206
1226
|
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
1227
|
+
privateNetworkAttachment: options.privateNetworkAttachment,
|
|
1228
|
+
edgeHostname: options.edgeHostname?.trim() || undefined,
|
|
1207
1229
|
publicKeys: {
|
|
1208
1230
|
ed25519: options.keys.ed25519.publicKey,
|
|
1209
1231
|
mlDsa: options.keys.mlDsa.publicKey
|
|
@@ -2696,17 +2718,23 @@ function validateLifecycleProfile(profile) {
|
|
|
2696
2718
|
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
2697
2719
|
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
2698
2720
|
}
|
|
2699
|
-
|
|
2721
|
+
const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
|
|
2722
|
+
if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
|
|
2723
|
+
throw new Error("database lifecycle settings must be supplied together");
|
|
2724
|
+
}
|
|
2725
|
+
if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
|
|
2700
2726
|
throw new Error("lifecycle profile database unit is invalid");
|
|
2701
2727
|
const apiUrl = new URL(profile.apiHealthUrl);
|
|
2702
2728
|
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
2703
2729
|
throw new Error("API health URL must be loopback HTTP");
|
|
2704
2730
|
}
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2731
|
+
if (profile.databaseHealthUrl) {
|
|
2732
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
2733
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
2734
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
2735
|
+
}
|
|
2708
2736
|
}
|
|
2709
|
-
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
2737
|
+
if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
|
|
2710
2738
|
throw new Error("lifecycle profile database ports are invalid");
|
|
2711
2739
|
}
|
|
2712
2740
|
}
|
|
@@ -2765,7 +2793,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2765
2793
|
throw new Error("WARP is not connected");
|
|
2766
2794
|
}
|
|
2767
2795
|
for (const address of claim.peerPrivateAddresses) {
|
|
2768
|
-
for (const port of profile.databasePorts)
|
|
2796
|
+
for (const port of profile.databasePorts ?? [])
|
|
2769
2797
|
await tcpProbe(address, port);
|
|
2770
2798
|
}
|
|
2771
2799
|
return {
|
|
@@ -2775,6 +2803,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2775
2803
|
};
|
|
2776
2804
|
}
|
|
2777
2805
|
case "database-member-ready": {
|
|
2806
|
+
if (!profile.databaseUnit || !profile.databaseHealthUrl) {
|
|
2807
|
+
throw new Error("this workload has no controller-managed database lifecycle");
|
|
2808
|
+
}
|
|
2778
2809
|
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
2779
2810
|
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2780
2811
|
if (!response.ok && response.status !== 401)
|
|
@@ -2791,7 +2822,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2791
2822
|
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
2792
2823
|
return { sourceDrained: true };
|
|
2793
2824
|
case "source-stopped":
|
|
2794
|
-
await requireSuccess(exec, [
|
|
2825
|
+
await requireSuccess(exec, [
|
|
2826
|
+
"/usr/bin/systemctl",
|
|
2827
|
+
"stop",
|
|
2828
|
+
...profile.apiUnits,
|
|
2829
|
+
...profile.databaseUnit ? [profile.databaseUnit] : []
|
|
2830
|
+
], "source retirement");
|
|
2795
2831
|
return { sourceStopped: true };
|
|
2796
2832
|
default:
|
|
2797
2833
|
throw new Error("unknown lifecycle action");
|
|
@@ -2919,7 +2955,7 @@ function materializeWarpMdm(options) {
|
|
|
2919
2955
|
}
|
|
2920
2956
|
|
|
2921
2957
|
// src/version.ts
|
|
2922
|
-
var VERSION = "0.1.
|
|
2958
|
+
var VERSION = "0.1.25";
|
|
2923
2959
|
|
|
2924
2960
|
// src/index.ts
|
|
2925
2961
|
function loadOrCreateSeed(path) {
|
|
@@ -3062,7 +3098,9 @@ if (import.meta.main) {
|
|
|
3062
3098
|
nodeKey: nodeKey2,
|
|
3063
3099
|
keys: keys2,
|
|
3064
3100
|
label: process.env.FZ_NODE_LABEL,
|
|
3065
|
-
|
|
3101
|
+
edgeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
3102
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3103
|
+
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
3066
3104
|
});
|
|
3067
3105
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
3068
3106
|
process.exit(0);
|
|
@@ -3280,7 +3318,9 @@ if (import.meta.main) {
|
|
|
3280
3318
|
nodeKey,
|
|
3281
3319
|
keys,
|
|
3282
3320
|
label: process.env.FZ_NODE_LABEL,
|
|
3283
|
-
|
|
3321
|
+
edgeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
3322
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3323
|
+
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
3284
3324
|
});
|
|
3285
3325
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
3286
3326
|
}
|
package/dist/fz.js
CHANGED
|
@@ -163,6 +163,7 @@ var systemdPath = (value, label) => {
|
|
|
163
163
|
throw new Error(`invalid ${label} path`);
|
|
164
164
|
return value;
|
|
165
165
|
};
|
|
166
|
+
var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
166
167
|
function warpConfigUnit(options) {
|
|
167
168
|
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
168
169
|
throw new Error("WARP organization is invalid");
|
|
@@ -252,13 +253,27 @@ function agentEnrolmentUnit(options) {
|
|
|
252
253
|
if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
|
|
253
254
|
throw new Error("direct enrolment needs API, credential and state paths");
|
|
254
255
|
}
|
|
256
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
257
|
+
throw new Error("node hostname is invalid");
|
|
255
258
|
const bin = options.binPath ?? "fz-agent";
|
|
256
259
|
const user = options.user ?? "forgezero";
|
|
257
260
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
258
261
|
const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
|
|
262
|
+
` : "";
|
|
263
|
+
const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
|
|
259
264
|
` : "";
|
|
260
265
|
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
261
266
|
` : "";
|
|
267
|
+
const networkAttachment = [
|
|
268
|
+
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
269
|
+
` : "",
|
|
270
|
+
options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
|
|
271
|
+
` : "",
|
|
272
|
+
options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
|
|
273
|
+
` : "",
|
|
274
|
+
options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
|
|
275
|
+
` : ""
|
|
276
|
+
].join("");
|
|
262
277
|
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
263
278
|
return `[Unit]
|
|
264
279
|
Description=Bind this machine to its ForgeZero compute
|
|
@@ -277,7 +292,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
277
292
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
278
293
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
279
294
|
Environment=FZ_API=${options.apiUrl}
|
|
280
|
-
${label}${gitPublicKey}ExecStart=${bin} enrol
|
|
295
|
+
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
281
296
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
282
297
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
283
298
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -336,6 +351,8 @@ WantedBy=multi-user.target
|
|
|
336
351
|
`;
|
|
337
352
|
}
|
|
338
353
|
function agentUnit(options) {
|
|
354
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
355
|
+
throw new Error("node hostname is invalid");
|
|
339
356
|
const bin = options.binPath ?? "fz-agent";
|
|
340
357
|
const user = options.user ?? "forgezero";
|
|
341
358
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
@@ -355,6 +372,21 @@ function agentUnit(options) {
|
|
|
355
372
|
const warpEnabled = warpValues.every(Boolean);
|
|
356
373
|
if (warpValues.some(Boolean) && !warpEnabled)
|
|
357
374
|
throw new Error("WARP configuration must be supplied together");
|
|
375
|
+
const networkAttachmentValues = [
|
|
376
|
+
options.cloudflareAccountId,
|
|
377
|
+
options.cloudflareTunnelId,
|
|
378
|
+
options.cloudflareVirtualNetworkId,
|
|
379
|
+
options.cloudflareWarpPolicyId
|
|
380
|
+
];
|
|
381
|
+
if (networkAttachmentValues.some(Boolean)) {
|
|
382
|
+
if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
|
|
383
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
384
|
+
}
|
|
385
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
386
|
+
if (!/^[a-f0-9]{32}$/i.test(options.cloudflareAccountId) || !uuid.test(options.cloudflareTunnelId) || options.cloudflareVirtualNetworkId && !uuid.test(options.cloudflareVirtualNetworkId) || !/^[A-Za-z0-9-]{1,64}$/.test(options.cloudflareWarpPolicyId)) {
|
|
387
|
+
throw new Error("private-network attachment coordinates are invalid");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
358
390
|
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
359
391
|
const deploymentEnvironment = options.deploymentEnvironment ?? {};
|
|
360
392
|
const deploymentCredentials = options.deploymentCredentials ?? {};
|
|
@@ -378,6 +410,7 @@ function agentUnit(options) {
|
|
|
378
410
|
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
379
411
|
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
380
412
|
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
413
|
+
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
381
414
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
382
415
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
383
416
|
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
@@ -941,7 +974,7 @@ async function resolveIdentity(selector, socketPath) {
|
|
|
941
974
|
}
|
|
942
975
|
|
|
943
976
|
// src/version.ts
|
|
944
|
-
var VERSION = "0.1.
|
|
977
|
+
var VERSION = "0.1.25";
|
|
945
978
|
|
|
946
979
|
// src/cli/index.ts
|
|
947
980
|
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
@@ -1170,12 +1203,17 @@ async function cmdAgent(options, args) {
|
|
|
1170
1203
|
warpOrganization: process.env.FZ_WARP_ORGANIZATION,
|
|
1171
1204
|
warpClientIdCredentialPath: process.env.FZ_WARP_CLIENT_ID_CREDENTIAL_PATH,
|
|
1172
1205
|
warpClientSecretCredentialPath: process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL_PATH,
|
|
1206
|
+
cloudflareAccountId: process.env.FZ_CF_ACCOUNT_ID,
|
|
1207
|
+
cloudflareTunnelId: process.env.FZ_CF_TUNNEL_ID,
|
|
1208
|
+
cloudflareVirtualNetworkId: process.env.FZ_CF_VIRTUAL_NETWORK_ID,
|
|
1209
|
+
cloudflareWarpPolicyId: process.env.FZ_CF_WARP_POLICY_ID,
|
|
1173
1210
|
...options.enrol ? {
|
|
1174
1211
|
pullDeployments: true,
|
|
1175
1212
|
enrolTokenSourcePath,
|
|
1176
1213
|
enrolTokenCredentialPath,
|
|
1177
1214
|
enrolStatePath,
|
|
1178
1215
|
nodeLabel: process.env.FZ_NODE_LABEL,
|
|
1216
|
+
nodeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
1179
1217
|
deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
|
|
1180
1218
|
} : {},
|
|
1181
1219
|
binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
|
|
@@ -7,6 +7,14 @@ export interface GuestBinding {
|
|
|
7
7
|
realm: 'platform' | 'tenant';
|
|
8
8
|
tenantSlug?: string;
|
|
9
9
|
}
|
|
10
|
+
export interface GuestPrivateNetworkAttachment {
|
|
11
|
+
accountId: string;
|
|
12
|
+
tunnelId: string;
|
|
13
|
+
virtualNetworkId?: string;
|
|
14
|
+
warpPolicyId: string;
|
|
15
|
+
}
|
|
16
|
+
/** Fail closed when setup supplied only part of a cross-network attachment. */
|
|
17
|
+
export declare function privateNetworkAttachmentFromEnvironment(env?: Record<string, string | undefined>): GuestPrivateNetworkAttachment | undefined;
|
|
10
18
|
export interface GuestEnrolmentOptions {
|
|
11
19
|
apiUrl: string;
|
|
12
20
|
/** Already-unsealed systemd credential. Production uses this path. */
|
|
@@ -21,6 +29,9 @@ export interface GuestEnrolmentOptions {
|
|
|
21
29
|
keys: NodeKeyPair;
|
|
22
30
|
label?: string;
|
|
23
31
|
gitDeployPublicKey?: string;
|
|
32
|
+
privateNetworkAttachment?: GuestPrivateNetworkAttachment;
|
|
33
|
+
/** Stable API ingress identity; signed with the rest of the enrolment body. */
|
|
34
|
+
edgeHostname?: string;
|
|
24
35
|
fetch?: (input: URL, init: RequestInit) => Promise<Response>;
|
|
25
36
|
requestTimeoutMs?: number;
|
|
26
37
|
}
|
package/dist/guest-enrolment.js
CHANGED
|
@@ -68,6 +68,26 @@ async function postSignedNode(options, path, body) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// src/guest-enrolment.ts
|
|
71
|
+
function privateNetworkAttachmentFromEnvironment(env = process.env) {
|
|
72
|
+
const values = {
|
|
73
|
+
accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
|
|
74
|
+
tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
|
|
75
|
+
virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
|
|
76
|
+
warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
|
|
77
|
+
};
|
|
78
|
+
const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
|
|
79
|
+
if (!supplied)
|
|
80
|
+
return;
|
|
81
|
+
if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
|
|
82
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
accountId: values.accountId,
|
|
86
|
+
tunnelId: values.tunnelId,
|
|
87
|
+
...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
|
|
88
|
+
warpPolicyId: values.warpPolicyId
|
|
89
|
+
};
|
|
90
|
+
}
|
|
71
91
|
var validBinding = (value, expectedNodeKey) => {
|
|
72
92
|
if (!value || typeof value !== "object")
|
|
73
93
|
return false;
|
|
@@ -114,6 +134,8 @@ async function enrolGuestIdentity(options) {
|
|
|
114
134
|
token,
|
|
115
135
|
label: options.label,
|
|
116
136
|
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
137
|
+
privateNetworkAttachment: options.privateNetworkAttachment,
|
|
138
|
+
edgeHostname: options.edgeHostname?.trim() || undefined,
|
|
117
139
|
publicKeys: {
|
|
118
140
|
ed25519: options.keys.ed25519.publicKey,
|
|
119
141
|
mlDsa: options.keys.mlDsa.publicKey
|
|
@@ -138,6 +160,7 @@ async function enrolGuestIdentity(options) {
|
|
|
138
160
|
return binding;
|
|
139
161
|
}
|
|
140
162
|
export {
|
|
163
|
+
privateNetworkAttachmentFromEnvironment,
|
|
141
164
|
loadGuestBinding,
|
|
142
165
|
enrolGuestIdentity
|
|
143
166
|
};
|
|
@@ -5,13 +5,13 @@ export interface LifecycleProfile {
|
|
|
5
5
|
/** Root-owned systemd units whose normal stop path performs application drain. */
|
|
6
6
|
apiUnits: readonly string[];
|
|
7
7
|
/** Root-owned database service stopped only at the final retirement stage. */
|
|
8
|
-
databaseUnit
|
|
8
|
+
databaseUnit?: string;
|
|
9
9
|
/** Loopback health endpoint for the API on this compute. */
|
|
10
10
|
apiHealthUrl: string;
|
|
11
11
|
/** Loopback health endpoint for the database member on this compute. */
|
|
12
|
-
databaseHealthUrl
|
|
12
|
+
databaseHealthUrl?: string;
|
|
13
13
|
/** Ports that must be reachable on every signed controller-provided private peer. */
|
|
14
|
-
databasePorts
|
|
14
|
+
databasePorts?: readonly number[];
|
|
15
15
|
}
|
|
16
16
|
export interface LifecycleCommandResult {
|
|
17
17
|
exitCode: number;
|
package/dist/lifecycle-helper.js
CHANGED
|
@@ -22,17 +22,23 @@ function validateLifecycleProfile(profile) {
|
|
|
22
22
|
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
23
23
|
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
|
|
26
|
+
if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
|
|
27
|
+
throw new Error("database lifecycle settings must be supplied together");
|
|
28
|
+
}
|
|
29
|
+
if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
|
|
26
30
|
throw new Error("lifecycle profile database unit is invalid");
|
|
27
31
|
const apiUrl = new URL(profile.apiHealthUrl);
|
|
28
32
|
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
29
33
|
throw new Error("API health URL must be loopback HTTP");
|
|
30
34
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
if (profile.databaseHealthUrl) {
|
|
36
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
37
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
38
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
39
|
+
}
|
|
34
40
|
}
|
|
35
|
-
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
41
|
+
if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
|
|
36
42
|
throw new Error("lifecycle profile database ports are invalid");
|
|
37
43
|
}
|
|
38
44
|
}
|
|
@@ -91,7 +97,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
91
97
|
throw new Error("WARP is not connected");
|
|
92
98
|
}
|
|
93
99
|
for (const address of claim.peerPrivateAddresses) {
|
|
94
|
-
for (const port of profile.databasePorts)
|
|
100
|
+
for (const port of profile.databasePorts ?? [])
|
|
95
101
|
await tcpProbe(address, port);
|
|
96
102
|
}
|
|
97
103
|
return {
|
|
@@ -101,6 +107,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
case "database-member-ready": {
|
|
110
|
+
if (!profile.databaseUnit || !profile.databaseHealthUrl) {
|
|
111
|
+
throw new Error("this workload has no controller-managed database lifecycle");
|
|
112
|
+
}
|
|
104
113
|
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
105
114
|
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
106
115
|
if (!response.ok && response.status !== 401)
|
|
@@ -117,7 +126,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
117
126
|
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
118
127
|
return { sourceDrained: true };
|
|
119
128
|
case "source-stopped":
|
|
120
|
-
await requireSuccess(exec, [
|
|
129
|
+
await requireSuccess(exec, [
|
|
130
|
+
"/usr/bin/systemctl",
|
|
131
|
+
"stop",
|
|
132
|
+
...profile.apiUnits,
|
|
133
|
+
...profile.databaseUnit ? [profile.databaseUnit] : []
|
|
134
|
+
], "source retirement");
|
|
121
135
|
return { sourceStopped: true };
|
|
122
136
|
default:
|
|
123
137
|
throw new Error("unknown lifecycle action");
|
package/dist/migration-pull.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NodeKeyPair } from '@forgezero/runtime/identity';
|
|
2
2
|
export type MigrationNetwork = 'private-lan' | 'cloudflare-warp';
|
|
3
|
+
export type MigrationEvidenceProfile = 'forgezero-platform' | 'tenant-managed';
|
|
3
4
|
export type MigrationAction = 'network-ready' | 'database-member-ready' | 'api-ready' | 'source-drained' | 'source-stopped';
|
|
4
5
|
export interface MigrationEvidence {
|
|
5
6
|
targetAgentReady?: boolean;
|
|
@@ -14,6 +15,7 @@ export interface RemoteMigrationClaim {
|
|
|
14
15
|
migrationKey: string;
|
|
15
16
|
action: MigrationAction;
|
|
16
17
|
network: MigrationNetwork;
|
|
18
|
+
evidenceProfile?: MigrationEvidenceProfile;
|
|
17
19
|
claimToken: string;
|
|
18
20
|
claimExpiresAtTs: number;
|
|
19
21
|
attempt: number;
|
package/dist/provision.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export interface UnitOptions {
|
|
|
101
101
|
warpOrganization?: string;
|
|
102
102
|
warpClientIdCredentialPath?: string;
|
|
103
103
|
warpClientSecretCredentialPath?: string;
|
|
104
|
+
/** Non-secret coordinates bound through the PQ-signed enrolment request. */
|
|
105
|
+
cloudflareAccountId?: string;
|
|
106
|
+
cloudflareTunnelId?: string;
|
|
107
|
+
cloudflareVirtualNetworkId?: string;
|
|
108
|
+
cloudflareWarpPolicyId?: string;
|
|
104
109
|
/** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
|
|
105
110
|
binPath?: string;
|
|
106
111
|
/** Package-owned binary copied to binPath before hardened units start. */
|
|
@@ -114,6 +119,8 @@ export interface UnitOptions {
|
|
|
114
119
|
enrolTokenCredentialPath?: string;
|
|
115
120
|
enrolStatePath?: string;
|
|
116
121
|
nodeLabel?: string;
|
|
122
|
+
/** Stable API ingress identity bound by the signed one-time enrolment. */
|
|
123
|
+
nodeHostname?: string;
|
|
117
124
|
}
|
|
118
125
|
export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
119
126
|
export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
|
package/dist/provision.js
CHANGED
|
@@ -55,6 +55,7 @@ var systemdPath = (value, label) => {
|
|
|
55
55
|
throw new Error(`invalid ${label} path`);
|
|
56
56
|
return value;
|
|
57
57
|
};
|
|
58
|
+
var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
58
59
|
function warpConfigUnit(options) {
|
|
59
60
|
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
60
61
|
throw new Error("WARP organization is invalid");
|
|
@@ -144,13 +145,27 @@ function agentEnrolmentUnit(options) {
|
|
|
144
145
|
if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
|
|
145
146
|
throw new Error("direct enrolment needs API, credential and state paths");
|
|
146
147
|
}
|
|
148
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
149
|
+
throw new Error("node hostname is invalid");
|
|
147
150
|
const bin = options.binPath ?? "fz-agent";
|
|
148
151
|
const user = options.user ?? "forgezero";
|
|
149
152
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
150
153
|
const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
|
|
154
|
+
` : "";
|
|
155
|
+
const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
|
|
151
156
|
` : "";
|
|
152
157
|
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
153
158
|
` : "";
|
|
159
|
+
const networkAttachment = [
|
|
160
|
+
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
161
|
+
` : "",
|
|
162
|
+
options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
|
|
163
|
+
` : "",
|
|
164
|
+
options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
|
|
165
|
+
` : "",
|
|
166
|
+
options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
|
|
167
|
+
` : ""
|
|
168
|
+
].join("");
|
|
154
169
|
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
155
170
|
return `[Unit]
|
|
156
171
|
Description=Bind this machine to its ForgeZero compute
|
|
@@ -169,7 +184,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
169
184
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
170
185
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
171
186
|
Environment=FZ_API=${options.apiUrl}
|
|
172
|
-
${label}${gitPublicKey}ExecStart=${bin} enrol
|
|
187
|
+
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
173
188
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
174
189
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
175
190
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -228,6 +243,8 @@ WantedBy=multi-user.target
|
|
|
228
243
|
`;
|
|
229
244
|
}
|
|
230
245
|
function agentUnit(options) {
|
|
246
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
247
|
+
throw new Error("node hostname is invalid");
|
|
231
248
|
const bin = options.binPath ?? "fz-agent";
|
|
232
249
|
const user = options.user ?? "forgezero";
|
|
233
250
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
@@ -247,6 +264,21 @@ function agentUnit(options) {
|
|
|
247
264
|
const warpEnabled = warpValues.every(Boolean);
|
|
248
265
|
if (warpValues.some(Boolean) && !warpEnabled)
|
|
249
266
|
throw new Error("WARP configuration must be supplied together");
|
|
267
|
+
const networkAttachmentValues = [
|
|
268
|
+
options.cloudflareAccountId,
|
|
269
|
+
options.cloudflareTunnelId,
|
|
270
|
+
options.cloudflareVirtualNetworkId,
|
|
271
|
+
options.cloudflareWarpPolicyId
|
|
272
|
+
];
|
|
273
|
+
if (networkAttachmentValues.some(Boolean)) {
|
|
274
|
+
if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
|
|
275
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
276
|
+
}
|
|
277
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
278
|
+
if (!/^[a-f0-9]{32}$/i.test(options.cloudflareAccountId) || !uuid.test(options.cloudflareTunnelId) || options.cloudflareVirtualNetworkId && !uuid.test(options.cloudflareVirtualNetworkId) || !/^[A-Za-z0-9-]{1,64}$/.test(options.cloudflareWarpPolicyId)) {
|
|
279
|
+
throw new Error("private-network attachment coordinates are invalid");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
250
282
|
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
251
283
|
const deploymentEnvironment = options.deploymentEnvironment ?? {};
|
|
252
284
|
const deploymentCredentials = options.deploymentCredentials ?? {};
|
|
@@ -270,6 +302,7 @@ function agentUnit(options) {
|
|
|
270
302
|
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
271
303
|
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
272
304
|
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
305
|
+
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
273
306
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
274
307
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
275
308
|
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** One package version shared by both public binaries. Pinned to package.json by tests. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.25";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.25",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"check": "tsc --noEmit",
|