@forgezero/agent 0.1.11 → 0.1.13
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 +545 -488
- 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 +152 -26
- package/dist/metal-provision.d.ts +4 -3
- package/dist/metal-provision.js +152 -26
- 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 +22 -1
- package/dist/provisioning-pull.js +21 -10
- 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."));
|
|
293
|
+
}
|
|
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."));
|
|
50
299
|
}
|
|
51
|
-
return options.cache.get(
|
|
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) {
|
|
@@ -905,155 +1117,29 @@ function startDeploymentPull(options) {
|
|
|
905
1117
|
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
906
1118
|
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
907
1119
|
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);
|
|
994
|
-
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
995
|
-
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
996
|
-
const now = options.now ?? Date.now;
|
|
997
|
-
const readState = options.readState ?? (() => readStaticDeploymentState(options.statePath));
|
|
998
|
-
const writeState = options.writeState ?? ((state) => writeStaticDeploymentState(options.statePath, state));
|
|
999
|
-
const emit = options.onEvent ?? (() => {
|
|
1000
|
-
return;
|
|
1001
|
-
});
|
|
1002
|
-
let stopped = false;
|
|
1003
|
-
let timer;
|
|
1004
|
-
let inFlight = null;
|
|
1005
|
-
const persist = (revision, outcome, detail) => {
|
|
1006
|
-
writeState({ revision, outcome, updatedAtTs: now(), ...detail ? { detail: detail.slice(0, 2000) } : {} });
|
|
1007
|
-
};
|
|
1008
|
-
const schedule = () => {
|
|
1120
|
+
const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
|
|
1121
|
+
let stopped = false;
|
|
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
|
}
|
|
@@ -1451,21 +1375,22 @@ function startProvisioningPull(options) {
|
|
|
1451
1375
|
}
|
|
1452
1376
|
|
|
1453
1377
|
// src/metal-helper-socket.ts
|
|
1454
|
-
import { chmodSync as
|
|
1378
|
+
import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
1455
1379
|
import { connect as connect2, createServer as createServer3 } from "net";
|
|
1456
1380
|
|
|
1457
1381
|
// src/metal-provision.ts
|
|
1458
1382
|
import { createHash } from "crypto";
|
|
1459
1383
|
import {
|
|
1460
1384
|
existsSync as existsSync5,
|
|
1461
|
-
mkdirSync as
|
|
1462
|
-
readFileSync as
|
|
1385
|
+
mkdirSync as mkdirSync3,
|
|
1386
|
+
readFileSync as readFileSync3,
|
|
1463
1387
|
readdirSync,
|
|
1388
|
+
rmSync,
|
|
1464
1389
|
statSync as statSync2,
|
|
1465
|
-
unlinkSync as
|
|
1466
|
-
writeFileSync as
|
|
1390
|
+
unlinkSync as unlinkSync4,
|
|
1391
|
+
writeFileSync as writeFileSync3
|
|
1467
1392
|
} from "fs";
|
|
1468
|
-
import { dirname as
|
|
1393
|
+
import { dirname as dirname3, isAbsolute, join as join2 } from "path";
|
|
1469
1394
|
|
|
1470
1395
|
// src/compute.ts
|
|
1471
1396
|
class ComputeError extends Error {
|
|
@@ -1568,6 +1493,7 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
|
|
|
1568
1493
|
// src/provision.ts
|
|
1569
1494
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1570
1495
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1496
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
1571
1497
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
1572
1498
|
function deploymentRunnerSocketUnit(agentUser) {
|
|
1573
1499
|
return `[Unit]
|
|
@@ -1622,6 +1548,21 @@ WantedBy=multi-user.target
|
|
|
1622
1548
|
`;
|
|
1623
1549
|
}
|
|
1624
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
|
+
|
|
1625
1566
|
// src/metal-provision.ts
|
|
1626
1567
|
var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
1627
1568
|
var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
|
|
@@ -1672,6 +1613,10 @@ function validateMetalProfile(profile) {
|
|
|
1672
1613
|
throw new MetalProvisionError("metal paths must be absolute");
|
|
1673
1614
|
}
|
|
1674
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
|
+
}
|
|
1675
1620
|
if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
|
|
1676
1621
|
throw new MetalProvisionError("at least one exclusive CPU pool is required");
|
|
1677
1622
|
}
|
|
@@ -1720,7 +1665,7 @@ function validateMetalProfile(profile) {
|
|
|
1720
1665
|
var readManifests = (stateDir) => {
|
|
1721
1666
|
if (!existsSync5(stateDir))
|
|
1722
1667
|
return [];
|
|
1723
|
-
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")));
|
|
1724
1669
|
};
|
|
1725
1670
|
function allocateAddress(profile, computeKey, rows) {
|
|
1726
1671
|
const existing = rows.find((row) => row.computeKey === computeKey);
|
|
@@ -1737,6 +1682,21 @@ function allocateAddress(profile, computeKey, rows) {
|
|
|
1737
1682
|
}
|
|
1738
1683
|
throw new MetalProvisionError("guest address range is full");
|
|
1739
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
|
+
}
|
|
1740
1700
|
function allocateCpuPool(profile, claim, rows) {
|
|
1741
1701
|
const prior = rows.find((row) => row.computeKey === claim.computeKey);
|
|
1742
1702
|
if (prior) {
|
|
@@ -1747,6 +1707,15 @@ function allocateCpuPool(profile, claim, rows) {
|
|
|
1747
1707
|
return retained;
|
|
1748
1708
|
}
|
|
1749
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
|
+
}
|
|
1750
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));
|
|
1751
1720
|
const selected = candidates[0];
|
|
1752
1721
|
if (!selected)
|
|
@@ -1759,7 +1728,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
1759
1728
|
encoding: b64
|
|
1760
1729
|
content: ${base64(content)}
|
|
1761
1730
|
`;
|
|
1762
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
|
|
1731
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
1763
1732
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
1764
1733
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
1765
1734
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -1772,7 +1741,9 @@ test -c /dev/sev-guest
|
|
|
1772
1741
|
` : "";
|
|
1773
1742
|
return `#!/usr/bin/env bash
|
|
1774
1743
|
set -Eeuo pipefail
|
|
1775
|
-
${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
|
|
1776
1747
|
groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
|
|
1777
1748
|
useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
|
|
1778
1749
|
usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
|
|
@@ -1783,13 +1754,13 @@ install -d -o root -g root -m 0755 /opt/forgezero
|
|
|
1783
1754
|
install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
|
|
1784
1755
|
install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
|
|
1785
1756
|
install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
|
|
1786
|
-
if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1757
|
+
${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1787
1758
|
systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
|
|
1788
1759
|
rm -f /run/forgezero-enrol-token
|
|
1789
1760
|
fi
|
|
1790
1761
|
chown root:root /var/lib/forgezero/enrol-token.cred
|
|
1791
1762
|
chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
1792
|
-
if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1763
|
+
` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1793
1764
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
1794
1765
|
printf '%s %s
|
|
1795
1766
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -1828,8 +1799,8 @@ systemctl daemon-reload
|
|
|
1828
1799
|
systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
|
|
1829
1800
|
`;
|
|
1830
1801
|
}
|
|
1831
|
-
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
|
|
1832
|
-
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
|
|
1833
1804
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1834
1805
|
` : "";
|
|
1835
1806
|
const attestationDevice = attested ? `DevicePolicy=closed
|
|
@@ -1844,7 +1815,7 @@ Requires=forgezero-deploy-runner.service
|
|
|
1844
1815
|
[Service]
|
|
1845
1816
|
Type=simple
|
|
1846
1817
|
User=forgezero-agent
|
|
1847
|
-
Group
|
|
1818
|
+
Group=${VAULT_GROUP}
|
|
1848
1819
|
SupplementaryGroups=${DEPLOYMENT_GROUP}
|
|
1849
1820
|
LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
|
|
1850
1821
|
LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
|
|
@@ -1855,15 +1826,15 @@ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
|
|
|
1855
1826
|
Environment=FZ_NODE_LABEL=${name}
|
|
1856
1827
|
Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
|
|
1857
1828
|
Environment=FZ_DEPLOY_ROOT=/opt/forgezero
|
|
1858
|
-
Environment=FZ_DEPLOY_PULL
|
|
1829
|
+
Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
|
|
1859
1830
|
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1860
1831
|
Environment=HOME=/opt/forgezero/home
|
|
1861
1832
|
${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
|
|
1862
1833
|
Restart=on-failure
|
|
1863
1834
|
RestartSec=5
|
|
1864
1835
|
RuntimeDirectory=forgezero
|
|
1865
|
-
RuntimeDirectoryMode=
|
|
1866
|
-
UMask=
|
|
1836
|
+
RuntimeDirectoryMode=0750
|
|
1837
|
+
UMask=0007
|
|
1867
1838
|
LimitCORE=0
|
|
1868
1839
|
NoNewPrivileges=true
|
|
1869
1840
|
PrivateTmp=true
|
|
@@ -1913,24 +1884,39 @@ WantedBy=multi-user.target
|
|
|
1913
1884
|
`;
|
|
1914
1885
|
}
|
|
1915
1886
|
function cloudInit(profile, claim, manifest) {
|
|
1916
|
-
const
|
|
1917
|
-
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);
|
|
1918
1890
|
const enrolmentDropIn = guestEnrolmentDropIn();
|
|
1919
1891
|
const cleanupScript = guestEnrolmentCleanupScript();
|
|
1920
1892
|
const cleanupUnit = guestEnrolmentCleanupUnit();
|
|
1921
1893
|
const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
|
|
1922
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")}` : "";
|
|
1923
1908
|
return {
|
|
1924
1909
|
userData: `#cloud-config
|
|
1925
1910
|
package_update: true
|
|
1911
|
+
${users}disable_root: true
|
|
1926
1912
|
# Git is part of the deployment transport, not a tenant-selected prerequisite:
|
|
1927
1913
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
1928
1914
|
packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
|
|
1929
1915
|
write_files:
|
|
1930
|
-
${yamlFile("/
|
|
1931
|
-
`, "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:
|
|
1932
1917
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
1933
|
-
- [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1918
|
+
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1919
|
+
` : ""}
|
|
1934
1920
|
`,
|
|
1935
1921
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
1936
1922
|
local-hostname: ${manifest.name}
|
|
@@ -1954,7 +1940,8 @@ var checked = async (exec, argv) => {
|
|
|
1954
1940
|
};
|
|
1955
1941
|
async function provisionMetalGuest(profile, claim, exec) {
|
|
1956
1942
|
validateMetalProfile(profile);
|
|
1957
|
-
|
|
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)
|
|
1958
1945
|
throw new MetalProvisionError("invalid compute claim");
|
|
1959
1946
|
const image = profile.images[claim.spec.imageKey];
|
|
1960
1947
|
if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
|
|
@@ -1966,14 +1953,24 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
1966
1953
|
if (digest !== image.sha256)
|
|
1967
1954
|
throw new MetalProvisionError("configured image checksum mismatch");
|
|
1968
1955
|
for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
|
|
1969
|
-
|
|
1956
|
+
mkdirSync3(path, { recursive: true, mode: 448 });
|
|
1970
1957
|
const manifests = readManifests(profile.stateDir);
|
|
1971
|
-
|
|
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");
|
|
1972
1969
|
const manifestPath = join2(profile.stateDir, `${name}.json`);
|
|
1973
1970
|
const prior = manifests.find((row) => row.computeKey === claim.computeKey);
|
|
1974
1971
|
if (prior && prior.reference !== claim.spec.reference)
|
|
1975
1972
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
1976
|
-
const address =
|
|
1973
|
+
const address = requestedAddress(profile, claim, manifests);
|
|
1977
1974
|
const cpuPool = allocateCpuPool(profile, claim, manifests);
|
|
1978
1975
|
const manifest = prior ?? {
|
|
1979
1976
|
computeKey: claim.computeKey,
|
|
@@ -1986,7 +1983,7 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
1986
1983
|
allowedMemoryNodes: cpuPool.memoryNodes,
|
|
1987
1984
|
phase: "allocating"
|
|
1988
1985
|
};
|
|
1989
|
-
const save = () =>
|
|
1986
|
+
const save = () => writeFileSync3(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
1990
1987
|
`, { mode: 384 });
|
|
1991
1988
|
save();
|
|
1992
1989
|
const lv = `/dev/${profile.volumeGroup}/${name}`;
|
|
@@ -2000,9 +1997,9 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
2000
1997
|
}
|
|
2001
1998
|
const seedBase = join2(profile.seedDir, name);
|
|
2002
1999
|
const init = cloudInit(profile, claim, manifest);
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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 });
|
|
2006
2003
|
const seed = `${seedBase}-seed.iso`;
|
|
2007
2004
|
await checked(exec, [
|
|
2008
2005
|
"cloud-localds",
|
|
@@ -2035,8 +2032,8 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
2035
2032
|
}
|
|
2036
2033
|
const service = `forgezero-guest@${name}.service`;
|
|
2037
2034
|
const unitPath = join2(profile.unitDir, service);
|
|
2038
|
-
|
|
2039
|
-
|
|
2035
|
+
mkdirSync3(dirname3(unitPath), { recursive: true });
|
|
2036
|
+
writeFileSync3(unitPath, guestUnit(spec), { mode: 420 });
|
|
2040
2037
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
2041
2038
|
if (prior?.phase === "running") {
|
|
2042
2039
|
await checked(exec, ["systemctl", "restart", service]);
|
|
@@ -2048,22 +2045,59 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
2048
2045
|
save();
|
|
2049
2046
|
return { guestAddress: address };
|
|
2050
2047
|
}
|
|
2048
|
+
function legacyPlatformManifest(profile, claim, name) {
|
|
2049
|
+
if (claim.bootstrap?.kind !== "platform-genesis" || claim.computeKey !== `platform:${name}` || claim.spec.reference !== `platform:${name}` || claim.spec.guestAddress === undefined)
|
|
2050
|
+
return;
|
|
2051
|
+
const legacyDir = profile.legacyStateDir ?? "/etc/forgezero/guests";
|
|
2052
|
+
const legacyPath = join2(legacyDir, `${name}.conf`);
|
|
2053
|
+
if (!existsSync5(legacyPath))
|
|
2054
|
+
return;
|
|
2055
|
+
const values = new Map;
|
|
2056
|
+
for (const line of readFileSync3(legacyPath, "utf8").split(/\r?\n/)) {
|
|
2057
|
+
if (!line || line.startsWith("#"))
|
|
2058
|
+
continue;
|
|
2059
|
+
const match = /^([A-Z][A-Z0-9_]*)=([^\s'"`$;|&<>]+)$/.exec(line);
|
|
2060
|
+
if (!match)
|
|
2061
|
+
throw new MetalProvisionError("legacy guest inventory contains unsafe syntax");
|
|
2062
|
+
values.set(match[1], match[2]);
|
|
2063
|
+
}
|
|
2064
|
+
const disk = `/dev/${profile.volumeGroup}/${name}`;
|
|
2065
|
+
if (values.get("NAME") !== name || values.get("DISK") !== disk || values.get("IP") !== claim.spec.guestAddress)
|
|
2066
|
+
throw new MetalProvisionError("legacy guest inventory does not match the platform claim");
|
|
2067
|
+
return {
|
|
2068
|
+
computeKey: claim.computeKey,
|
|
2069
|
+
reference: claim.spec.reference,
|
|
2070
|
+
name,
|
|
2071
|
+
address: claim.spec.guestAddress,
|
|
2072
|
+
mac: values.get("MAC") ?? "",
|
|
2073
|
+
cpuPoolKey: claim.spec.cpuPoolKey ?? "",
|
|
2074
|
+
allowedCpus: values.get("CPUSET") ?? "",
|
|
2075
|
+
allowedMemoryNodes: values.get("NUMA"),
|
|
2076
|
+
phase: "running",
|
|
2077
|
+
disk,
|
|
2078
|
+
unit: `forgezero-guest@${name}.service`
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2051
2081
|
async function removeMetalGuest(profile, claim, exec) {
|
|
2052
2082
|
validateMetalProfile(profile);
|
|
2053
2083
|
if (claim.action !== "delete")
|
|
2054
2084
|
throw new MetalProvisionError("create claim cannot remove a guest");
|
|
2055
|
-
const name = guestNameFor(claim.computeKey);
|
|
2085
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
2056
2086
|
const manifestPath = join2(profile.stateDir, `${name}.json`);
|
|
2057
|
-
|
|
2087
|
+
const manifest = existsSync5(manifestPath) ? JSON.parse(readFileSync3(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
|
|
2088
|
+
if (!manifest)
|
|
2058
2089
|
return {};
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2090
|
+
const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
|
|
2091
|
+
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`);
|
|
2092
|
+
if (!currentIdentity && !legacyPlatformIdentity) {
|
|
2061
2093
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
2062
2094
|
}
|
|
2063
2095
|
const service = `forgezero-guest@${name}.service`;
|
|
2064
2096
|
const unitPath = join2(profile.unitDir, service);
|
|
2065
2097
|
if (existsSync5(unitPath))
|
|
2066
2098
|
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
2099
|
+
else if (legacyPlatformIdentity)
|
|
2100
|
+
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
2067
2101
|
else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
|
|
2068
2102
|
throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
|
|
2069
2103
|
}
|
|
@@ -2079,7 +2113,15 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
2079
2113
|
manifestPath
|
|
2080
2114
|
])
|
|
2081
2115
|
if (existsSync5(path))
|
|
2082
|
-
|
|
2116
|
+
unlinkSync4(path);
|
|
2117
|
+
if (legacyPlatformIdentity) {
|
|
2118
|
+
const legacyConfig = join2(profile.legacyStateDir ?? "/etc/forgezero/guests", `${name}.conf`);
|
|
2119
|
+
const legacyDropIn = join2(profile.unitDir, `${service}.d`);
|
|
2120
|
+
if (existsSync5(legacyConfig))
|
|
2121
|
+
unlinkSync4(legacyConfig);
|
|
2122
|
+
if (existsSync5(legacyDropIn))
|
|
2123
|
+
rmSync(legacyDropIn, { recursive: true });
|
|
2124
|
+
}
|
|
2083
2125
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
2084
2126
|
return {};
|
|
2085
2127
|
}
|
|
@@ -2117,7 +2159,7 @@ function startMetalHelper(options) {
|
|
|
2117
2159
|
validateMetalProfile(options.profile);
|
|
2118
2160
|
const socketPath = options.socketPath ?? DEFAULT_METAL_HELPER_SOCKET;
|
|
2119
2161
|
if (existsSync6(socketPath))
|
|
2120
|
-
|
|
2162
|
+
unlinkSync5(socketPath);
|
|
2121
2163
|
let tail = Promise.resolve();
|
|
2122
2164
|
const server = createServer3((socket) => {
|
|
2123
2165
|
let buffer = "";
|
|
@@ -2163,7 +2205,7 @@ function startMetalHelper(options) {
|
|
|
2163
2205
|
});
|
|
2164
2206
|
socket.on("error", () => socket.destroy());
|
|
2165
2207
|
});
|
|
2166
|
-
server.listen(socketPath, () =>
|
|
2208
|
+
server.listen(socketPath, () => chmodSync5(socketPath, 432));
|
|
2167
2209
|
return {
|
|
2168
2210
|
server,
|
|
2169
2211
|
async stop() {
|
|
@@ -2203,7 +2245,7 @@ function requestMetalProvision(claim, socketPath = DEFAULT_METAL_HELPER_SOCKET)
|
|
|
2203
2245
|
}
|
|
2204
2246
|
|
|
2205
2247
|
// src/deployment-runner.ts
|
|
2206
|
-
import { chmodSync as
|
|
2248
|
+
import { chmodSync as chmodSync6, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync6 } from "fs";
|
|
2207
2249
|
import { isAbsolute as isAbsolute2, resolve, sep } from "path";
|
|
2208
2250
|
import { connect as connect3, createServer as createServer4 } from "net";
|
|
2209
2251
|
var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -2308,7 +2350,7 @@ function startDeploymentRunner(options) {
|
|
|
2308
2350
|
const home = resolve(options.home);
|
|
2309
2351
|
const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
|
|
2310
2352
|
if (options.listenFd === undefined && existsSync7(socketPath))
|
|
2311
|
-
|
|
2353
|
+
unlinkSync6(socketPath);
|
|
2312
2354
|
const active = new Set;
|
|
2313
2355
|
const server = createServer4((socket) => {
|
|
2314
2356
|
let buffer = "";
|
|
@@ -2352,7 +2394,7 @@ function startDeploymentRunner(options) {
|
|
|
2352
2394
|
if (options.listenFd !== undefined)
|
|
2353
2395
|
server.listen({ fd: options.listenFd });
|
|
2354
2396
|
else
|
|
2355
|
-
server.listen(socketPath, () =>
|
|
2397
|
+
server.listen(socketPath, () => chmodSync6(socketPath, 432));
|
|
2356
2398
|
return {
|
|
2357
2399
|
server,
|
|
2358
2400
|
async stop() {
|
|
@@ -2571,7 +2613,7 @@ function startNodeAttestation(options) {
|
|
|
2571
2613
|
}
|
|
2572
2614
|
|
|
2573
2615
|
// src/metal-isolation.ts
|
|
2574
|
-
import { mkdirSync as
|
|
2616
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2575
2617
|
import { join as join3 } from "path";
|
|
2576
2618
|
var members = (list) => list.split(",").flatMap((part) => {
|
|
2577
2619
|
const [first, last = first] = part.split("-").map(Number);
|
|
@@ -2649,16 +2691,16 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
|
2649
2691
|
validateMetalProfile(profile);
|
|
2650
2692
|
await requireGuestsInSlice(exec);
|
|
2651
2693
|
const unitDir = profile.unitDir;
|
|
2652
|
-
|
|
2653
|
-
|
|
2694
|
+
mkdirSync4(unitDir, { recursive: true });
|
|
2695
|
+
writeFileSync4(join3(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
|
|
2654
2696
|
for (const unit of ["system.slice", "user.slice"]) {
|
|
2655
2697
|
const directory = join3(unitDir, `${unit}.d`);
|
|
2656
|
-
|
|
2657
|
-
|
|
2698
|
+
mkdirSync4(directory, { recursive: true });
|
|
2699
|
+
writeFileSync4(join3(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
|
|
2658
2700
|
}
|
|
2659
2701
|
const initDirectory = join3(unitDir, "init.scope.d");
|
|
2660
|
-
|
|
2661
|
-
|
|
2702
|
+
mkdirSync4(initDirectory, { recursive: true });
|
|
2703
|
+
writeFileSync4(join3(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
|
|
2662
2704
|
await checked2(exec, ["systemctl", "daemon-reload"]);
|
|
2663
2705
|
await requireGuestsInSlice(exec);
|
|
2664
2706
|
const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
|
|
@@ -2669,20 +2711,22 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
|
2669
2711
|
}
|
|
2670
2712
|
}
|
|
2671
2713
|
|
|
2714
|
+
// src/version.ts
|
|
2715
|
+
var VERSION = "0.1.13";
|
|
2716
|
+
|
|
2672
2717
|
// src/index.ts
|
|
2673
|
-
var VERSION = "0.1.11";
|
|
2674
2718
|
function loadOrCreateSeed(path) {
|
|
2675
2719
|
if (existsSync9(path)) {
|
|
2676
|
-
const seed2 = new Uint8Array(Buffer.from(
|
|
2720
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
|
|
2677
2721
|
if (seed2.length < 32) {
|
|
2678
2722
|
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
2679
2723
|
}
|
|
2680
2724
|
return seed2;
|
|
2681
2725
|
}
|
|
2682
|
-
|
|
2726
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
2683
2727
|
const seed = new Uint8Array(randomBytes(32));
|
|
2684
|
-
|
|
2685
|
-
|
|
2728
|
+
writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
2729
|
+
chmodSync7(path, 384);
|
|
2686
2730
|
return seed;
|
|
2687
2731
|
}
|
|
2688
2732
|
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
@@ -2695,7 +2739,7 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
|
|
|
2695
2739
|
const path = `${directory}/${name}`;
|
|
2696
2740
|
if (!existsSync9(path))
|
|
2697
2741
|
throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
|
|
2698
|
-
const seed = new Uint8Array(Buffer.from(
|
|
2742
|
+
const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
|
|
2699
2743
|
if (seed.length < 32)
|
|
2700
2744
|
throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
|
|
2701
2745
|
return seed;
|
|
@@ -2705,7 +2749,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
|
|
|
2705
2749
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
|
|
2706
2750
|
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
2707
2751
|
throw new Error("agent: invalid systemd credential name.");
|
|
2708
|
-
const value =
|
|
2752
|
+
const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
|
|
2709
2753
|
if (!value)
|
|
2710
2754
|
throw new Error(`agent: systemd credential ${name} is empty.`);
|
|
2711
2755
|
return value;
|
|
@@ -2740,9 +2784,15 @@ function runAgent(config = {}) {
|
|
|
2740
2784
|
record: config.record
|
|
2741
2785
|
};
|
|
2742
2786
|
const server = startAgent(options);
|
|
2743
|
-
return {
|
|
2744
|
-
|
|
2745
|
-
|
|
2787
|
+
return {
|
|
2788
|
+
server,
|
|
2789
|
+
keys,
|
|
2790
|
+
nodeKey,
|
|
2791
|
+
setVault: (cache, projectKey) => {
|
|
2792
|
+
options.cache = cache;
|
|
2793
|
+
options.projectKey = projectKey;
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2746
2796
|
}
|
|
2747
2797
|
if (import.meta.main) {
|
|
2748
2798
|
const args = process.argv.slice(2);
|
|
@@ -2805,7 +2855,7 @@ if (import.meta.main) {
|
|
|
2805
2855
|
nodeKey: nodeKey2,
|
|
2806
2856
|
keys: keys2,
|
|
2807
2857
|
label: process.env.FZ_NODE_LABEL,
|
|
2808
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
2858
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
2809
2859
|
});
|
|
2810
2860
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
2811
2861
|
process.exit(0);
|
|
@@ -2814,7 +2864,7 @@ if (import.meta.main) {
|
|
|
2814
2864
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2815
2865
|
if (!profilePath)
|
|
2816
2866
|
throw new Error("metal-helper requires --profile=/absolute/path.json");
|
|
2817
|
-
const profile = JSON.parse(
|
|
2867
|
+
const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
|
|
2818
2868
|
const helper = startMetalHelper({
|
|
2819
2869
|
profile,
|
|
2820
2870
|
socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
|
|
@@ -2836,7 +2886,7 @@ if (import.meta.main) {
|
|
|
2836
2886
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2837
2887
|
if (!profilePath)
|
|
2838
2888
|
throw new Error("metal-isolation requires --profile=/absolute/path.json");
|
|
2839
|
-
const profile = JSON.parse(
|
|
2889
|
+
const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
|
|
2840
2890
|
await applyMetalIsolation(profile);
|
|
2841
2891
|
console.log("[metal-isolation] host and guest cgroup boundaries active");
|
|
2842
2892
|
process.exit(0);
|
|
@@ -2865,6 +2915,27 @@ if (import.meta.main) {
|
|
|
2865
2915
|
process.on("SIGINT", () => void stop());
|
|
2866
2916
|
await new Promise(() => {});
|
|
2867
2917
|
}
|
|
2918
|
+
if (command === "metal-apply") {
|
|
2919
|
+
const claimArg = args.find((arg) => arg.startsWith("--claim="))?.slice("--claim=".length);
|
|
2920
|
+
if (!claimArg)
|
|
2921
|
+
throw new Error("metal-apply requires --claim=- or --claim=/absolute/path.json");
|
|
2922
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
2923
|
+
throw new Error("metal-apply must be invoked by the authenticated root bootstrap session");
|
|
2924
|
+
}
|
|
2925
|
+
if (claimArg !== "-" && !claimArg.startsWith("/")) {
|
|
2926
|
+
throw new Error("metal-apply claim path must be absolute");
|
|
2927
|
+
}
|
|
2928
|
+
const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
|
|
2929
|
+
if (Buffer.byteLength(raw) > 32 * 1024)
|
|
2930
|
+
throw new Error("metal-apply claim exceeds 32 KiB");
|
|
2931
|
+
const claim = JSON.parse(raw);
|
|
2932
|
+
if (claim.bootstrap?.kind !== "platform-genesis" || "enrolment" in claim) {
|
|
2933
|
+
throw new Error("metal-apply accepts only an offline platform-genesis claim");
|
|
2934
|
+
}
|
|
2935
|
+
const result = await requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET);
|
|
2936
|
+
console.log(JSON.stringify({ ok: true, computeKey: claim.computeKey, ...result }));
|
|
2937
|
+
process.exit(0);
|
|
2938
|
+
}
|
|
2868
2939
|
if (process.env.FZ_AGENT_ROLE === "metal") {
|
|
2869
2940
|
if (!process.env.FZ_API)
|
|
2870
2941
|
throw new Error("metal agent requires FZ_API");
|
|
@@ -2954,28 +3025,33 @@ if (import.meta.main) {
|
|
|
2954
3025
|
nodeKey,
|
|
2955
3026
|
keys,
|
|
2956
3027
|
label: process.env.FZ_NODE_LABEL,
|
|
2957
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
3028
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
2958
3029
|
});
|
|
2959
3030
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
2960
3031
|
}
|
|
2961
|
-
let nodeApiUrl = binding && process.env.FZ_API ? tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
|
|
3032
|
+
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;
|
|
2962
3033
|
let secretCache;
|
|
2963
3034
|
let vaultSync;
|
|
2964
3035
|
if (binding && attestationSource && nodeApiUrl) {
|
|
2965
3036
|
const result = await attestNodeOnce({ apiUrl: nodeApiUrl, nodeKey, keys, source: attestationSource });
|
|
2966
3037
|
console.log(`[agent] initial SEV-SNP attestation verified ${result.measurement.slice(0, 16)}\u2026`);
|
|
2967
3038
|
}
|
|
2968
|
-
if (binding && nodeApiUrl) {
|
|
2969
|
-
secretCache = createNodeVaultCache({
|
|
3039
|
+
if (binding?.realm === "tenant" && nodeApiUrl) {
|
|
3040
|
+
secretCache = createNodeVaultCache({
|
|
3041
|
+
apiUrl: nodeApiUrl,
|
|
3042
|
+
nodeKey,
|
|
3043
|
+
keys,
|
|
3044
|
+
projectKey: binding.projectKey
|
|
3045
|
+
});
|
|
2970
3046
|
const loaded = await secretCache.load();
|
|
2971
3047
|
if (loaded.failed.length > 0) {
|
|
2972
3048
|
throw new Error(`agent: failed to load ${loaded.failed.length} assigned vault entries`);
|
|
2973
3049
|
}
|
|
2974
|
-
running.
|
|
3050
|
+
running.setVault(secretCache, binding.projectKey);
|
|
2975
3051
|
vaultSync = startNodeVaultSync(secretCache, {
|
|
2976
3052
|
onEvent: (event, detail) => console.log(`[agent] vault ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
2977
3053
|
});
|
|
2978
|
-
console.log(`[agent] in-memory vault loaded for ${binding.projectKey}
|
|
3054
|
+
console.log(`[agent] in-memory vault loaded for every environment in project ${binding.projectKey}`);
|
|
2979
3055
|
}
|
|
2980
3056
|
const attestationLoop = attestationSource && nodeApiUrl ? startNodeAttestation({
|
|
2981
3057
|
apiUrl: nodeApiUrl,
|
|
@@ -3035,23 +3111,6 @@ if (import.meta.main) {
|
|
|
3035
3111
|
const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
|
|
3036
3112
|
if (control)
|
|
3037
3113
|
console.log(`[agent] deployment control listening for ${repository}@${branch}`);
|
|
3038
|
-
const staticWatch = staticManager && process.env.FZ_DEPLOY_WATCH === "true" ? startStaticDeploymentWatch({
|
|
3039
|
-
manager: staticManager,
|
|
3040
|
-
statePath: process.env.FZ_DEPLOY_STATE ?? join4(root, "cache", "static-deployment.json"),
|
|
3041
|
-
coordinator: process.env.FZ_DEPLOY_COORDINATOR === "true",
|
|
3042
|
-
currentRevision: () => {
|
|
3043
|
-
try {
|
|
3044
|
-
const slot = readFileSync5(join4(root, ".forge-slot"), "utf8").trim();
|
|
3045
|
-
const revision = readFileSync5(join4(root, "slots", slot, ".git", "HEAD"), "utf8").trim();
|
|
3046
|
-
return /^[a-f0-9]{40}$/i.test(revision) ? revision.toLowerCase() : undefined;
|
|
3047
|
-
} catch {
|
|
3048
|
-
return;
|
|
3049
|
-
}
|
|
3050
|
-
},
|
|
3051
|
-
onEvent: (event, detail) => console.log(`[agent] static deployment ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
3052
|
-
}) : undefined;
|
|
3053
|
-
if (staticWatch)
|
|
3054
|
-
console.log(`[agent] watching ${repository}@${branch} for exact revisions`);
|
|
3055
3114
|
const pull = pullEnabled ? startDeploymentPull({
|
|
3056
3115
|
apiUrl: nodeApiUrl,
|
|
3057
3116
|
nodeKey,
|
|
@@ -3086,13 +3145,10 @@ if (import.meta.main) {
|
|
|
3086
3145
|
await new Promise((resolve2) => control.close(() => resolve2()));
|
|
3087
3146
|
const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
|
|
3088
3147
|
const deadline = Date.now() + deadlineMs;
|
|
3089
|
-
const pullDrain = Promise.
|
|
3090
|
-
pull?.stop() ?? Promise.resolve(),
|
|
3091
|
-
staticWatch?.stop() ?? Promise.resolve()
|
|
3092
|
-
]);
|
|
3148
|
+
const pullDrain = pull?.stop() ?? Promise.resolve();
|
|
3093
3149
|
const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
|
|
3094
3150
|
const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
|
|
3095
|
-
const pullWithinDeadline = pull
|
|
3151
|
+
const pullWithinDeadline = pull ? Promise.race([
|
|
3096
3152
|
pullDrain.then(() => true),
|
|
3097
3153
|
new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
|
|
3098
3154
|
]) : Promise.resolve(true);
|
|
@@ -3127,10 +3183,8 @@ if (import.meta.main) {
|
|
|
3127
3183
|
}
|
|
3128
3184
|
}
|
|
3129
3185
|
export {
|
|
3130
|
-
writeStaticDeploymentState,
|
|
3131
3186
|
validateMetalProfile,
|
|
3132
3187
|
tenantNodeApiUrl,
|
|
3133
|
-
startStaticDeploymentWatch,
|
|
3134
3188
|
startProvisioningPull,
|
|
3135
3189
|
startNodeVaultSync,
|
|
3136
3190
|
startNodeAttestation,
|
|
@@ -3144,10 +3198,11 @@ export {
|
|
|
3144
3198
|
requestDeploymentCommand,
|
|
3145
3199
|
requestControl,
|
|
3146
3200
|
removeMetalGuest,
|
|
3147
|
-
readStaticDeploymentState,
|
|
3148
3201
|
pullProvisioningOnce,
|
|
3149
3202
|
pullDeploymentOnce,
|
|
3150
3203
|
provisionMetalGuest,
|
|
3204
|
+
projectVaultCoordinate,
|
|
3205
|
+
projectVaultCacheKey,
|
|
3151
3206
|
metalHousekeepingDropIn,
|
|
3152
3207
|
metalGuestSliceUnit,
|
|
3153
3208
|
loadTextCredential,
|
|
@@ -3164,10 +3219,12 @@ export {
|
|
|
3164
3219
|
createDeploymentManager,
|
|
3165
3220
|
cloudInit,
|
|
3166
3221
|
attestNodeOnce,
|
|
3222
|
+
assertSupportedGuestImage,
|
|
3167
3223
|
applyMetalIsolation,
|
|
3168
3224
|
allocateCpuPool,
|
|
3169
3225
|
allocateAddress,
|
|
3170
3226
|
VERSION,
|
|
3227
|
+
SUPPORTED_GUEST_IMAGE,
|
|
3171
3228
|
DeploymentError,
|
|
3172
3229
|
DEFAULT_SOCKET_PATH,
|
|
3173
3230
|
DEFAULT_SEED_PATH,
|