@forgezero/agent 0.1.10 → 0.1.12
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 +36 -9
- package/dist/deployment.d.ts +1 -1
- package/dist/fz-agent.js +525 -490
- package/dist/fz.js +34 -12
- package/dist/guest-enrolment.d.ts +2 -1
- package/dist/guest-enrolment.js +81 -23
- package/dist/index.d.ts +5 -29
- package/dist/metal-helper-socket.js +117 -24
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +117 -24
- package/dist/node-vault.d.ts +9 -0
- package/dist/node-vault.js +53 -17
- package/dist/provision.d.ts +1 -0
- package/dist/provision.js +14 -4
- package/dist/provisioning-pull.d.ts +24 -1
- package/dist/provisioning-pull.js +30 -11
- package/dist/signed-node-http.d.ts +1 -1
- package/dist/socket.d.ts +11 -4
- package/dist/ubuntu.d.ts +16 -0
- package/dist/ubuntu.js +18 -0
- package/dist/version.d.ts +2 -0
- package/package.json +11 -6
- package/dist/attestation-client.test.d.ts +0 -1
- package/dist/cache.test.d.ts +0 -1
- package/dist/cli/agent-install.test.d.ts +0 -1
- package/dist/cli/options.test.d.ts +0 -1
- package/dist/cli/run.test.d.ts +0 -1
- package/dist/compute.test.d.ts +0 -1
- package/dist/control.test.d.ts +0 -1
- package/dist/definition.test.d.ts +0 -1
- package/dist/deployment-pull.test.d.ts +0 -1
- package/dist/deployment-runner.test.d.ts +0 -1
- package/dist/deployment-watch.d.ts +0 -36
- package/dist/deployment-watch.test.d.ts +0 -1
- package/dist/deployment.test.d.ts +0 -1
- package/dist/guest-enrolment.test.d.ts +0 -1
- package/dist/index.test.d.ts +0 -1
- package/dist/metal-helper-socket.test.d.ts +0 -1
- package/dist/metal-isolation.test.d.ts +0 -1
- package/dist/metal-provision.test.d.ts +0 -1
- package/dist/node-vault.test.d.ts +0 -1
- package/dist/pipeline.test.d.ts +0 -1
- package/dist/provisioning-pull.test.d.ts +0 -1
- package/dist/snp-attestation.test.d.ts +0 -1
- package/dist/socket.test.d.ts +0 -1
- package/dist/ssh-listen.test.d.ts +0 -1
- package/dist/ssh-server.test.d.ts +0 -1
- package/dist/subscribe.test.d.ts +0 -1
package/dist/fz-agent.js
CHANGED
|
@@ -3,15 +3,258 @@
|
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
5
|
import { randomBytes } from "crypto";
|
|
6
|
-
import { readFileSync as
|
|
7
|
-
import { dirname as
|
|
6
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync9, mkdirSync as mkdirSync5, chmodSync as chmodSync7 } from "fs";
|
|
7
|
+
import { dirname as dirname4, join as join4 } from "path";
|
|
8
8
|
import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
|
|
9
9
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
10
10
|
|
|
11
11
|
// src/socket.ts
|
|
12
12
|
import { createServer } from "net";
|
|
13
13
|
import { chmodSync, existsSync, unlinkSync } from "fs";
|
|
14
|
-
import { signRequest } from "@forgezero/runtime/identity";
|
|
14
|
+
import { signRequest as signRequest2 } from "@forgezero/runtime/identity";
|
|
15
|
+
|
|
16
|
+
// src/cache.ts
|
|
17
|
+
class CacheError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "CacheError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
var DEFAULT_TTL_MS = 60000;
|
|
26
|
+
var DEFAULT_MAX_STALE_MS = 300000;
|
|
27
|
+
function createSecretCache(options) {
|
|
28
|
+
const entries = new Map;
|
|
29
|
+
const now = options.now ?? (() => Date.now());
|
|
30
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
31
|
+
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
32
|
+
let cursor = 0;
|
|
33
|
+
let replicated = false;
|
|
34
|
+
let lastSyncOkMs = now();
|
|
35
|
+
const loadScope = async () => {
|
|
36
|
+
if (!options.list)
|
|
37
|
+
return { loaded: 0, failed: [] };
|
|
38
|
+
const names = await options.list();
|
|
39
|
+
const failed = [];
|
|
40
|
+
let loaded = 0;
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await options.fetch(name);
|
|
44
|
+
entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
45
|
+
loaded += 1;
|
|
46
|
+
} catch {
|
|
47
|
+
failed.push(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
replicated = true;
|
|
51
|
+
return { loaded, failed };
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
names: () => [...entries.keys()],
|
|
55
|
+
get replica() {
|
|
56
|
+
return replicated;
|
|
57
|
+
},
|
|
58
|
+
load: loadScope,
|
|
59
|
+
get cursor() {
|
|
60
|
+
return cursor;
|
|
61
|
+
},
|
|
62
|
+
async get(name) {
|
|
63
|
+
const staleFor = now() - lastSyncOkMs;
|
|
64
|
+
if (staleFor > maxStaleMs) {
|
|
65
|
+
throw new CacheError("STALE", `Synchronisation has not succeeded for ${Math.round(staleFor / 1000)}s, so this ` + "cache can no longer vouch for what it holds. Refusing rather than serving a value " + "that may already be revoked.");
|
|
66
|
+
}
|
|
67
|
+
const cached = entries.get(name);
|
|
68
|
+
if (cached && now() - cached.fetchedAtMs < ttlMs)
|
|
69
|
+
return cached.value;
|
|
70
|
+
let fetched;
|
|
71
|
+
try {
|
|
72
|
+
fetched = await options.fetch(name);
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
if (cached)
|
|
75
|
+
return cached.value;
|
|
76
|
+
throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
|
|
77
|
+
}
|
|
78
|
+
entries.set(name, { ...fetched, fetchedAtMs: now() });
|
|
79
|
+
return fetched.value;
|
|
80
|
+
},
|
|
81
|
+
async sync() {
|
|
82
|
+
const result = await options.changes(cursor);
|
|
83
|
+
if (result.resync) {
|
|
84
|
+
const dropped = [...entries.keys()];
|
|
85
|
+
entries.clear();
|
|
86
|
+
cursor = 0;
|
|
87
|
+
lastSyncOkMs = now();
|
|
88
|
+
if (options.list)
|
|
89
|
+
await loadScope();
|
|
90
|
+
return { invalidated: dropped, cursor: 0, resync: true };
|
|
91
|
+
}
|
|
92
|
+
const invalidated = [];
|
|
93
|
+
for (const name of result.changed) {
|
|
94
|
+
if (entries.delete(name))
|
|
95
|
+
invalidated.push(name);
|
|
96
|
+
}
|
|
97
|
+
cursor = result.version;
|
|
98
|
+
lastSyncOkMs = now();
|
|
99
|
+
return { invalidated, cursor, resync: false };
|
|
100
|
+
},
|
|
101
|
+
clear() {
|
|
102
|
+
entries.clear();
|
|
103
|
+
cursor = 0;
|
|
104
|
+
},
|
|
105
|
+
staleForMs: () => now() - lastSyncOkMs
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/signed-node-http.ts
|
|
110
|
+
import {
|
|
111
|
+
encodeSignatureHeader,
|
|
112
|
+
generateResponseRecipient,
|
|
113
|
+
openResponse,
|
|
114
|
+
RESPONSE_KEY_HEADER,
|
|
115
|
+
signRequest
|
|
116
|
+
} from "@forgezero/runtime/identity";
|
|
117
|
+
|
|
118
|
+
class SignedNodeHttpError extends Error {
|
|
119
|
+
status;
|
|
120
|
+
constructor(status, message) {
|
|
121
|
+
super(message);
|
|
122
|
+
this.status = status;
|
|
123
|
+
this.name = "SignedNodeHttpError";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function postSignedNode(options, path, body, sealedResponse = false) {
|
|
127
|
+
const url = new URL(options.apiUrl);
|
|
128
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
129
|
+
url.search = "";
|
|
130
|
+
url.hash = "";
|
|
131
|
+
const raw = JSON.stringify(body);
|
|
132
|
+
const recipient = sealedResponse ? generateResponseRecipient() : undefined;
|
|
133
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
path: url.pathname,
|
|
136
|
+
query: "",
|
|
137
|
+
body: raw,
|
|
138
|
+
responseKey: recipient?.publicKey
|
|
139
|
+
});
|
|
140
|
+
const signature = encodeSignatureHeader(envelope);
|
|
141
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"content-type": "application/json",
|
|
145
|
+
"x-fz-node": options.nodeKey,
|
|
146
|
+
"x-fz-signature": signature,
|
|
147
|
+
...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
|
|
148
|
+
},
|
|
149
|
+
body: raw,
|
|
150
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
151
|
+
});
|
|
152
|
+
const payload = await response.json().catch(() => null);
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const failure = payload;
|
|
155
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
156
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
157
|
+
}
|
|
158
|
+
if (recipient) {
|
|
159
|
+
try {
|
|
160
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/node-vault.ts
|
|
169
|
+
var SCOPE_PART = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
170
|
+
function projectVaultCacheKey(coordinate) {
|
|
171
|
+
if (!SCOPE_PART.test(coordinate.environment) || !SCOPE_PART.test(coordinate.name)) {
|
|
172
|
+
throw new Error("node vault coordinate is malformed");
|
|
173
|
+
}
|
|
174
|
+
return JSON.stringify([coordinate.environment, coordinate.name]);
|
|
175
|
+
}
|
|
176
|
+
function projectVaultCoordinate(key) {
|
|
177
|
+
let value;
|
|
178
|
+
try {
|
|
179
|
+
value = JSON.parse(key);
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error("node vault cache key is malformed");
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !SCOPE_PART.test(value[0]) || !SCOPE_PART.test(value[1]))
|
|
184
|
+
throw new Error("node vault cache key is malformed");
|
|
185
|
+
return { environment: value[0], name: value[1] };
|
|
186
|
+
}
|
|
187
|
+
function tenantNodeApiUrl(apiUrl, tenantSlug) {
|
|
188
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
|
|
189
|
+
throw new Error("tenant slug is malformed");
|
|
190
|
+
const url = new URL(apiUrl);
|
|
191
|
+
url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
|
|
192
|
+
url.search = "";
|
|
193
|
+
url.hash = "";
|
|
194
|
+
return url.toString().replace(/\/$/, "");
|
|
195
|
+
}
|
|
196
|
+
function createNodeVaultCache(options) {
|
|
197
|
+
const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body, true);
|
|
198
|
+
return createSecretCache({
|
|
199
|
+
ttlMs: options.ttlMs,
|
|
200
|
+
maxStaleMs: options.maxStaleMs,
|
|
201
|
+
list: async () => {
|
|
202
|
+
const payload = await post("list", {});
|
|
203
|
+
if (!Array.isArray(payload.entries) || payload.entries.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string")) {
|
|
204
|
+
throw new Error("node vault list response is malformed");
|
|
205
|
+
}
|
|
206
|
+
return payload.entries.map(projectVaultCacheKey);
|
|
207
|
+
},
|
|
208
|
+
fetch: async (key) => {
|
|
209
|
+
const coordinate = projectVaultCoordinate(key);
|
|
210
|
+
const payload = await post("read", coordinate);
|
|
211
|
+
if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
|
|
212
|
+
throw new Error("node vault read response is malformed");
|
|
213
|
+
}
|
|
214
|
+
return { value: payload.value, version: payload.version };
|
|
215
|
+
},
|
|
216
|
+
changes: async (since) => {
|
|
217
|
+
const payload = await post("changes", { since });
|
|
218
|
+
if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string"))
|
|
219
|
+
throw new Error("node vault changes response is malformed");
|
|
220
|
+
return {
|
|
221
|
+
version: payload.version,
|
|
222
|
+
changed: payload.changed.map(projectVaultCacheKey),
|
|
223
|
+
resync: payload.resync
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function startNodeVaultSync(cache, options = {}) {
|
|
229
|
+
const interval = Math.max(1000, options.intervalMs ?? 30000);
|
|
230
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
231
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
232
|
+
let stopped = false;
|
|
233
|
+
let timer;
|
|
234
|
+
let active = null;
|
|
235
|
+
const schedule = () => {
|
|
236
|
+
if (!stopped)
|
|
237
|
+
timer = setTimer(tick, interval);
|
|
238
|
+
};
|
|
239
|
+
const tick = () => {
|
|
240
|
+
if (stopped || active)
|
|
241
|
+
return;
|
|
242
|
+
active = cache.sync().then((result) => options.onEvent?.("synced", result)).catch((cause) => options.onEvent?.("sync-failed", cause)).finally(() => {
|
|
243
|
+
active = null;
|
|
244
|
+
schedule();
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
schedule();
|
|
248
|
+
return {
|
|
249
|
+
async stop() {
|
|
250
|
+
stopped = true;
|
|
251
|
+
clearTimer(timer);
|
|
252
|
+
await active;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/socket.ts
|
|
15
258
|
var MAX_LINE_BYTES = 64 * 1024;
|
|
16
259
|
function handleRequest(options, request) {
|
|
17
260
|
switch (request?.op) {
|
|
@@ -33,7 +276,7 @@ function handleRequest(options, request) {
|
|
|
33
276
|
return Promise.resolve({
|
|
34
277
|
ok: true,
|
|
35
278
|
op: "sign",
|
|
36
|
-
envelope:
|
|
279
|
+
envelope: signRequest2(options.keys, options.nodeKey, {
|
|
37
280
|
method: request.method,
|
|
38
281
|
path: request.path,
|
|
39
282
|
query: request.query ?? "",
|
|
@@ -45,10 +288,16 @@ function handleRequest(options, request) {
|
|
|
45
288
|
if (!options.cache) {
|
|
46
289
|
return Promise.resolve(refuse("NO_CACHE", "This agent holds no secrets. It signs; it does not serve values."));
|
|
47
290
|
}
|
|
48
|
-
if (typeof request.
|
|
49
|
-
return Promise.resolve(refuse("
|
|
291
|
+
if (typeof request.project !== "string" || request.project !== options.projectKey || typeof request.environment !== "string" || typeof request.name !== "string") {
|
|
292
|
+
return Promise.resolve(refuse("SCOPE_REFUSED", "This agent serves only its enrolled tenant project."));
|
|
50
293
|
}
|
|
51
|
-
|
|
294
|
+
let key;
|
|
295
|
+
try {
|
|
296
|
+
key = projectVaultCacheKey({ environment: request.environment, name: request.name });
|
|
297
|
+
} catch {
|
|
298
|
+
return Promise.resolve(refuse("BAD_REQUEST", "The vault coordinate is malformed."));
|
|
299
|
+
}
|
|
300
|
+
return options.cache.get(key).then((value) => ({ ok: true, op: "get", value })).catch((cause) => refuse(cause.code ?? "READ_FAILED", cause instanceof Error ? cause.message : "Could not read that value."));
|
|
52
301
|
}
|
|
53
302
|
case "sync": {
|
|
54
303
|
if (!options.cache) {
|
|
@@ -65,10 +314,21 @@ function handleRequest(options, request) {
|
|
|
65
314
|
staleForMs: 0
|
|
66
315
|
});
|
|
67
316
|
}
|
|
317
|
+
if (request.project !== options.projectKey) {
|
|
318
|
+
return Promise.resolve(refuse("SCOPE_REFUSED", "This agent serves only its enrolled tenant project."));
|
|
319
|
+
}
|
|
320
|
+
const names = options.cache.names().flatMap((key) => {
|
|
321
|
+
try {
|
|
322
|
+
const coordinate = projectVaultCoordinate(key);
|
|
323
|
+
return coordinate.environment === request.environment ? [coordinate.name] : [];
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
});
|
|
68
328
|
return Promise.resolve({
|
|
69
329
|
ok: true,
|
|
70
330
|
op: "held",
|
|
71
|
-
names
|
|
331
|
+
names,
|
|
72
332
|
staleForMs: options.cache.staleForMs()
|
|
73
333
|
});
|
|
74
334
|
}
|
|
@@ -121,7 +381,7 @@ function startAgent(options) {
|
|
|
121
381
|
socket.on("error", () => socket.destroy());
|
|
122
382
|
});
|
|
123
383
|
server.listen(options.socketPath, () => {
|
|
124
|
-
chmodSync(options.socketPath,
|
|
384
|
+
chmodSync(options.socketPath, 432);
|
|
125
385
|
});
|
|
126
386
|
return server;
|
|
127
387
|
}
|
|
@@ -740,54 +1000,6 @@ function requestControl(request, socketPath = DEFAULT_CONTROL_SOCKET) {
|
|
|
740
1000
|
});
|
|
741
1001
|
}
|
|
742
1002
|
|
|
743
|
-
// src/signed-node-http.ts
|
|
744
|
-
import { signRequest as signRequest2 } from "@forgezero/runtime/identity";
|
|
745
|
-
|
|
746
|
-
class SignedNodeHttpError extends Error {
|
|
747
|
-
status;
|
|
748
|
-
constructor(status, message) {
|
|
749
|
-
super(message);
|
|
750
|
-
this.status = status;
|
|
751
|
-
this.name = "SignedNodeHttpError";
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
var signatureHeader = (envelope) => Buffer.from(JSON.stringify({
|
|
755
|
-
timestamp: envelope.timestamp,
|
|
756
|
-
nonce: envelope.nonce,
|
|
757
|
-
edSignature: envelope.edSignature,
|
|
758
|
-
mlDsaSignature: envelope.mlDsaSignature
|
|
759
|
-
})).toString("base64url");
|
|
760
|
-
async function postSignedNode(options, path, body) {
|
|
761
|
-
const url = new URL(options.apiUrl);
|
|
762
|
-
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
763
|
-
url.search = "";
|
|
764
|
-
url.hash = "";
|
|
765
|
-
const raw = JSON.stringify(body);
|
|
766
|
-
const envelope = signRequest2(options.keys, options.nodeKey, {
|
|
767
|
-
method: "POST",
|
|
768
|
-
path: url.pathname,
|
|
769
|
-
query: "",
|
|
770
|
-
body: raw
|
|
771
|
-
});
|
|
772
|
-
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
773
|
-
method: "POST",
|
|
774
|
-
headers: {
|
|
775
|
-
"content-type": "application/json",
|
|
776
|
-
"x-fz-node": options.nodeKey,
|
|
777
|
-
"x-fz-signature": signatureHeader(envelope)
|
|
778
|
-
},
|
|
779
|
-
body: raw,
|
|
780
|
-
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
781
|
-
});
|
|
782
|
-
const payload = await response.json().catch(() => null);
|
|
783
|
-
if (!response.ok) {
|
|
784
|
-
const failure = payload;
|
|
785
|
-
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
786
|
-
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
787
|
-
}
|
|
788
|
-
return payload;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
1003
|
// src/deployment-pull.ts
|
|
792
1004
|
class DeploymentClaimLostError extends Error {
|
|
793
1005
|
constructor(message) {
|
|
@@ -900,160 +1112,34 @@ async function pullDeploymentOnce(options) {
|
|
|
900
1112
|
});
|
|
901
1113
|
return { status: "deployed", claim, result };
|
|
902
1114
|
}
|
|
903
|
-
function startDeploymentPull(options) {
|
|
904
|
-
const interval = Math.max(1000, options.intervalMs ?? 5000);
|
|
905
|
-
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
906
|
-
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
907
|
-
const emit = options.onEvent ?? (() => {});
|
|
908
|
-
const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
|
|
909
|
-
let stopped = false;
|
|
910
|
-
const workers = Array.from({ length: parallelism }, () => ({ active: null }));
|
|
911
|
-
const schedule = (worker) => {
|
|
912
|
-
if (!stopped)
|
|
913
|
-
worker.timer = setTimer(() => tick(worker), interval);
|
|
914
|
-
};
|
|
915
|
-
const tick = (worker) => {
|
|
916
|
-
if (stopped || worker.active)
|
|
917
|
-
return;
|
|
918
|
-
worker.active = pullDeploymentOnce(options).then((result) => emit(result.status, result)).catch((cause) => emit("poll-failed", cause)).finally(() => {
|
|
919
|
-
worker.active = null;
|
|
920
|
-
schedule(worker);
|
|
921
|
-
});
|
|
922
|
-
};
|
|
923
|
-
for (const worker of workers)
|
|
924
|
-
tick(worker);
|
|
925
|
-
return {
|
|
926
|
-
async stop() {
|
|
927
|
-
stopped = true;
|
|
928
|
-
for (const worker of workers)
|
|
929
|
-
clearTimer(worker.timer);
|
|
930
|
-
await Promise.all(workers.map((worker) => worker.active));
|
|
931
|
-
},
|
|
932
|
-
get active() {
|
|
933
|
-
return !stopped;
|
|
934
|
-
}
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// src/deployment-watch.ts
|
|
939
|
-
import {
|
|
940
|
-
chmodSync as chmodSync4,
|
|
941
|
-
closeSync,
|
|
942
|
-
fsyncSync,
|
|
943
|
-
mkdirSync as mkdirSync2,
|
|
944
|
-
openSync,
|
|
945
|
-
readFileSync as readFileSync2,
|
|
946
|
-
renameSync as renameSync2,
|
|
947
|
-
unlinkSync as unlinkSync3,
|
|
948
|
-
writeFileSync as writeFileSync2
|
|
949
|
-
} from "fs";
|
|
950
|
-
import { dirname as dirname2 } from "path";
|
|
951
|
-
var validState = (value) => {
|
|
952
|
-
const state = value;
|
|
953
|
-
return Boolean(state && /^[a-f0-9]{40}$/i.test(state.revision ?? "") && ["pending", "running", "deployed", "failed"].includes(state.outcome ?? "") && Number.isFinite(state.updatedAtTs));
|
|
954
|
-
};
|
|
955
|
-
function readStaticDeploymentState(path) {
|
|
956
|
-
try {
|
|
957
|
-
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
958
|
-
return validState(parsed) ? parsed : null;
|
|
959
|
-
} catch {
|
|
960
|
-
return null;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
function writeStaticDeploymentState(path, state) {
|
|
964
|
-
const directory = dirname2(path);
|
|
965
|
-
mkdirSync2(directory, { recursive: true, mode: 448 });
|
|
966
|
-
const temporary = `${path}.new-${process.pid}`;
|
|
967
|
-
let file;
|
|
968
|
-
try {
|
|
969
|
-
file = openSync(temporary, "w", 384);
|
|
970
|
-
writeFileSync2(file, `${JSON.stringify(state)}
|
|
971
|
-
`);
|
|
972
|
-
fsyncSync(file);
|
|
973
|
-
closeSync(file);
|
|
974
|
-
file = undefined;
|
|
975
|
-
chmodSync4(temporary, 384);
|
|
976
|
-
renameSync2(temporary, path);
|
|
977
|
-
const parent = openSync(directory, "r");
|
|
978
|
-
try {
|
|
979
|
-
fsyncSync(parent);
|
|
980
|
-
} finally {
|
|
981
|
-
closeSync(parent);
|
|
982
|
-
}
|
|
983
|
-
} catch (cause) {
|
|
984
|
-
if (file !== undefined)
|
|
985
|
-
closeSync(file);
|
|
986
|
-
try {
|
|
987
|
-
unlinkSync3(temporary);
|
|
988
|
-
} catch {}
|
|
989
|
-
throw cause;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
function startStaticDeploymentWatch(options) {
|
|
993
|
-
const interval = Math.max(1000, options.intervalMs ?? 15000);
|
|
1115
|
+
function startDeploymentPull(options) {
|
|
1116
|
+
const interval = Math.max(1000, options.intervalMs ?? 5000);
|
|
994
1117
|
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
995
1118
|
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
996
|
-
const
|
|
997
|
-
const
|
|
998
|
-
const writeState = options.writeState ?? ((state) => writeStaticDeploymentState(options.statePath, state));
|
|
999
|
-
const emit = options.onEvent ?? (() => {
|
|
1000
|
-
return;
|
|
1001
|
-
});
|
|
1119
|
+
const emit = options.onEvent ?? (() => {});
|
|
1120
|
+
const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
|
|
1002
1121
|
let stopped = false;
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
const persist = (revision, outcome, detail) => {
|
|
1006
|
-
writeState({ revision, outcome, updatedAtTs: now(), ...detail ? { detail: detail.slice(0, 2000) } : {} });
|
|
1007
|
-
};
|
|
1008
|
-
const schedule = () => {
|
|
1122
|
+
const workers = Array.from({ length: parallelism }, () => ({ active: null }));
|
|
1123
|
+
const schedule = (worker) => {
|
|
1009
1124
|
if (!stopped)
|
|
1010
|
-
timer = setTimer(tick, interval);
|
|
1011
|
-
};
|
|
1012
|
-
const run = async () => {
|
|
1013
|
-
const prior = readState();
|
|
1014
|
-
const revision = prior && (prior.outcome === "pending" || prior.outcome === "running") ? prior.revision : await options.manager.latestRevision();
|
|
1015
|
-
const current = options.currentRevision?.();
|
|
1016
|
-
if (prior?.outcome === "deployed" && prior.revision === revision) {
|
|
1017
|
-
emit("unchanged", { revision });
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
if (prior?.outcome === "failed" && prior.revision === revision) {
|
|
1021
|
-
emit("failed-unchanged", { revision, detail: prior.detail });
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
if (!prior && current === revision) {
|
|
1025
|
-
persist(revision, "deployed", "Adopted the already active bootstrap release.");
|
|
1026
|
-
emit("adopted", { revision });
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
persist(revision, "pending", "Observed at the configured branch head.");
|
|
1030
|
-
persist(revision, "running", "Submitted to the keyed deployment queue.");
|
|
1031
|
-
let result;
|
|
1032
|
-
try {
|
|
1033
|
-
result = await options.manager.deploy({ revision, coordinator: options.coordinator }).result;
|
|
1034
|
-
} catch (cause) {
|
|
1035
|
-
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
1036
|
-
persist(revision, "failed", detail);
|
|
1037
|
-
emit("failed", { revision, detail });
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
persist(revision, "deployed", `Activated ${result.release}.`);
|
|
1041
|
-
emit("deployed", { revision, release: result.release });
|
|
1125
|
+
worker.timer = setTimer(() => tick(worker), interval);
|
|
1042
1126
|
};
|
|
1043
|
-
|
|
1044
|
-
if (stopped ||
|
|
1127
|
+
const tick = (worker) => {
|
|
1128
|
+
if (stopped || worker.active)
|
|
1045
1129
|
return;
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
schedule();
|
|
1130
|
+
worker.active = pullDeploymentOnce(options).then((result) => emit(result.status, result)).catch((cause) => emit("poll-failed", cause)).finally(() => {
|
|
1131
|
+
worker.active = null;
|
|
1132
|
+
schedule(worker);
|
|
1049
1133
|
});
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1134
|
+
};
|
|
1135
|
+
for (const worker of workers)
|
|
1136
|
+
tick(worker);
|
|
1052
1137
|
return {
|
|
1053
1138
|
async stop() {
|
|
1054
1139
|
stopped = true;
|
|
1055
|
-
|
|
1056
|
-
|
|
1140
|
+
for (const worker of workers)
|
|
1141
|
+
clearTimer(worker.timer);
|
|
1142
|
+
await Promise.all(workers.map((worker) => worker.active));
|
|
1057
1143
|
},
|
|
1058
1144
|
get active() {
|
|
1059
1145
|
return !stopped;
|
|
@@ -1063,21 +1149,21 @@ function startStaticDeploymentWatch(options) {
|
|
|
1063
1149
|
|
|
1064
1150
|
// src/guest-enrolment.ts
|
|
1065
1151
|
import {
|
|
1066
|
-
chmodSync as
|
|
1152
|
+
chmodSync as chmodSync4,
|
|
1067
1153
|
existsSync as existsSync4,
|
|
1068
|
-
mkdirSync as
|
|
1069
|
-
readFileSync as
|
|
1070
|
-
renameSync as
|
|
1154
|
+
mkdirSync as mkdirSync2,
|
|
1155
|
+
readFileSync as readFileSync2,
|
|
1156
|
+
renameSync as renameSync2,
|
|
1071
1157
|
statSync,
|
|
1072
|
-
unlinkSync as
|
|
1073
|
-
writeFileSync as
|
|
1158
|
+
unlinkSync as unlinkSync3,
|
|
1159
|
+
writeFileSync as writeFileSync2
|
|
1074
1160
|
} from "fs";
|
|
1075
|
-
import { dirname as
|
|
1161
|
+
import { dirname as dirname2 } from "path";
|
|
1076
1162
|
var validBinding = (value, expectedNodeKey) => {
|
|
1077
1163
|
if (!value || typeof value !== "object")
|
|
1078
1164
|
return false;
|
|
1079
1165
|
const row = value;
|
|
1080
|
-
return ["nodeKey", "computeReference", "projectKey", "environmentKey"
|
|
1166
|
+
return ["nodeKey", "computeReference", "projectKey", "environmentKey"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (row.realm === "platform" || row.realm === "tenant" && typeof row.tenantSlug === "string" && row.tenantSlug.length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
|
|
1081
1167
|
};
|
|
1082
1168
|
function loadGuestBinding(path, expectedNodeKey) {
|
|
1083
1169
|
if (!existsSync4(path))
|
|
@@ -1088,7 +1174,7 @@ function loadGuestBinding(path, expectedNodeKey) {
|
|
|
1088
1174
|
}
|
|
1089
1175
|
let parsed;
|
|
1090
1176
|
try {
|
|
1091
|
-
parsed = JSON.parse(
|
|
1177
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
1092
1178
|
} catch {
|
|
1093
1179
|
throw new Error(`guest enrolment state at ${path} is malformed`);
|
|
1094
1180
|
}
|
|
@@ -1098,213 +1184,51 @@ function loadGuestBinding(path, expectedNodeKey) {
|
|
|
1098
1184
|
return parsed;
|
|
1099
1185
|
}
|
|
1100
1186
|
function persistGuestBinding(path, binding) {
|
|
1101
|
-
|
|
1187
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
|
|
1102
1188
|
const temporary = `${path}.next`;
|
|
1103
|
-
|
|
1189
|
+
writeFileSync2(temporary, `${JSON.stringify(binding)}
|
|
1104
1190
|
`, { mode: 384 });
|
|
1105
|
-
|
|
1106
|
-
|
|
1191
|
+
chmodSync4(temporary, 384);
|
|
1192
|
+
renameSync2(temporary, path);
|
|
1107
1193
|
}
|
|
1108
1194
|
async function enrolGuestIdentity(options) {
|
|
1109
|
-
const token = options.token?.trim() ?? (options.tokenPath ?
|
|
1195
|
+
const token = options.token?.trim() ?? (options.tokenPath ? readFileSync2(options.tokenPath, "utf8").trim() : "");
|
|
1110
1196
|
if (!token.startsWith("fze_"))
|
|
1111
1197
|
throw new Error("guest enrolment credential is malformed");
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
});
|
|
1130
|
-
const payload = await response.json().catch(() => null);
|
|
1131
|
-
if (!response.ok || !payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || !payload.tenantSlug) {
|
|
1132
|
-
throw new Error(payload?.error?.message || `guest enrolment returned HTTP ${response.status}`);
|
|
1198
|
+
const payload = await postSignedNode({
|
|
1199
|
+
apiUrl: options.apiUrl,
|
|
1200
|
+
nodeKey: options.nodeKey,
|
|
1201
|
+
keys: options.keys,
|
|
1202
|
+
fetch: options.fetch,
|
|
1203
|
+
requestTimeoutMs: options.requestTimeoutMs
|
|
1204
|
+
}, "v1/compute/enrol", {
|
|
1205
|
+
token,
|
|
1206
|
+
label: options.label,
|
|
1207
|
+
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
1208
|
+
publicKeys: {
|
|
1209
|
+
ed25519: options.keys.ed25519.publicKey,
|
|
1210
|
+
mlDsa: options.keys.mlDsa.publicKey
|
|
1211
|
+
}
|
|
1212
|
+
}, true);
|
|
1213
|
+
if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
|
|
1214
|
+
throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
|
|
1133
1215
|
}
|
|
1134
1216
|
const binding = {
|
|
1135
1217
|
nodeKey: payload.nodeKey,
|
|
1136
1218
|
computeReference: payload.computeReference,
|
|
1137
1219
|
projectKey: payload.projectKey,
|
|
1138
1220
|
environmentKey: payload.environmentKey,
|
|
1139
|
-
|
|
1221
|
+
realm: payload.realm,
|
|
1222
|
+
...payload.tenantSlug ? { tenantSlug: payload.tenantSlug } : {}
|
|
1140
1223
|
};
|
|
1141
1224
|
persistGuestBinding(options.statePath, binding);
|
|
1142
1225
|
if (options.consume)
|
|
1143
1226
|
await options.consume();
|
|
1144
1227
|
else if (options.tokenPath)
|
|
1145
|
-
|
|
1228
|
+
unlinkSync3(options.tokenPath);
|
|
1146
1229
|
return binding;
|
|
1147
1230
|
}
|
|
1148
1231
|
|
|
1149
|
-
// src/cache.ts
|
|
1150
|
-
class CacheError extends Error {
|
|
1151
|
-
code;
|
|
1152
|
-
constructor(code, message) {
|
|
1153
|
-
super(message);
|
|
1154
|
-
this.code = code;
|
|
1155
|
-
this.name = "CacheError";
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
var DEFAULT_TTL_MS = 60000;
|
|
1159
|
-
var DEFAULT_MAX_STALE_MS = 300000;
|
|
1160
|
-
function createSecretCache(options) {
|
|
1161
|
-
const entries = new Map;
|
|
1162
|
-
const now = options.now ?? (() => Date.now());
|
|
1163
|
-
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
1164
|
-
const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
|
|
1165
|
-
let cursor = 0;
|
|
1166
|
-
let replicated = false;
|
|
1167
|
-
let lastSyncOkMs = now();
|
|
1168
|
-
const loadScope = async () => {
|
|
1169
|
-
if (!options.list)
|
|
1170
|
-
return { loaded: 0, failed: [] };
|
|
1171
|
-
const names = await options.list();
|
|
1172
|
-
const failed = [];
|
|
1173
|
-
let loaded = 0;
|
|
1174
|
-
for (const name of names) {
|
|
1175
|
-
try {
|
|
1176
|
-
const result = await options.fetch(name);
|
|
1177
|
-
entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
|
|
1178
|
-
loaded += 1;
|
|
1179
|
-
} catch {
|
|
1180
|
-
failed.push(name);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
replicated = true;
|
|
1184
|
-
return { loaded, failed };
|
|
1185
|
-
};
|
|
1186
|
-
return {
|
|
1187
|
-
names: () => [...entries.keys()],
|
|
1188
|
-
get replica() {
|
|
1189
|
-
return replicated;
|
|
1190
|
-
},
|
|
1191
|
-
load: loadScope,
|
|
1192
|
-
get cursor() {
|
|
1193
|
-
return cursor;
|
|
1194
|
-
},
|
|
1195
|
-
async get(name) {
|
|
1196
|
-
const staleFor = now() - lastSyncOkMs;
|
|
1197
|
-
if (staleFor > maxStaleMs) {
|
|
1198
|
-
throw new CacheError("STALE", `Synchronisation has not succeeded for ${Math.round(staleFor / 1000)}s, so this ` + "cache can no longer vouch for what it holds. Refusing rather than serving a value " + "that may already be revoked.");
|
|
1199
|
-
}
|
|
1200
|
-
const cached = entries.get(name);
|
|
1201
|
-
if (cached && now() - cached.fetchedAtMs < ttlMs)
|
|
1202
|
-
return cached.value;
|
|
1203
|
-
let fetched;
|
|
1204
|
-
try {
|
|
1205
|
-
fetched = await options.fetch(name);
|
|
1206
|
-
} catch (cause) {
|
|
1207
|
-
if (cached)
|
|
1208
|
-
return cached.value;
|
|
1209
|
-
throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
|
|
1210
|
-
}
|
|
1211
|
-
entries.set(name, { ...fetched, fetchedAtMs: now() });
|
|
1212
|
-
return fetched.value;
|
|
1213
|
-
},
|
|
1214
|
-
async sync() {
|
|
1215
|
-
const result = await options.changes(cursor);
|
|
1216
|
-
if (result.resync) {
|
|
1217
|
-
const dropped = [...entries.keys()];
|
|
1218
|
-
entries.clear();
|
|
1219
|
-
cursor = 0;
|
|
1220
|
-
lastSyncOkMs = now();
|
|
1221
|
-
if (options.list)
|
|
1222
|
-
await loadScope();
|
|
1223
|
-
return { invalidated: dropped, cursor: 0, resync: true };
|
|
1224
|
-
}
|
|
1225
|
-
const invalidated = [];
|
|
1226
|
-
for (const name of result.changed) {
|
|
1227
|
-
if (entries.delete(name))
|
|
1228
|
-
invalidated.push(name);
|
|
1229
|
-
}
|
|
1230
|
-
cursor = result.version;
|
|
1231
|
-
lastSyncOkMs = now();
|
|
1232
|
-
return { invalidated, cursor, resync: false };
|
|
1233
|
-
},
|
|
1234
|
-
clear() {
|
|
1235
|
-
entries.clear();
|
|
1236
|
-
cursor = 0;
|
|
1237
|
-
},
|
|
1238
|
-
staleForMs: () => now() - lastSyncOkMs
|
|
1239
|
-
};
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// src/node-vault.ts
|
|
1243
|
-
function tenantNodeApiUrl(apiUrl, tenantSlug) {
|
|
1244
|
-
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
|
|
1245
|
-
throw new Error("tenant slug is malformed");
|
|
1246
|
-
const url = new URL(apiUrl);
|
|
1247
|
-
url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
|
|
1248
|
-
url.search = "";
|
|
1249
|
-
url.hash = "";
|
|
1250
|
-
return url.toString().replace(/\/$/, "");
|
|
1251
|
-
}
|
|
1252
|
-
function createNodeVaultCache(options) {
|
|
1253
|
-
const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
|
|
1254
|
-
return createSecretCache({
|
|
1255
|
-
ttlMs: options.ttlMs,
|
|
1256
|
-
maxStaleMs: options.maxStaleMs,
|
|
1257
|
-
list: async () => {
|
|
1258
|
-
const payload = await post("list", {});
|
|
1259
|
-
if (!Array.isArray(payload.names) || payload.names.some((name) => typeof name !== "string")) {
|
|
1260
|
-
throw new Error("node vault list response is malformed");
|
|
1261
|
-
}
|
|
1262
|
-
return payload.names;
|
|
1263
|
-
},
|
|
1264
|
-
fetch: async (name) => {
|
|
1265
|
-
const payload = await post("read", { name });
|
|
1266
|
-
if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
|
|
1267
|
-
throw new Error("node vault read response is malformed");
|
|
1268
|
-
}
|
|
1269
|
-
return { value: payload.value, version: payload.version };
|
|
1270
|
-
},
|
|
1271
|
-
changes: async (since) => {
|
|
1272
|
-
const payload = await post("changes", { since });
|
|
1273
|
-
if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((name) => typeof name !== "string"))
|
|
1274
|
-
throw new Error("node vault changes response is malformed");
|
|
1275
|
-
return { version: payload.version, changed: payload.changed, resync: payload.resync };
|
|
1276
|
-
}
|
|
1277
|
-
});
|
|
1278
|
-
}
|
|
1279
|
-
function startNodeVaultSync(cache, options = {}) {
|
|
1280
|
-
const interval = Math.max(1000, options.intervalMs ?? 30000);
|
|
1281
|
-
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
1282
|
-
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1283
|
-
let stopped = false;
|
|
1284
|
-
let timer;
|
|
1285
|
-
let active = null;
|
|
1286
|
-
const schedule = () => {
|
|
1287
|
-
if (!stopped)
|
|
1288
|
-
timer = setTimer(tick, interval);
|
|
1289
|
-
};
|
|
1290
|
-
const tick = () => {
|
|
1291
|
-
if (stopped || active)
|
|
1292
|
-
return;
|
|
1293
|
-
active = cache.sync().then((result) => options.onEvent?.("synced", result)).catch((cause) => options.onEvent?.("sync-failed", cause)).finally(() => {
|
|
1294
|
-
active = null;
|
|
1295
|
-
schedule();
|
|
1296
|
-
});
|
|
1297
|
-
};
|
|
1298
|
-
schedule();
|
|
1299
|
-
return {
|
|
1300
|
-
async stop() {
|
|
1301
|
-
stopped = true;
|
|
1302
|
-
clearTimer(timer);
|
|
1303
|
-
await active;
|
|
1304
|
-
}
|
|
1305
|
-
};
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
1232
|
// src/provisioning-pull.ts
|
|
1309
1233
|
class ProvisionClaimLostError extends Error {
|
|
1310
1234
|
}
|
|
@@ -1382,7 +1306,15 @@ async function pullProvisioningOnce(options) {
|
|
|
1382
1306
|
if (options.metalPreflight) {
|
|
1383
1307
|
const report = options.metalPreflight();
|
|
1384
1308
|
const accepted = await postSignedNode(options, "v1/metal/preflight", report);
|
|
1385
|
-
options.
|
|
1309
|
+
if (options.metalHostname && accepted.hostname !== options.metalHostname) {
|
|
1310
|
+
throw new Error(`metal agent: configured inventory hostname ${options.metalHostname} is bound as ${accepted.hostname}; refusing work.`);
|
|
1311
|
+
}
|
|
1312
|
+
options.onEvent?.("preflight", {
|
|
1313
|
+
...report,
|
|
1314
|
+
hostname: accepted.hostname,
|
|
1315
|
+
ready: accepted.ready,
|
|
1316
|
+
state: accepted.state
|
|
1317
|
+
});
|
|
1386
1318
|
if (!accepted.ready)
|
|
1387
1319
|
return { status: "idle" };
|
|
1388
1320
|
}
|
|
@@ -1443,21 +1375,22 @@ function startProvisioningPull(options) {
|
|
|
1443
1375
|
}
|
|
1444
1376
|
|
|
1445
1377
|
// src/metal-helper-socket.ts
|
|
1446
|
-
import { chmodSync as
|
|
1378
|
+
import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
1447
1379
|
import { connect as connect2, createServer as createServer3 } from "net";
|
|
1448
1380
|
|
|
1449
1381
|
// src/metal-provision.ts
|
|
1450
1382
|
import { createHash } from "crypto";
|
|
1451
1383
|
import {
|
|
1452
1384
|
existsSync as existsSync5,
|
|
1453
|
-
mkdirSync as
|
|
1454
|
-
readFileSync as
|
|
1385
|
+
mkdirSync as mkdirSync3,
|
|
1386
|
+
readFileSync as readFileSync3,
|
|
1455
1387
|
readdirSync,
|
|
1388
|
+
rmSync,
|
|
1456
1389
|
statSync as statSync2,
|
|
1457
|
-
unlinkSync as
|
|
1458
|
-
writeFileSync as
|
|
1390
|
+
unlinkSync as unlinkSync4,
|
|
1391
|
+
writeFileSync as writeFileSync3
|
|
1459
1392
|
} from "fs";
|
|
1460
|
-
import { dirname as
|
|
1393
|
+
import { dirname as dirname3, isAbsolute, join as join2 } from "path";
|
|
1461
1394
|
|
|
1462
1395
|
// src/compute.ts
|
|
1463
1396
|
class ComputeError extends Error {
|
|
@@ -1560,6 +1493,7 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
|
|
|
1560
1493
|
// src/provision.ts
|
|
1561
1494
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1562
1495
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1496
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
1563
1497
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
1564
1498
|
function deploymentRunnerSocketUnit(agentUser) {
|
|
1565
1499
|
return `[Unit]
|
|
@@ -1614,6 +1548,21 @@ WantedBy=multi-user.target
|
|
|
1614
1548
|
`;
|
|
1615
1549
|
}
|
|
1616
1550
|
|
|
1551
|
+
// src/ubuntu.ts
|
|
1552
|
+
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
1553
|
+
key: "ubuntu-resolute-20260731",
|
|
1554
|
+
family: "ubuntu-26.04",
|
|
1555
|
+
version: "2026-07-31",
|
|
1556
|
+
label: "Ubuntu 26.04 LTS Resolute",
|
|
1557
|
+
url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
|
|
1558
|
+
sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
|
|
1559
|
+
});
|
|
1560
|
+
function assertSupportedGuestImage(imageKey) {
|
|
1561
|
+
if (imageKey !== SUPPORTED_GUEST_IMAGE.key) {
|
|
1562
|
+
throw new Error(`unsupported guest image ${imageKey}; ForgeZero currently supports only ${SUPPORTED_GUEST_IMAGE.key}`);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1617
1566
|
// src/metal-provision.ts
|
|
1618
1567
|
var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
1619
1568
|
var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
|
|
@@ -1664,6 +1613,10 @@ function validateMetalProfile(profile) {
|
|
|
1664
1613
|
throw new MetalProvisionError("metal paths must be absolute");
|
|
1665
1614
|
}
|
|
1666
1615
|
new URL(profile.apiUrl);
|
|
1616
|
+
const imageKeys = Object.keys(profile.images);
|
|
1617
|
+
if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
|
|
1618
|
+
throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
|
|
1619
|
+
}
|
|
1667
1620
|
if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
|
|
1668
1621
|
throw new MetalProvisionError("at least one exclusive CPU pool is required");
|
|
1669
1622
|
}
|
|
@@ -1712,7 +1665,7 @@ function validateMetalProfile(profile) {
|
|
|
1712
1665
|
var readManifests = (stateDir) => {
|
|
1713
1666
|
if (!existsSync5(stateDir))
|
|
1714
1667
|
return [];
|
|
1715
|
-
return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(
|
|
1668
|
+
return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync3(join2(stateDir, name), "utf8")));
|
|
1716
1669
|
};
|
|
1717
1670
|
function allocateAddress(profile, computeKey, rows) {
|
|
1718
1671
|
const existing = rows.find((row) => row.computeKey === computeKey);
|
|
@@ -1729,6 +1682,21 @@ function allocateAddress(profile, computeKey, rows) {
|
|
|
1729
1682
|
}
|
|
1730
1683
|
throw new MetalProvisionError("guest address range is full");
|
|
1731
1684
|
}
|
|
1685
|
+
function requestedAddress(profile, claim, rows) {
|
|
1686
|
+
if (!claim.spec.guestAddress)
|
|
1687
|
+
return allocateAddress(profile, claim.computeKey, rows);
|
|
1688
|
+
if (!claim.spec.guestAddress.startsWith(`${profile.subnetPrefix}.`)) {
|
|
1689
|
+
throw new MetalProvisionError("requested guest address is outside the metal profile");
|
|
1690
|
+
}
|
|
1691
|
+
const last = Number(claim.spec.guestAddress.slice(profile.subnetPrefix.length + 1));
|
|
1692
|
+
if (!Number.isInteger(last) || last < profile.addressStart || last > profile.addressEnd) {
|
|
1693
|
+
throw new MetalProvisionError("requested guest address is outside the allocatable range");
|
|
1694
|
+
}
|
|
1695
|
+
const occupied = rows.find((row) => row.address === claim.spec.guestAddress && row.computeKey !== claim.computeKey);
|
|
1696
|
+
if (occupied)
|
|
1697
|
+
throw new MetalProvisionError("requested guest address is already allocated");
|
|
1698
|
+
return claim.spec.guestAddress;
|
|
1699
|
+
}
|
|
1732
1700
|
function allocateCpuPool(profile, claim, rows) {
|
|
1733
1701
|
const prior = rows.find((row) => row.computeKey === claim.computeKey);
|
|
1734
1702
|
if (prior) {
|
|
@@ -1739,6 +1707,15 @@ function allocateCpuPool(profile, claim, rows) {
|
|
|
1739
1707
|
return retained;
|
|
1740
1708
|
}
|
|
1741
1709
|
const used = new Set(rows.map((row) => row.cpuPoolKey));
|
|
1710
|
+
if (claim.spec.cpuPoolKey) {
|
|
1711
|
+
const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
|
|
1712
|
+
if (!requested || used.has(requested.key)) {
|
|
1713
|
+
throw new MetalProvisionError("requested CPU pool is unavailable");
|
|
1714
|
+
}
|
|
1715
|
+
if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
|
|
1716
|
+
throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
|
|
1717
|
+
return requested;
|
|
1718
|
+
}
|
|
1742
1719
|
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));
|
|
1743
1720
|
const selected = candidates[0];
|
|
1744
1721
|
if (!selected)
|
|
@@ -1751,7 +1728,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
1751
1728
|
encoding: b64
|
|
1752
1729
|
content: ${base64(content)}
|
|
1753
1730
|
`;
|
|
1754
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
|
|
1731
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
1755
1732
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
1756
1733
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
1757
1734
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -1764,7 +1741,9 @@ test -c /dev/sev-guest
|
|
|
1764
1741
|
` : "";
|
|
1765
1742
|
return `#!/usr/bin/env bash
|
|
1766
1743
|
set -Eeuo pipefail
|
|
1767
|
-
${attestationSetup}
|
|
1744
|
+
${attestationSetup}groupadd --system ${VAULT_GROUP} 2>/dev/null || true
|
|
1745
|
+
useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
|
|
1746
|
+
usermod -g ${VAULT_GROUP} forgezero-agent
|
|
1768
1747
|
groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
|
|
1769
1748
|
useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
|
|
1770
1749
|
usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
|
|
@@ -1775,13 +1754,13 @@ install -d -o root -g root -m 0755 /opt/forgezero
|
|
|
1775
1754
|
install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
|
|
1776
1755
|
install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
|
|
1777
1756
|
install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
|
|
1778
|
-
if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1757
|
+
${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1779
1758
|
systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
|
|
1780
1759
|
rm -f /run/forgezero-enrol-token
|
|
1781
1760
|
fi
|
|
1782
1761
|
chown root:root /var/lib/forgezero/enrol-token.cred
|
|
1783
1762
|
chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
1784
|
-
if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1763
|
+
` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1785
1764
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
1786
1765
|
printf '%s %s
|
|
1787
1766
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -1820,8 +1799,8 @@ systemctl daemon-reload
|
|
|
1820
1799
|
systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
|
|
1821
1800
|
`;
|
|
1822
1801
|
}
|
|
1823
|
-
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
|
|
1824
|
-
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp
|
|
1802
|
+
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
|
|
1803
|
+
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
1825
1804
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1826
1805
|
` : "";
|
|
1827
1806
|
const attestationDevice = attested ? `DevicePolicy=closed
|
|
@@ -1836,7 +1815,7 @@ Requires=forgezero-deploy-runner.service
|
|
|
1836
1815
|
[Service]
|
|
1837
1816
|
Type=simple
|
|
1838
1817
|
User=forgezero-agent
|
|
1839
|
-
Group
|
|
1818
|
+
Group=${VAULT_GROUP}
|
|
1840
1819
|
SupplementaryGroups=${DEPLOYMENT_GROUP}
|
|
1841
1820
|
LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
|
|
1842
1821
|
LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
|
|
@@ -1847,15 +1826,15 @@ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
|
|
|
1847
1826
|
Environment=FZ_NODE_LABEL=${name}
|
|
1848
1827
|
Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
|
|
1849
1828
|
Environment=FZ_DEPLOY_ROOT=/opt/forgezero
|
|
1850
|
-
Environment=FZ_DEPLOY_PULL
|
|
1829
|
+
Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
|
|
1851
1830
|
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1852
1831
|
Environment=HOME=/opt/forgezero/home
|
|
1853
1832
|
${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
|
|
1854
1833
|
Restart=on-failure
|
|
1855
1834
|
RestartSec=5
|
|
1856
1835
|
RuntimeDirectory=forgezero
|
|
1857
|
-
RuntimeDirectoryMode=
|
|
1858
|
-
UMask=
|
|
1836
|
+
RuntimeDirectoryMode=0750
|
|
1837
|
+
UMask=0007
|
|
1859
1838
|
LimitCORE=0
|
|
1860
1839
|
NoNewPrivileges=true
|
|
1861
1840
|
PrivateTmp=true
|
|
@@ -1905,24 +1884,39 @@ WantedBy=multi-user.target
|
|
|
1905
1884
|
`;
|
|
1906
1885
|
}
|
|
1907
1886
|
function cloudInit(profile, claim, manifest) {
|
|
1908
|
-
const
|
|
1909
|
-
const
|
|
1887
|
+
const enrolled = Boolean(claim.enrolment);
|
|
1888
|
+
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
|
|
1889
|
+
const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
|
|
1910
1890
|
const enrolmentDropIn = guestEnrolmentDropIn();
|
|
1911
1891
|
const cleanupScript = guestEnrolmentCleanupScript();
|
|
1912
1892
|
const cleanupUnit = guestEnrolmentCleanupUnit();
|
|
1913
1893
|
const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
|
|
1914
1894
|
const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
|
|
1895
|
+
const access = claim.access;
|
|
1896
|
+
const users = access ? `users:
|
|
1897
|
+
- default
|
|
1898
|
+
- name: ${access.sshUser}
|
|
1899
|
+
groups: [sudo]
|
|
1900
|
+
shell: /bin/bash
|
|
1901
|
+
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
1902
|
+
ssh_authorized_keys:
|
|
1903
|
+
${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
|
|
1904
|
+
`)}
|
|
1905
|
+
` : "";
|
|
1906
|
+
const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
|
|
1907
|
+
`, "0600")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}` : "";
|
|
1915
1908
|
return {
|
|
1916
1909
|
userData: `#cloud-config
|
|
1917
1910
|
package_update: true
|
|
1911
|
+
${users}disable_root: true
|
|
1918
1912
|
# Git is part of the deployment transport, not a tenant-selected prerequisite:
|
|
1919
1913
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
1920
1914
|
packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
|
|
1921
1915
|
write_files:
|
|
1922
|
-
${yamlFile("/
|
|
1923
|
-
`, "0600")}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}runcmd:
|
|
1916
|
+
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit, "0644")}runcmd:
|
|
1924
1917
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
1925
|
-
- [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1918
|
+
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1919
|
+
` : ""}
|
|
1926
1920
|
`,
|
|
1927
1921
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
1928
1922
|
local-hostname: ${manifest.name}
|
|
@@ -1946,7 +1940,8 @@ var checked = async (exec, argv) => {
|
|
|
1946
1940
|
};
|
|
1947
1941
|
async function provisionMetalGuest(profile, claim, exec) {
|
|
1948
1942
|
validateMetalProfile(profile);
|
|
1949
|
-
|
|
1943
|
+
assertSupportedGuestImage(claim.spec.imageKey);
|
|
1944
|
+
if (!claim.computeKey || !claim.spec.reference || !SAFE_NAME.test(claim.spec.imageKey) || claim.spec.guestName !== undefined && !SAFE_NAME.test(claim.spec.guestName) || claim.spec.cpuPoolKey !== undefined && !SAFE_NAME.test(claim.spec.cpuPoolKey) || !Number.isInteger(claim.spec.physicalCores) || claim.spec.physicalCores < 1 || claim.spec.physicalCores > 256 || !Number.isInteger(claim.spec.vcpu) || claim.spec.vcpu < 1 || claim.spec.vcpu > 512 || !Number.isInteger(claim.spec.memoryGib) || claim.spec.memoryGib < 1 || claim.spec.memoryGib > 8192 || !Number.isInteger(claim.spec.diskGib) || claim.spec.diskGib < 8 || claim.spec.diskGib > 65536 || !Number.isInteger(claim.spec.egressGuaranteedMbps) || claim.spec.egressGuaranteedMbps < 0 || !Number.isInteger(claim.spec.egressBurstMbps) || claim.spec.egressBurstMbps < claim.spec.egressGuaranteedMbps)
|
|
1950
1945
|
throw new MetalProvisionError("invalid compute claim");
|
|
1951
1946
|
const image = profile.images[claim.spec.imageKey];
|
|
1952
1947
|
if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
|
|
@@ -1958,14 +1953,24 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
1958
1953
|
if (digest !== image.sha256)
|
|
1959
1954
|
throw new MetalProvisionError("configured image checksum mismatch");
|
|
1960
1955
|
for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
|
|
1961
|
-
|
|
1956
|
+
mkdirSync3(path, { recursive: true, mode: 448 });
|
|
1962
1957
|
const manifests = readManifests(profile.stateDir);
|
|
1963
|
-
|
|
1958
|
+
if (claim.access) {
|
|
1959
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(claim.access.sshUser)) {
|
|
1960
|
+
throw new MetalProvisionError("invalid SSH user");
|
|
1961
|
+
}
|
|
1962
|
+
if (claim.access.sshPublicKeys.length < 1 || claim.access.sshPublicKeys.length > 16 || claim.access.sshPublicKeys.some((key) => typeof key !== "string" || key.length > 16384 || !/^ssh-(?:ed25519|rsa)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/.test(key.trim())))
|
|
1963
|
+
throw new MetalProvisionError("invalid SSH public key list");
|
|
1964
|
+
}
|
|
1965
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
1966
|
+
const conflictingName = manifests.find((row) => row.name === name && row.computeKey !== claim.computeKey);
|
|
1967
|
+
if (conflictingName)
|
|
1968
|
+
throw new MetalProvisionError("requested guest name is already allocated");
|
|
1964
1969
|
const manifestPath = join2(profile.stateDir, `${name}.json`);
|
|
1965
1970
|
const prior = manifests.find((row) => row.computeKey === claim.computeKey);
|
|
1966
1971
|
if (prior && prior.reference !== claim.spec.reference)
|
|
1967
1972
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
1968
|
-
const address =
|
|
1973
|
+
const address = requestedAddress(profile, claim, manifests);
|
|
1969
1974
|
const cpuPool = allocateCpuPool(profile, claim, manifests);
|
|
1970
1975
|
const manifest = prior ?? {
|
|
1971
1976
|
computeKey: claim.computeKey,
|
|
@@ -1978,7 +1983,7 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
1978
1983
|
allowedMemoryNodes: cpuPool.memoryNodes,
|
|
1979
1984
|
phase: "allocating"
|
|
1980
1985
|
};
|
|
1981
|
-
const save = () =>
|
|
1986
|
+
const save = () => writeFileSync3(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
1982
1987
|
`, { mode: 384 });
|
|
1983
1988
|
save();
|
|
1984
1989
|
const lv = `/dev/${profile.volumeGroup}/${name}`;
|
|
@@ -1992,9 +1997,9 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
1992
1997
|
}
|
|
1993
1998
|
const seedBase = join2(profile.seedDir, name);
|
|
1994
1999
|
const init = cloudInit(profile, claim, manifest);
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2000
|
+
writeFileSync3(`${seedBase}-user-data`, init.userData, { mode: 384 });
|
|
2001
|
+
writeFileSync3(`${seedBase}-meta-data`, init.metaData, { mode: 384 });
|
|
2002
|
+
writeFileSync3(`${seedBase}-network-config`, init.networkConfig, { mode: 384 });
|
|
1998
2003
|
const seed = `${seedBase}-seed.iso`;
|
|
1999
2004
|
await checked(exec, [
|
|
2000
2005
|
"cloud-localds",
|
|
@@ -2027,8 +2032,8 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
2027
2032
|
}
|
|
2028
2033
|
const service = `forgezero-guest@${name}.service`;
|
|
2029
2034
|
const unitPath = join2(profile.unitDir, service);
|
|
2030
|
-
|
|
2031
|
-
|
|
2035
|
+
mkdirSync3(dirname3(unitPath), { recursive: true });
|
|
2036
|
+
writeFileSync3(unitPath, guestUnit(spec), { mode: 420 });
|
|
2032
2037
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
2033
2038
|
if (prior?.phase === "running") {
|
|
2034
2039
|
await checked(exec, ["systemctl", "restart", service]);
|
|
@@ -2044,18 +2049,22 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
2044
2049
|
validateMetalProfile(profile);
|
|
2045
2050
|
if (claim.action !== "delete")
|
|
2046
2051
|
throw new MetalProvisionError("create claim cannot remove a guest");
|
|
2047
|
-
const name = guestNameFor(claim.computeKey);
|
|
2052
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
2048
2053
|
const manifestPath = join2(profile.stateDir, `${name}.json`);
|
|
2049
2054
|
if (!existsSync5(manifestPath))
|
|
2050
2055
|
return {};
|
|
2051
|
-
const manifest = JSON.parse(
|
|
2052
|
-
|
|
2056
|
+
const manifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
|
|
2057
|
+
const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
|
|
2058
|
+
const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
|
|
2059
|
+
if (!currentIdentity && !legacyPlatformIdentity) {
|
|
2053
2060
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
2054
2061
|
}
|
|
2055
2062
|
const service = `forgezero-guest@${name}.service`;
|
|
2056
2063
|
const unitPath = join2(profile.unitDir, service);
|
|
2057
2064
|
if (existsSync5(unitPath))
|
|
2058
2065
|
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
2066
|
+
else if (legacyPlatformIdentity)
|
|
2067
|
+
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
2059
2068
|
else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
|
|
2060
2069
|
throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
|
|
2061
2070
|
}
|
|
@@ -2071,7 +2080,15 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
2071
2080
|
manifestPath
|
|
2072
2081
|
])
|
|
2073
2082
|
if (existsSync5(path))
|
|
2074
|
-
|
|
2083
|
+
unlinkSync4(path);
|
|
2084
|
+
if (legacyPlatformIdentity) {
|
|
2085
|
+
const legacyConfig = `/etc/forgezero/guests/${name}.conf`;
|
|
2086
|
+
const legacyDropIn = join2(profile.unitDir, `${service}.d`);
|
|
2087
|
+
if (existsSync5(legacyConfig))
|
|
2088
|
+
unlinkSync4(legacyConfig);
|
|
2089
|
+
if (existsSync5(legacyDropIn))
|
|
2090
|
+
rmSync(legacyDropIn, { recursive: true });
|
|
2091
|
+
}
|
|
2075
2092
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
2076
2093
|
return {};
|
|
2077
2094
|
}
|
|
@@ -2109,7 +2126,7 @@ function startMetalHelper(options) {
|
|
|
2109
2126
|
validateMetalProfile(options.profile);
|
|
2110
2127
|
const socketPath = options.socketPath ?? DEFAULT_METAL_HELPER_SOCKET;
|
|
2111
2128
|
if (existsSync6(socketPath))
|
|
2112
|
-
|
|
2129
|
+
unlinkSync5(socketPath);
|
|
2113
2130
|
let tail = Promise.resolve();
|
|
2114
2131
|
const server = createServer3((socket) => {
|
|
2115
2132
|
let buffer = "";
|
|
@@ -2155,7 +2172,7 @@ function startMetalHelper(options) {
|
|
|
2155
2172
|
});
|
|
2156
2173
|
socket.on("error", () => socket.destroy());
|
|
2157
2174
|
});
|
|
2158
|
-
server.listen(socketPath, () =>
|
|
2175
|
+
server.listen(socketPath, () => chmodSync5(socketPath, 432));
|
|
2159
2176
|
return {
|
|
2160
2177
|
server,
|
|
2161
2178
|
async stop() {
|
|
@@ -2195,7 +2212,7 @@ function requestMetalProvision(claim, socketPath = DEFAULT_METAL_HELPER_SOCKET)
|
|
|
2195
2212
|
}
|
|
2196
2213
|
|
|
2197
2214
|
// src/deployment-runner.ts
|
|
2198
|
-
import { chmodSync as
|
|
2215
|
+
import { chmodSync as chmodSync6, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync6 } from "fs";
|
|
2199
2216
|
import { isAbsolute as isAbsolute2, resolve, sep } from "path";
|
|
2200
2217
|
import { connect as connect3, createServer as createServer4 } from "net";
|
|
2201
2218
|
var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -2300,7 +2317,7 @@ function startDeploymentRunner(options) {
|
|
|
2300
2317
|
const home = resolve(options.home);
|
|
2301
2318
|
const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
|
|
2302
2319
|
if (options.listenFd === undefined && existsSync7(socketPath))
|
|
2303
|
-
|
|
2320
|
+
unlinkSync6(socketPath);
|
|
2304
2321
|
const active = new Set;
|
|
2305
2322
|
const server = createServer4((socket) => {
|
|
2306
2323
|
let buffer = "";
|
|
@@ -2344,7 +2361,7 @@ function startDeploymentRunner(options) {
|
|
|
2344
2361
|
if (options.listenFd !== undefined)
|
|
2345
2362
|
server.listen({ fd: options.listenFd });
|
|
2346
2363
|
else
|
|
2347
|
-
server.listen(socketPath, () =>
|
|
2364
|
+
server.listen(socketPath, () => chmodSync6(socketPath, 432));
|
|
2348
2365
|
return {
|
|
2349
2366
|
server,
|
|
2350
2367
|
async stop() {
|
|
@@ -2563,7 +2580,7 @@ function startNodeAttestation(options) {
|
|
|
2563
2580
|
}
|
|
2564
2581
|
|
|
2565
2582
|
// src/metal-isolation.ts
|
|
2566
|
-
import { mkdirSync as
|
|
2583
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2567
2584
|
import { join as join3 } from "path";
|
|
2568
2585
|
var members = (list) => list.split(",").flatMap((part) => {
|
|
2569
2586
|
const [first, last = first] = part.split("-").map(Number);
|
|
@@ -2641,16 +2658,16 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
|
2641
2658
|
validateMetalProfile(profile);
|
|
2642
2659
|
await requireGuestsInSlice(exec);
|
|
2643
2660
|
const unitDir = profile.unitDir;
|
|
2644
|
-
|
|
2645
|
-
|
|
2661
|
+
mkdirSync4(unitDir, { recursive: true });
|
|
2662
|
+
writeFileSync4(join3(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
|
|
2646
2663
|
for (const unit of ["system.slice", "user.slice"]) {
|
|
2647
2664
|
const directory = join3(unitDir, `${unit}.d`);
|
|
2648
|
-
|
|
2649
|
-
|
|
2665
|
+
mkdirSync4(directory, { recursive: true });
|
|
2666
|
+
writeFileSync4(join3(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
|
|
2650
2667
|
}
|
|
2651
2668
|
const initDirectory = join3(unitDir, "init.scope.d");
|
|
2652
|
-
|
|
2653
|
-
|
|
2669
|
+
mkdirSync4(initDirectory, { recursive: true });
|
|
2670
|
+
writeFileSync4(join3(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
|
|
2654
2671
|
await checked2(exec, ["systemctl", "daemon-reload"]);
|
|
2655
2672
|
await requireGuestsInSlice(exec);
|
|
2656
2673
|
const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
|
|
@@ -2661,20 +2678,22 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
|
2661
2678
|
}
|
|
2662
2679
|
}
|
|
2663
2680
|
|
|
2681
|
+
// src/version.ts
|
|
2682
|
+
var VERSION = "0.1.12";
|
|
2683
|
+
|
|
2664
2684
|
// src/index.ts
|
|
2665
|
-
var VERSION = "0.1.10";
|
|
2666
2685
|
function loadOrCreateSeed(path) {
|
|
2667
2686
|
if (existsSync9(path)) {
|
|
2668
|
-
const seed2 = new Uint8Array(Buffer.from(
|
|
2687
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
|
|
2669
2688
|
if (seed2.length < 32) {
|
|
2670
2689
|
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
2671
2690
|
}
|
|
2672
2691
|
return seed2;
|
|
2673
2692
|
}
|
|
2674
|
-
|
|
2693
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
2675
2694
|
const seed = new Uint8Array(randomBytes(32));
|
|
2676
|
-
|
|
2677
|
-
|
|
2695
|
+
writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
2696
|
+
chmodSync7(path, 384);
|
|
2678
2697
|
return seed;
|
|
2679
2698
|
}
|
|
2680
2699
|
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
@@ -2687,7 +2706,7 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
|
|
|
2687
2706
|
const path = `${directory}/${name}`;
|
|
2688
2707
|
if (!existsSync9(path))
|
|
2689
2708
|
throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
|
|
2690
|
-
const seed = new Uint8Array(Buffer.from(
|
|
2709
|
+
const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
|
|
2691
2710
|
if (seed.length < 32)
|
|
2692
2711
|
throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
|
|
2693
2712
|
return seed;
|
|
@@ -2697,7 +2716,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
|
|
|
2697
2716
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
|
|
2698
2717
|
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
2699
2718
|
throw new Error("agent: invalid systemd credential name.");
|
|
2700
|
-
const value =
|
|
2719
|
+
const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
|
|
2701
2720
|
if (!value)
|
|
2702
2721
|
throw new Error(`agent: systemd credential ${name} is empty.`);
|
|
2703
2722
|
return value;
|
|
@@ -2732,9 +2751,15 @@ function runAgent(config = {}) {
|
|
|
2732
2751
|
record: config.record
|
|
2733
2752
|
};
|
|
2734
2753
|
const server = startAgent(options);
|
|
2735
|
-
return {
|
|
2736
|
-
|
|
2737
|
-
|
|
2754
|
+
return {
|
|
2755
|
+
server,
|
|
2756
|
+
keys,
|
|
2757
|
+
nodeKey,
|
|
2758
|
+
setVault: (cache, projectKey) => {
|
|
2759
|
+
options.cache = cache;
|
|
2760
|
+
options.projectKey = projectKey;
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2738
2763
|
}
|
|
2739
2764
|
if (import.meta.main) {
|
|
2740
2765
|
const args = process.argv.slice(2);
|
|
@@ -2797,7 +2822,7 @@ if (import.meta.main) {
|
|
|
2797
2822
|
nodeKey: nodeKey2,
|
|
2798
2823
|
keys: keys2,
|
|
2799
2824
|
label: process.env.FZ_NODE_LABEL,
|
|
2800
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
2825
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
2801
2826
|
});
|
|
2802
2827
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
2803
2828
|
process.exit(0);
|
|
@@ -2806,7 +2831,7 @@ if (import.meta.main) {
|
|
|
2806
2831
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2807
2832
|
if (!profilePath)
|
|
2808
2833
|
throw new Error("metal-helper requires --profile=/absolute/path.json");
|
|
2809
|
-
const profile = JSON.parse(
|
|
2834
|
+
const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
|
|
2810
2835
|
const helper = startMetalHelper({
|
|
2811
2836
|
profile,
|
|
2812
2837
|
socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
|
|
@@ -2828,7 +2853,7 @@ if (import.meta.main) {
|
|
|
2828
2853
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2829
2854
|
if (!profilePath)
|
|
2830
2855
|
throw new Error("metal-isolation requires --profile=/absolute/path.json");
|
|
2831
|
-
const profile = JSON.parse(
|
|
2856
|
+
const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
|
|
2832
2857
|
await applyMetalIsolation(profile);
|
|
2833
2858
|
console.log("[metal-isolation] host and guest cgroup boundaries active");
|
|
2834
2859
|
process.exit(0);
|
|
@@ -2857,9 +2882,32 @@ if (import.meta.main) {
|
|
|
2857
2882
|
process.on("SIGINT", () => void stop());
|
|
2858
2883
|
await new Promise(() => {});
|
|
2859
2884
|
}
|
|
2885
|
+
if (command === "metal-apply") {
|
|
2886
|
+
const claimArg = args.find((arg) => arg.startsWith("--claim="))?.slice("--claim=".length);
|
|
2887
|
+
if (!claimArg)
|
|
2888
|
+
throw new Error("metal-apply requires --claim=- or --claim=/absolute/path.json");
|
|
2889
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
2890
|
+
throw new Error("metal-apply must be invoked by the authenticated root bootstrap session");
|
|
2891
|
+
}
|
|
2892
|
+
if (claimArg !== "-" && !claimArg.startsWith("/")) {
|
|
2893
|
+
throw new Error("metal-apply claim path must be absolute");
|
|
2894
|
+
}
|
|
2895
|
+
const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
|
|
2896
|
+
if (Buffer.byteLength(raw) > 32 * 1024)
|
|
2897
|
+
throw new Error("metal-apply claim exceeds 32 KiB");
|
|
2898
|
+
const claim = JSON.parse(raw);
|
|
2899
|
+
if (claim.bootstrap?.kind !== "platform-genesis" || "enrolment" in claim) {
|
|
2900
|
+
throw new Error("metal-apply accepts only an offline platform-genesis claim");
|
|
2901
|
+
}
|
|
2902
|
+
const result = await requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET);
|
|
2903
|
+
console.log(JSON.stringify({ ok: true, computeKey: claim.computeKey, ...result }));
|
|
2904
|
+
process.exit(0);
|
|
2905
|
+
}
|
|
2860
2906
|
if (process.env.FZ_AGENT_ROLE === "metal") {
|
|
2861
2907
|
if (!process.env.FZ_API)
|
|
2862
2908
|
throw new Error("metal agent requires FZ_API");
|
|
2909
|
+
if (!process.env.FZ_METAL_HOSTNAME)
|
|
2910
|
+
throw new Error("metal agent requires FZ_METAL_HOSTNAME");
|
|
2863
2911
|
const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
|
|
2864
2912
|
const keys2 = deriveKeysFromSeed(seed);
|
|
2865
2913
|
const nodeKey2 = process.env.FZ_NODE_KEY ?? keys2.ed25519.publicKey;
|
|
@@ -2867,6 +2915,7 @@ if (import.meta.main) {
|
|
|
2867
2915
|
apiUrl: process.env.FZ_API,
|
|
2868
2916
|
nodeKey: nodeKey2,
|
|
2869
2917
|
keys: keys2,
|
|
2918
|
+
metalHostname: process.env.FZ_METAL_HOSTNAME,
|
|
2870
2919
|
run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
|
|
2871
2920
|
metalPreflight: () => ({
|
|
2872
2921
|
snpHost: existsSync9("/dev/sev"),
|
|
@@ -2943,28 +2992,33 @@ if (import.meta.main) {
|
|
|
2943
2992
|
nodeKey,
|
|
2944
2993
|
keys,
|
|
2945
2994
|
label: process.env.FZ_NODE_LABEL,
|
|
2946
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
2995
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
2947
2996
|
});
|
|
2948
2997
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
2949
2998
|
}
|
|
2950
|
-
let nodeApiUrl = binding && process.env.FZ_API ? tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
|
|
2999
|
+
let nodeApiUrl = binding && process.env.FZ_API ? binding.realm === "platform" ? process.env.FZ_API : tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
|
|
2951
3000
|
let secretCache;
|
|
2952
3001
|
let vaultSync;
|
|
2953
3002
|
if (binding && attestationSource && nodeApiUrl) {
|
|
2954
3003
|
const result = await attestNodeOnce({ apiUrl: nodeApiUrl, nodeKey, keys, source: attestationSource });
|
|
2955
3004
|
console.log(`[agent] initial SEV-SNP attestation verified ${result.measurement.slice(0, 16)}\u2026`);
|
|
2956
3005
|
}
|
|
2957
|
-
if (binding && nodeApiUrl) {
|
|
2958
|
-
secretCache = createNodeVaultCache({
|
|
3006
|
+
if (binding?.realm === "tenant" && nodeApiUrl) {
|
|
3007
|
+
secretCache = createNodeVaultCache({
|
|
3008
|
+
apiUrl: nodeApiUrl,
|
|
3009
|
+
nodeKey,
|
|
3010
|
+
keys,
|
|
3011
|
+
projectKey: binding.projectKey
|
|
3012
|
+
});
|
|
2959
3013
|
const loaded = await secretCache.load();
|
|
2960
3014
|
if (loaded.failed.length > 0) {
|
|
2961
3015
|
throw new Error(`agent: failed to load ${loaded.failed.length} assigned vault entries`);
|
|
2962
3016
|
}
|
|
2963
|
-
running.
|
|
3017
|
+
running.setVault(secretCache, binding.projectKey);
|
|
2964
3018
|
vaultSync = startNodeVaultSync(secretCache, {
|
|
2965
3019
|
onEvent: (event, detail) => console.log(`[agent] vault ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
2966
3020
|
});
|
|
2967
|
-
console.log(`[agent] in-memory vault loaded for ${binding.projectKey}
|
|
3021
|
+
console.log(`[agent] in-memory vault loaded for every environment in project ${binding.projectKey}`);
|
|
2968
3022
|
}
|
|
2969
3023
|
const attestationLoop = attestationSource && nodeApiUrl ? startNodeAttestation({
|
|
2970
3024
|
apiUrl: nodeApiUrl,
|
|
@@ -3024,23 +3078,6 @@ if (import.meta.main) {
|
|
|
3024
3078
|
const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
|
|
3025
3079
|
if (control)
|
|
3026
3080
|
console.log(`[agent] deployment control listening for ${repository}@${branch}`);
|
|
3027
|
-
const staticWatch = staticManager && process.env.FZ_DEPLOY_WATCH === "true" ? startStaticDeploymentWatch({
|
|
3028
|
-
manager: staticManager,
|
|
3029
|
-
statePath: process.env.FZ_DEPLOY_STATE ?? join4(root, "cache", "static-deployment.json"),
|
|
3030
|
-
coordinator: process.env.FZ_DEPLOY_COORDINATOR === "true",
|
|
3031
|
-
currentRevision: () => {
|
|
3032
|
-
try {
|
|
3033
|
-
const slot = readFileSync5(join4(root, ".forge-slot"), "utf8").trim();
|
|
3034
|
-
const revision = readFileSync5(join4(root, "slots", slot, ".git", "HEAD"), "utf8").trim();
|
|
3035
|
-
return /^[a-f0-9]{40}$/i.test(revision) ? revision.toLowerCase() : undefined;
|
|
3036
|
-
} catch {
|
|
3037
|
-
return;
|
|
3038
|
-
}
|
|
3039
|
-
},
|
|
3040
|
-
onEvent: (event, detail) => console.log(`[agent] static deployment ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
3041
|
-
}) : undefined;
|
|
3042
|
-
if (staticWatch)
|
|
3043
|
-
console.log(`[agent] watching ${repository}@${branch} for exact revisions`);
|
|
3044
3081
|
const pull = pullEnabled ? startDeploymentPull({
|
|
3045
3082
|
apiUrl: nodeApiUrl,
|
|
3046
3083
|
nodeKey,
|
|
@@ -3075,13 +3112,10 @@ if (import.meta.main) {
|
|
|
3075
3112
|
await new Promise((resolve2) => control.close(() => resolve2()));
|
|
3076
3113
|
const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
|
|
3077
3114
|
const deadline = Date.now() + deadlineMs;
|
|
3078
|
-
const pullDrain = Promise.
|
|
3079
|
-
pull?.stop() ?? Promise.resolve(),
|
|
3080
|
-
staticWatch?.stop() ?? Promise.resolve()
|
|
3081
|
-
]);
|
|
3115
|
+
const pullDrain = pull?.stop() ?? Promise.resolve();
|
|
3082
3116
|
const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
|
|
3083
3117
|
const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
|
|
3084
|
-
const pullWithinDeadline = pull
|
|
3118
|
+
const pullWithinDeadline = pull ? Promise.race([
|
|
3085
3119
|
pullDrain.then(() => true),
|
|
3086
3120
|
new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
|
|
3087
3121
|
]) : Promise.resolve(true);
|
|
@@ -3116,10 +3150,8 @@ if (import.meta.main) {
|
|
|
3116
3150
|
}
|
|
3117
3151
|
}
|
|
3118
3152
|
export {
|
|
3119
|
-
writeStaticDeploymentState,
|
|
3120
3153
|
validateMetalProfile,
|
|
3121
3154
|
tenantNodeApiUrl,
|
|
3122
|
-
startStaticDeploymentWatch,
|
|
3123
3155
|
startProvisioningPull,
|
|
3124
3156
|
startNodeVaultSync,
|
|
3125
3157
|
startNodeAttestation,
|
|
@@ -3133,10 +3165,11 @@ export {
|
|
|
3133
3165
|
requestDeploymentCommand,
|
|
3134
3166
|
requestControl,
|
|
3135
3167
|
removeMetalGuest,
|
|
3136
|
-
readStaticDeploymentState,
|
|
3137
3168
|
pullProvisioningOnce,
|
|
3138
3169
|
pullDeploymentOnce,
|
|
3139
3170
|
provisionMetalGuest,
|
|
3171
|
+
projectVaultCoordinate,
|
|
3172
|
+
projectVaultCacheKey,
|
|
3140
3173
|
metalHousekeepingDropIn,
|
|
3141
3174
|
metalGuestSliceUnit,
|
|
3142
3175
|
loadTextCredential,
|
|
@@ -3153,10 +3186,12 @@ export {
|
|
|
3153
3186
|
createDeploymentManager,
|
|
3154
3187
|
cloudInit,
|
|
3155
3188
|
attestNodeOnce,
|
|
3189
|
+
assertSupportedGuestImage,
|
|
3156
3190
|
applyMetalIsolation,
|
|
3157
3191
|
allocateCpuPool,
|
|
3158
3192
|
allocateAddress,
|
|
3159
3193
|
VERSION,
|
|
3194
|
+
SUPPORTED_GUEST_IMAGE,
|
|
3160
3195
|
DeploymentError,
|
|
3161
3196
|
DEFAULT_SOCKET_PATH,
|
|
3162
3197
|
DEFAULT_SEED_PATH,
|