@forgezero/agent 0.1.24 → 0.1.26
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 +1 -0
- package/dist/fz-agent.js +62 -18
- package/dist/fz.js +11 -2
- package/dist/guest-enrolment.d.ts +2 -0
- package/dist/guest-enrolment.js +1 -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/node-vault.js +37 -10
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +9 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -70,6 +70,7 @@ export interface InstallOptions {
|
|
|
70
70
|
enrolTokenCredentialPath?: string;
|
|
71
71
|
enrolStatePath?: string;
|
|
72
72
|
nodeLabel?: string;
|
|
73
|
+
nodeHostname?: string;
|
|
73
74
|
}
|
|
74
75
|
export declare function planInstall(options: InstallOptions): ProvisionPlan;
|
|
75
76
|
/** Execute the same ordered plan the control plane executes over SSH. */
|
package/dist/fz-agent.js
CHANGED
|
@@ -25,30 +25,44 @@ class CacheError extends Error {
|
|
|
25
25
|
var DEFAULT_TTL_MS = 60000;
|
|
26
26
|
var DEFAULT_MAX_STALE_MS = 300000;
|
|
27
27
|
function createSecretCache(options) {
|
|
28
|
-
|
|
28
|
+
let entries = new Map;
|
|
29
29
|
const now = options.now ?? (() => Date.now());
|
|
30
30
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
31
31
|
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
32
32
|
let cursor = 0;
|
|
33
33
|
let replicated = false;
|
|
34
34
|
let lastSyncOkMs = now();
|
|
35
|
-
const
|
|
35
|
+
const fetchScope = async () => {
|
|
36
|
+
const next = new Map;
|
|
36
37
|
if (!options.list)
|
|
37
|
-
return { loaded: 0, failed: [] };
|
|
38
|
+
return { entries: next, loaded: 0, failed: [] };
|
|
38
39
|
const names = await options.list();
|
|
39
40
|
const failed = [];
|
|
40
|
-
let loaded = 0;
|
|
41
41
|
for (const name of names) {
|
|
42
42
|
try {
|
|
43
43
|
const result = await options.fetch(name);
|
|
44
|
-
|
|
45
|
-
loaded += 1;
|
|
44
|
+
next.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
46
45
|
} catch {
|
|
47
46
|
failed.push(name);
|
|
48
47
|
}
|
|
49
48
|
}
|
|
49
|
+
return { entries: next, loaded: next.size, failed };
|
|
50
|
+
};
|
|
51
|
+
const loadScope = async () => {
|
|
52
|
+
if (!options.list)
|
|
53
|
+
return { loaded: 0, failed: [] };
|
|
54
|
+
const snapshot = await fetchScope();
|
|
55
|
+
entries = snapshot.entries;
|
|
56
|
+
replicated = snapshot.failed.length === 0;
|
|
57
|
+
return { loaded: snapshot.loaded, failed: snapshot.failed };
|
|
58
|
+
};
|
|
59
|
+
const refreshScope = async () => {
|
|
60
|
+
const snapshot = await fetchScope();
|
|
61
|
+
if (snapshot.failed.length > 0) {
|
|
62
|
+
throw new CacheError("FETCH_FAILED", `Could not refresh ${snapshot.failed.length} assigned vault ${snapshot.failed.length === 1 ? "entry" : "entries"}.`);
|
|
63
|
+
}
|
|
64
|
+
entries = snapshot.entries;
|
|
50
65
|
replicated = true;
|
|
51
|
-
return { loaded, failed };
|
|
52
66
|
};
|
|
53
67
|
return {
|
|
54
68
|
names: () => [...entries.keys()],
|
|
@@ -82,13 +96,26 @@ function createSecretCache(options) {
|
|
|
82
96
|
const result = await options.changes(cursor);
|
|
83
97
|
if (result.resync) {
|
|
84
98
|
const dropped = [...entries.keys()];
|
|
85
|
-
|
|
99
|
+
if (options.list) {
|
|
100
|
+
await refreshScope();
|
|
101
|
+
} else {
|
|
102
|
+
entries.clear();
|
|
103
|
+
}
|
|
86
104
|
cursor = 0;
|
|
87
105
|
lastSyncOkMs = now();
|
|
88
|
-
if (options.list)
|
|
89
|
-
await loadScope();
|
|
90
106
|
return { invalidated: dropped, cursor: 0, resync: true };
|
|
91
107
|
}
|
|
108
|
+
if (options.list && result.changed.length > 0) {
|
|
109
|
+
const held = new Set(entries.keys());
|
|
110
|
+
await refreshScope();
|
|
111
|
+
cursor = result.version;
|
|
112
|
+
lastSyncOkMs = now();
|
|
113
|
+
return {
|
|
114
|
+
invalidated: result.changed.filter((name) => held.has(name) || entries.has(name)),
|
|
115
|
+
cursor,
|
|
116
|
+
resync: false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
92
119
|
const invalidated = [];
|
|
93
120
|
for (const name of result.changed) {
|
|
94
121
|
if (entries.delete(name))
|
|
@@ -1225,6 +1252,7 @@ async function enrolGuestIdentity(options) {
|
|
|
1225
1252
|
label: options.label,
|
|
1226
1253
|
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
1227
1254
|
privateNetworkAttachment: options.privateNetworkAttachment,
|
|
1255
|
+
edgeHostname: options.edgeHostname?.trim() || undefined,
|
|
1228
1256
|
publicKeys: {
|
|
1229
1257
|
ed25519: options.keys.ed25519.publicKey,
|
|
1230
1258
|
mlDsa: options.keys.mlDsa.publicKey
|
|
@@ -2717,17 +2745,23 @@ function validateLifecycleProfile(profile) {
|
|
|
2717
2745
|
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
2718
2746
|
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
2719
2747
|
}
|
|
2720
|
-
|
|
2748
|
+
const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
|
|
2749
|
+
if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
|
|
2750
|
+
throw new Error("database lifecycle settings must be supplied together");
|
|
2751
|
+
}
|
|
2752
|
+
if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
|
|
2721
2753
|
throw new Error("lifecycle profile database unit is invalid");
|
|
2722
2754
|
const apiUrl = new URL(profile.apiHealthUrl);
|
|
2723
2755
|
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
2724
2756
|
throw new Error("API health URL must be loopback HTTP");
|
|
2725
2757
|
}
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2758
|
+
if (profile.databaseHealthUrl) {
|
|
2759
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
2760
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
2761
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
2762
|
+
}
|
|
2729
2763
|
}
|
|
2730
|
-
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
2764
|
+
if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
|
|
2731
2765
|
throw new Error("lifecycle profile database ports are invalid");
|
|
2732
2766
|
}
|
|
2733
2767
|
}
|
|
@@ -2786,7 +2820,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2786
2820
|
throw new Error("WARP is not connected");
|
|
2787
2821
|
}
|
|
2788
2822
|
for (const address of claim.peerPrivateAddresses) {
|
|
2789
|
-
for (const port of profile.databasePorts)
|
|
2823
|
+
for (const port of profile.databasePorts ?? [])
|
|
2790
2824
|
await tcpProbe(address, port);
|
|
2791
2825
|
}
|
|
2792
2826
|
return {
|
|
@@ -2796,6 +2830,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2796
2830
|
};
|
|
2797
2831
|
}
|
|
2798
2832
|
case "database-member-ready": {
|
|
2833
|
+
if (!profile.databaseUnit || !profile.databaseHealthUrl) {
|
|
2834
|
+
throw new Error("this workload has no controller-managed database lifecycle");
|
|
2835
|
+
}
|
|
2799
2836
|
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
2800
2837
|
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2801
2838
|
if (!response.ok && response.status !== 401)
|
|
@@ -2812,7 +2849,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
|
|
|
2812
2849
|
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
2813
2850
|
return { sourceDrained: true };
|
|
2814
2851
|
case "source-stopped":
|
|
2815
|
-
await requireSuccess(exec, [
|
|
2852
|
+
await requireSuccess(exec, [
|
|
2853
|
+
"/usr/bin/systemctl",
|
|
2854
|
+
"stop",
|
|
2855
|
+
...profile.apiUnits,
|
|
2856
|
+
...profile.databaseUnit ? [profile.databaseUnit] : []
|
|
2857
|
+
], "source retirement");
|
|
2816
2858
|
return { sourceStopped: true };
|
|
2817
2859
|
default:
|
|
2818
2860
|
throw new Error("unknown lifecycle action");
|
|
@@ -2940,7 +2982,7 @@ function materializeWarpMdm(options) {
|
|
|
2940
2982
|
}
|
|
2941
2983
|
|
|
2942
2984
|
// src/version.ts
|
|
2943
|
-
var VERSION = "0.1.
|
|
2985
|
+
var VERSION = "0.1.26";
|
|
2944
2986
|
|
|
2945
2987
|
// src/index.ts
|
|
2946
2988
|
function loadOrCreateSeed(path) {
|
|
@@ -3083,6 +3125,7 @@ if (import.meta.main) {
|
|
|
3083
3125
|
nodeKey: nodeKey2,
|
|
3084
3126
|
keys: keys2,
|
|
3085
3127
|
label: process.env.FZ_NODE_LABEL,
|
|
3128
|
+
edgeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
3086
3129
|
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3087
3130
|
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
3088
3131
|
});
|
|
@@ -3302,6 +3345,7 @@ if (import.meta.main) {
|
|
|
3302
3345
|
nodeKey,
|
|
3303
3346
|
keys,
|
|
3304
3347
|
label: process.env.FZ_NODE_LABEL,
|
|
3348
|
+
edgeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
3305
3349
|
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3306
3350
|
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
3307
3351
|
});
|
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,10 +253,14 @@ 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
|
` : "";
|
|
@@ -287,7 +292,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
287
292
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
288
293
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
289
294
|
Environment=FZ_API=${options.apiUrl}
|
|
290
|
-
${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
295
|
+
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
291
296
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
292
297
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
293
298
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -346,6 +351,8 @@ WantedBy=multi-user.target
|
|
|
346
351
|
`;
|
|
347
352
|
}
|
|
348
353
|
function agentUnit(options) {
|
|
354
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
355
|
+
throw new Error("node hostname is invalid");
|
|
349
356
|
const bin = options.binPath ?? "fz-agent";
|
|
350
357
|
const user = options.user ?? "forgezero";
|
|
351
358
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
@@ -403,6 +410,7 @@ function agentUnit(options) {
|
|
|
403
410
|
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
404
411
|
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
405
412
|
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
413
|
+
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
406
414
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
407
415
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
408
416
|
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
@@ -966,7 +974,7 @@ async function resolveIdentity(selector, socketPath) {
|
|
|
966
974
|
}
|
|
967
975
|
|
|
968
976
|
// src/version.ts
|
|
969
|
-
var VERSION = "0.1.
|
|
977
|
+
var VERSION = "0.1.26";
|
|
970
978
|
|
|
971
979
|
// src/cli/index.ts
|
|
972
980
|
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
@@ -1205,6 +1213,7 @@ async function cmdAgent(options, args) {
|
|
|
1205
1213
|
enrolTokenCredentialPath,
|
|
1206
1214
|
enrolStatePath,
|
|
1207
1215
|
nodeLabel: process.env.FZ_NODE_LABEL,
|
|
1216
|
+
nodeHostname: process.env.FZ_NODE_HOSTNAME,
|
|
1208
1217
|
deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
|
|
1209
1218
|
} : {},
|
|
1210
1219
|
binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
|
|
@@ -30,6 +30,8 @@ export interface GuestEnrolmentOptions {
|
|
|
30
30
|
label?: string;
|
|
31
31
|
gitDeployPublicKey?: string;
|
|
32
32
|
privateNetworkAttachment?: GuestPrivateNetworkAttachment;
|
|
33
|
+
/** Stable API ingress identity; signed with the rest of the enrolment body. */
|
|
34
|
+
edgeHostname?: string;
|
|
33
35
|
fetch?: (input: URL, init: RequestInit) => Promise<Response>;
|
|
34
36
|
requestTimeoutMs?: number;
|
|
35
37
|
}
|
package/dist/guest-enrolment.js
CHANGED
|
@@ -135,6 +135,7 @@ async function enrolGuestIdentity(options) {
|
|
|
135
135
|
label: options.label,
|
|
136
136
|
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
137
137
|
privateNetworkAttachment: options.privateNetworkAttachment,
|
|
138
|
+
edgeHostname: options.edgeHostname?.trim() || undefined,
|
|
138
139
|
publicKeys: {
|
|
139
140
|
ed25519: options.keys.ed25519.publicKey,
|
|
140
141
|
mlDsa: options.keys.mlDsa.publicKey
|
|
@@ -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/node-vault.js
CHANGED
|
@@ -10,30 +10,44 @@ class CacheError extends Error {
|
|
|
10
10
|
var DEFAULT_TTL_MS = 60000;
|
|
11
11
|
var DEFAULT_MAX_STALE_MS = 300000;
|
|
12
12
|
function createSecretCache(options) {
|
|
13
|
-
|
|
13
|
+
let entries = new Map;
|
|
14
14
|
const now = options.now ?? (() => Date.now());
|
|
15
15
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
16
16
|
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
17
17
|
let cursor = 0;
|
|
18
18
|
let replicated = false;
|
|
19
19
|
let lastSyncOkMs = now();
|
|
20
|
-
const
|
|
20
|
+
const fetchScope = async () => {
|
|
21
|
+
const next = new Map;
|
|
21
22
|
if (!options.list)
|
|
22
|
-
return { loaded: 0, failed: [] };
|
|
23
|
+
return { entries: next, loaded: 0, failed: [] };
|
|
23
24
|
const names = await options.list();
|
|
24
25
|
const failed = [];
|
|
25
|
-
let loaded = 0;
|
|
26
26
|
for (const name of names) {
|
|
27
27
|
try {
|
|
28
28
|
const result = await options.fetch(name);
|
|
29
|
-
|
|
30
|
-
loaded += 1;
|
|
29
|
+
next.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
31
30
|
} catch {
|
|
32
31
|
failed.push(name);
|
|
33
32
|
}
|
|
34
33
|
}
|
|
34
|
+
return { entries: next, loaded: next.size, failed };
|
|
35
|
+
};
|
|
36
|
+
const loadScope = async () => {
|
|
37
|
+
if (!options.list)
|
|
38
|
+
return { loaded: 0, failed: [] };
|
|
39
|
+
const snapshot = await fetchScope();
|
|
40
|
+
entries = snapshot.entries;
|
|
41
|
+
replicated = snapshot.failed.length === 0;
|
|
42
|
+
return { loaded: snapshot.loaded, failed: snapshot.failed };
|
|
43
|
+
};
|
|
44
|
+
const refreshScope = async () => {
|
|
45
|
+
const snapshot = await fetchScope();
|
|
46
|
+
if (snapshot.failed.length > 0) {
|
|
47
|
+
throw new CacheError("FETCH_FAILED", `Could not refresh ${snapshot.failed.length} assigned vault ${snapshot.failed.length === 1 ? "entry" : "entries"}.`);
|
|
48
|
+
}
|
|
49
|
+
entries = snapshot.entries;
|
|
35
50
|
replicated = true;
|
|
36
|
-
return { loaded, failed };
|
|
37
51
|
};
|
|
38
52
|
return {
|
|
39
53
|
names: () => [...entries.keys()],
|
|
@@ -67,13 +81,26 @@ function createSecretCache(options) {
|
|
|
67
81
|
const result = await options.changes(cursor);
|
|
68
82
|
if (result.resync) {
|
|
69
83
|
const dropped = [...entries.keys()];
|
|
70
|
-
|
|
84
|
+
if (options.list) {
|
|
85
|
+
await refreshScope();
|
|
86
|
+
} else {
|
|
87
|
+
entries.clear();
|
|
88
|
+
}
|
|
71
89
|
cursor = 0;
|
|
72
90
|
lastSyncOkMs = now();
|
|
73
|
-
if (options.list)
|
|
74
|
-
await loadScope();
|
|
75
91
|
return { invalidated: dropped, cursor: 0, resync: true };
|
|
76
92
|
}
|
|
93
|
+
if (options.list && result.changed.length > 0) {
|
|
94
|
+
const held = new Set(entries.keys());
|
|
95
|
+
await refreshScope();
|
|
96
|
+
cursor = result.version;
|
|
97
|
+
lastSyncOkMs = now();
|
|
98
|
+
return {
|
|
99
|
+
invalidated: result.changed.filter((name) => held.has(name) || entries.has(name)),
|
|
100
|
+
cursor,
|
|
101
|
+
resync: false
|
|
102
|
+
};
|
|
103
|
+
}
|
|
77
104
|
const invalidated = [];
|
|
78
105
|
for (const name of result.changed) {
|
|
79
106
|
if (entries.delete(name))
|
package/dist/provision.d.ts
CHANGED
|
@@ -119,6 +119,8 @@ export interface UnitOptions {
|
|
|
119
119
|
enrolTokenCredentialPath?: string;
|
|
120
120
|
enrolStatePath?: string;
|
|
121
121
|
nodeLabel?: string;
|
|
122
|
+
/** Stable API ingress identity bound by the signed one-time enrolment. */
|
|
123
|
+
nodeHostname?: string;
|
|
122
124
|
}
|
|
123
125
|
export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
124
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,10 +145,14 @@ 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
|
` : "";
|
|
@@ -179,7 +184,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
179
184
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
180
185
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
181
186
|
Environment=FZ_API=${options.apiUrl}
|
|
182
|
-
${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
187
|
+
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
183
188
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
184
189
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
185
190
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -238,6 +243,8 @@ WantedBy=multi-user.target
|
|
|
238
243
|
`;
|
|
239
244
|
}
|
|
240
245
|
function agentUnit(options) {
|
|
246
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
247
|
+
throw new Error("node hostname is invalid");
|
|
241
248
|
const bin = options.binPath ?? "fz-agent";
|
|
242
249
|
const user = options.user ?? "forgezero";
|
|
243
250
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
@@ -295,6 +302,7 @@ function agentUnit(options) {
|
|
|
295
302
|
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
296
303
|
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
297
304
|
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
305
|
+
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
298
306
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
299
307
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
300
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.26";
|
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.26",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"check": "tsc --noEmit",
|