@forgezero/agent 0.1.11 → 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.
Files changed (48) hide show
  1. package/README.md +36 -9
  2. package/dist/deployment.d.ts +1 -1
  3. package/dist/fz-agent.js +514 -490
  4. package/dist/fz.js +34 -12
  5. package/dist/guest-enrolment.d.ts +2 -1
  6. package/dist/guest-enrolment.js +81 -23
  7. package/dist/index.d.ts +5 -29
  8. package/dist/metal-helper-socket.js +117 -24
  9. package/dist/metal-provision.d.ts +2 -2
  10. package/dist/metal-provision.js +117 -24
  11. package/dist/node-vault.d.ts +9 -0
  12. package/dist/node-vault.js +53 -17
  13. package/dist/provision.d.ts +1 -0
  14. package/dist/provision.js +14 -4
  15. package/dist/provisioning-pull.d.ts +22 -1
  16. package/dist/provisioning-pull.js +21 -10
  17. package/dist/signed-node-http.d.ts +1 -1
  18. package/dist/socket.d.ts +11 -4
  19. package/dist/ubuntu.d.ts +16 -0
  20. package/dist/ubuntu.js +18 -0
  21. package/dist/version.d.ts +2 -0
  22. package/package.json +11 -6
  23. package/dist/attestation-client.test.d.ts +0 -1
  24. package/dist/cache.test.d.ts +0 -1
  25. package/dist/cli/agent-install.test.d.ts +0 -1
  26. package/dist/cli/options.test.d.ts +0 -1
  27. package/dist/cli/run.test.d.ts +0 -1
  28. package/dist/compute.test.d.ts +0 -1
  29. package/dist/control.test.d.ts +0 -1
  30. package/dist/definition.test.d.ts +0 -1
  31. package/dist/deployment-pull.test.d.ts +0 -1
  32. package/dist/deployment-runner.test.d.ts +0 -1
  33. package/dist/deployment-watch.d.ts +0 -36
  34. package/dist/deployment-watch.test.d.ts +0 -1
  35. package/dist/deployment.test.d.ts +0 -1
  36. package/dist/guest-enrolment.test.d.ts +0 -1
  37. package/dist/index.test.d.ts +0 -1
  38. package/dist/metal-helper-socket.test.d.ts +0 -1
  39. package/dist/metal-isolation.test.d.ts +0 -1
  40. package/dist/metal-provision.test.d.ts +0 -1
  41. package/dist/node-vault.test.d.ts +0 -1
  42. package/dist/pipeline.test.d.ts +0 -1
  43. package/dist/provisioning-pull.test.d.ts +0 -1
  44. package/dist/snp-attestation.test.d.ts +0 -1
  45. package/dist/socket.test.d.ts +0 -1
  46. package/dist/ssh-listen.test.d.ts +0 -1
  47. package/dist/ssh-server.test.d.ts +0 -1
  48. 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 readFileSync5, writeFileSync as writeFileSync6, existsSync as existsSync9, mkdirSync as mkdirSync6, chmodSync as chmodSync8 } from "fs";
7
- import { dirname as dirname5, join as join4 } from "path";
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: signRequest(options.keys, options.nodeKey, {
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.name !== "string" || !request.name) {
49
- return Promise.resolve(refuse("BAD_REQUEST", "A read needs a name."));
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
- return options.cache.get(request.name).then((value) => ({ ok: true, op: "get", value })).catch((cause) => refuse(cause.code ?? "READ_FAILED", cause instanceof Error ? cause.message : "Could not read that value."));
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: options.cache.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);
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) {
@@ -898,162 +1110,36 @@ async function pullDeploymentOnce(options) {
898
1110
  detail: `Deployed ${result.revision}.`,
899
1111
  release: result.release
900
1112
  });
901
- return { status: "deployed", claim, result };
902
- }
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
- }
1113
+ return { status: "deployed", claim, result };
991
1114
  }
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 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
- });
1119
+ const emit = options.onEvent ?? (() => {});
1120
+ const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
1002
1121
  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 = () => {
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
- function tick() {
1044
- if (stopped || inFlight)
1127
+ const tick = (worker) => {
1128
+ if (stopped || worker.active)
1045
1129
  return;
1046
- inFlight = run().catch((cause) => emit("poll-failed", cause)).finally(() => {
1047
- inFlight = null;
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
- tick();
1134
+ };
1135
+ for (const worker of workers)
1136
+ tick(worker);
1052
1137
  return {
1053
1138
  async stop() {
1054
1139
  stopped = true;
1055
- clearTimer(timer);
1056
- await inFlight;
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 chmodSync5,
1152
+ chmodSync as chmodSync4,
1067
1153
  existsSync as existsSync4,
1068
- mkdirSync as mkdirSync3,
1069
- readFileSync as readFileSync3,
1070
- renameSync as renameSync3,
1154
+ mkdirSync as mkdirSync2,
1155
+ readFileSync as readFileSync2,
1156
+ renameSync as renameSync2,
1071
1157
  statSync,
1072
- unlinkSync as unlinkSync4,
1073
- writeFileSync as writeFileSync3
1158
+ unlinkSync as unlinkSync3,
1159
+ writeFileSync as writeFileSync2
1074
1160
  } from "fs";
1075
- import { dirname as dirname3 } from "path";
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", "tenantSlug"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
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(readFileSync3(path, "utf8"));
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
- mkdirSync3(dirname3(path), { recursive: true, mode: 448 });
1187
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
1102
1188
  const temporary = `${path}.next`;
1103
- writeFileSync3(temporary, `${JSON.stringify(binding)}
1189
+ writeFileSync2(temporary, `${JSON.stringify(binding)}
1104
1190
  `, { mode: 384 });
1105
- chmodSync5(temporary, 384);
1106
- renameSync3(temporary, path);
1191
+ chmodSync4(temporary, 384);
1192
+ renameSync2(temporary, path);
1107
1193
  }
1108
1194
  async function enrolGuestIdentity(options) {
1109
- const token = options.token?.trim() ?? (options.tokenPath ? readFileSync3(options.tokenPath, "utf8").trim() : "");
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 url = new URL(options.apiUrl);
1113
- url.pathname = `${url.pathname.replace(/\/$/, "")}/v1/compute/enrol`.replace(/\/+/g, "/");
1114
- url.search = "";
1115
- url.hash = "";
1116
- const response = await (options.fetch ?? globalThis.fetch)(url, {
1117
- method: "POST",
1118
- headers: { "content-type": "application/json" },
1119
- body: JSON.stringify({
1120
- token,
1121
- label: options.label,
1122
- gitDeployPublicKey: options.gitDeployPublicKey,
1123
- publicKeys: {
1124
- ed25519: options.keys.ed25519.publicKey,
1125
- mlDsa: options.keys.mlDsa.publicKey
1126
- }
1127
- }),
1128
- signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
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
- tenantSlug: payload.tenantSlug
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
- unlinkSync4(options.tokenPath);
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 chmodSync6, existsSync as existsSync6, unlinkSync as unlinkSync6 } from "fs";
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 mkdirSync4,
1462
- readFileSync as readFileSync4,
1385
+ mkdirSync as mkdirSync3,
1386
+ readFileSync as readFileSync3,
1463
1387
  readdirSync,
1388
+ rmSync,
1464
1389
  statSync as statSync2,
1465
- unlinkSync as unlinkSync5,
1466
- writeFileSync as writeFileSync4
1390
+ unlinkSync as unlinkSync4,
1391
+ writeFileSync as writeFileSync3
1467
1392
  } from "fs";
1468
- import { dirname as dirname4, isAbsolute, join as join2 } from "path";
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(readFileSync4(join2(stateDir, name), "utf8")));
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}useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
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 forgezero-agent /dev/sev-guest
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=forgezero-agent
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=true
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=0710
1866
- UMask=0077
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 bootstrap = guestBootstrapScript(profile, claim.spec.confidential);
1917
- const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential);
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("/run/forgezero-enrol-token", `${claim.enrolment.token}
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
- if (!claim.computeKey || !claim.spec.reference || !SAFE_NAME.test(claim.spec.imageKey) || !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)
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
- mkdirSync4(path, { recursive: true, mode: 448 });
1956
+ mkdirSync3(path, { recursive: true, mode: 448 });
1970
1957
  const manifests = readManifests(profile.stateDir);
1971
- const name = guestNameFor(claim.computeKey);
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 = allocateAddress(profile, claim.computeKey, manifests);
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 = () => writeFileSync4(manifestPath, `${JSON.stringify(manifest, null, 2)}
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
- writeFileSync4(`${seedBase}-user-data`, init.userData, { mode: 384 });
2004
- writeFileSync4(`${seedBase}-meta-data`, init.metaData, { mode: 384 });
2005
- writeFileSync4(`${seedBase}-network-config`, init.networkConfig, { mode: 384 });
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
- mkdirSync4(dirname4(unitPath), { recursive: true });
2039
- writeFileSync4(unitPath, guestUnit(spec), { mode: 420 });
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]);
@@ -2052,18 +2049,22 @@ async function removeMetalGuest(profile, claim, exec) {
2052
2049
  validateMetalProfile(profile);
2053
2050
  if (claim.action !== "delete")
2054
2051
  throw new MetalProvisionError("create claim cannot remove a guest");
2055
- const name = guestNameFor(claim.computeKey);
2052
+ const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
2056
2053
  const manifestPath = join2(profile.stateDir, `${name}.json`);
2057
2054
  if (!existsSync5(manifestPath))
2058
2055
  return {};
2059
- const manifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
2060
- if (manifest.computeKey !== claim.computeKey || manifest.reference !== claim.spec.reference || manifest.name !== name) {
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) {
2061
2060
  throw new MetalProvisionError("compute identity conflicts with host inventory");
2062
2061
  }
2063
2062
  const service = `forgezero-guest@${name}.service`;
2064
2063
  const unitPath = join2(profile.unitDir, service);
2065
2064
  if (existsSync5(unitPath))
2066
2065
  await checked(exec, ["systemctl", "disable", "--now", service]);
2066
+ else if (legacyPlatformIdentity)
2067
+ await checked(exec, ["systemctl", "disable", "--now", service]);
2067
2068
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
2068
2069
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
2069
2070
  }
@@ -2079,7 +2080,15 @@ async function removeMetalGuest(profile, claim, exec) {
2079
2080
  manifestPath
2080
2081
  ])
2081
2082
  if (existsSync5(path))
2082
- unlinkSync5(path);
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
+ }
2083
2092
  await checked(exec, ["systemctl", "daemon-reload"]);
2084
2093
  return {};
2085
2094
  }
@@ -2117,7 +2126,7 @@ function startMetalHelper(options) {
2117
2126
  validateMetalProfile(options.profile);
2118
2127
  const socketPath = options.socketPath ?? DEFAULT_METAL_HELPER_SOCKET;
2119
2128
  if (existsSync6(socketPath))
2120
- unlinkSync6(socketPath);
2129
+ unlinkSync5(socketPath);
2121
2130
  let tail = Promise.resolve();
2122
2131
  const server = createServer3((socket) => {
2123
2132
  let buffer = "";
@@ -2163,7 +2172,7 @@ function startMetalHelper(options) {
2163
2172
  });
2164
2173
  socket.on("error", () => socket.destroy());
2165
2174
  });
2166
- server.listen(socketPath, () => chmodSync6(socketPath, 432));
2175
+ server.listen(socketPath, () => chmodSync5(socketPath, 432));
2167
2176
  return {
2168
2177
  server,
2169
2178
  async stop() {
@@ -2203,7 +2212,7 @@ function requestMetalProvision(claim, socketPath = DEFAULT_METAL_HELPER_SOCKET)
2203
2212
  }
2204
2213
 
2205
2214
  // src/deployment-runner.ts
2206
- import { chmodSync as chmodSync7, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync7 } from "fs";
2215
+ import { chmodSync as chmodSync6, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync6 } from "fs";
2207
2216
  import { isAbsolute as isAbsolute2, resolve, sep } from "path";
2208
2217
  import { connect as connect3, createServer as createServer4 } from "net";
2209
2218
  var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
@@ -2308,7 +2317,7 @@ function startDeploymentRunner(options) {
2308
2317
  const home = resolve(options.home);
2309
2318
  const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
2310
2319
  if (options.listenFd === undefined && existsSync7(socketPath))
2311
- unlinkSync7(socketPath);
2320
+ unlinkSync6(socketPath);
2312
2321
  const active = new Set;
2313
2322
  const server = createServer4((socket) => {
2314
2323
  let buffer = "";
@@ -2352,7 +2361,7 @@ function startDeploymentRunner(options) {
2352
2361
  if (options.listenFd !== undefined)
2353
2362
  server.listen({ fd: options.listenFd });
2354
2363
  else
2355
- server.listen(socketPath, () => chmodSync7(socketPath, 432));
2364
+ server.listen(socketPath, () => chmodSync6(socketPath, 432));
2356
2365
  return {
2357
2366
  server,
2358
2367
  async stop() {
@@ -2571,7 +2580,7 @@ function startNodeAttestation(options) {
2571
2580
  }
2572
2581
 
2573
2582
  // src/metal-isolation.ts
2574
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2583
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2575
2584
  import { join as join3 } from "path";
2576
2585
  var members = (list) => list.split(",").flatMap((part) => {
2577
2586
  const [first, last = first] = part.split("-").map(Number);
@@ -2649,16 +2658,16 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2649
2658
  validateMetalProfile(profile);
2650
2659
  await requireGuestsInSlice(exec);
2651
2660
  const unitDir = profile.unitDir;
2652
- mkdirSync5(unitDir, { recursive: true });
2653
- writeFileSync5(join3(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
2661
+ mkdirSync4(unitDir, { recursive: true });
2662
+ writeFileSync4(join3(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
2654
2663
  for (const unit of ["system.slice", "user.slice"]) {
2655
2664
  const directory = join3(unitDir, `${unit}.d`);
2656
- mkdirSync5(directory, { recursive: true });
2657
- writeFileSync5(join3(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
2665
+ mkdirSync4(directory, { recursive: true });
2666
+ writeFileSync4(join3(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
2658
2667
  }
2659
2668
  const initDirectory = join3(unitDir, "init.scope.d");
2660
- mkdirSync5(initDirectory, { recursive: true });
2661
- writeFileSync5(join3(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
2669
+ mkdirSync4(initDirectory, { recursive: true });
2670
+ writeFileSync4(join3(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
2662
2671
  await checked2(exec, ["systemctl", "daemon-reload"]);
2663
2672
  await requireGuestsInSlice(exec);
2664
2673
  const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
@@ -2669,20 +2678,22 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2669
2678
  }
2670
2679
  }
2671
2680
 
2681
+ // src/version.ts
2682
+ var VERSION = "0.1.12";
2683
+
2672
2684
  // src/index.ts
2673
- var VERSION = "0.1.11";
2674
2685
  function loadOrCreateSeed(path) {
2675
2686
  if (existsSync9(path)) {
2676
- const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2687
+ const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2677
2688
  if (seed2.length < 32) {
2678
2689
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
2679
2690
  }
2680
2691
  return seed2;
2681
2692
  }
2682
- mkdirSync6(dirname5(path), { recursive: true });
2693
+ mkdirSync5(dirname4(path), { recursive: true });
2683
2694
  const seed = new Uint8Array(randomBytes(32));
2684
- writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2685
- chmodSync8(path, 384);
2695
+ writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2696
+ chmodSync7(path, 384);
2686
2697
  return seed;
2687
2698
  }
2688
2699
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
@@ -2695,7 +2706,7 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
2695
2706
  const path = `${directory}/${name}`;
2696
2707
  if (!existsSync9(path))
2697
2708
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2698
- const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2709
+ const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2699
2710
  if (seed.length < 32)
2700
2711
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2701
2712
  return seed;
@@ -2705,7 +2716,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
2705
2716
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2706
2717
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
2707
2718
  throw new Error("agent: invalid systemd credential name.");
2708
- const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2719
+ const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
2709
2720
  if (!value)
2710
2721
  throw new Error(`agent: systemd credential ${name} is empty.`);
2711
2722
  return value;
@@ -2740,9 +2751,15 @@ function runAgent(config = {}) {
2740
2751
  record: config.record
2741
2752
  };
2742
2753
  const server = startAgent(options);
2743
- return { server, keys, nodeKey, setCache: (cache) => {
2744
- options.cache = cache;
2745
- } };
2754
+ return {
2755
+ server,
2756
+ keys,
2757
+ nodeKey,
2758
+ setVault: (cache, projectKey) => {
2759
+ options.cache = cache;
2760
+ options.projectKey = projectKey;
2761
+ }
2762
+ };
2746
2763
  }
2747
2764
  if (import.meta.main) {
2748
2765
  const args = process.argv.slice(2);
@@ -2805,7 +2822,7 @@ if (import.meta.main) {
2805
2822
  nodeKey: nodeKey2,
2806
2823
  keys: keys2,
2807
2824
  label: process.env.FZ_NODE_LABEL,
2808
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2825
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2809
2826
  });
2810
2827
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2811
2828
  process.exit(0);
@@ -2814,7 +2831,7 @@ if (import.meta.main) {
2814
2831
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2815
2832
  if (!profilePath)
2816
2833
  throw new Error("metal-helper requires --profile=/absolute/path.json");
2817
- const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2834
+ const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
2818
2835
  const helper = startMetalHelper({
2819
2836
  profile,
2820
2837
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -2836,7 +2853,7 @@ if (import.meta.main) {
2836
2853
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2837
2854
  if (!profilePath)
2838
2855
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
2839
- const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2856
+ const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
2840
2857
  await applyMetalIsolation(profile);
2841
2858
  console.log("[metal-isolation] host and guest cgroup boundaries active");
2842
2859
  process.exit(0);
@@ -2865,6 +2882,27 @@ if (import.meta.main) {
2865
2882
  process.on("SIGINT", () => void stop());
2866
2883
  await new Promise(() => {});
2867
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
+ }
2868
2906
  if (process.env.FZ_AGENT_ROLE === "metal") {
2869
2907
  if (!process.env.FZ_API)
2870
2908
  throw new Error("metal agent requires FZ_API");
@@ -2954,28 +2992,33 @@ if (import.meta.main) {
2954
2992
  nodeKey,
2955
2993
  keys,
2956
2994
  label: process.env.FZ_NODE_LABEL,
2957
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2995
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync4(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2958
2996
  });
2959
2997
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
2960
2998
  }
2961
- 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;
2962
3000
  let secretCache;
2963
3001
  let vaultSync;
2964
3002
  if (binding && attestationSource && nodeApiUrl) {
2965
3003
  const result = await attestNodeOnce({ apiUrl: nodeApiUrl, nodeKey, keys, source: attestationSource });
2966
3004
  console.log(`[agent] initial SEV-SNP attestation verified ${result.measurement.slice(0, 16)}\u2026`);
2967
3005
  }
2968
- if (binding && nodeApiUrl) {
2969
- secretCache = createNodeVaultCache({ apiUrl: nodeApiUrl, nodeKey, keys });
3006
+ if (binding?.realm === "tenant" && nodeApiUrl) {
3007
+ secretCache = createNodeVaultCache({
3008
+ apiUrl: nodeApiUrl,
3009
+ nodeKey,
3010
+ keys,
3011
+ projectKey: binding.projectKey
3012
+ });
2970
3013
  const loaded = await secretCache.load();
2971
3014
  if (loaded.failed.length > 0) {
2972
3015
  throw new Error(`agent: failed to load ${loaded.failed.length} assigned vault entries`);
2973
3016
  }
2974
- running.setCache(secretCache);
3017
+ running.setVault(secretCache, binding.projectKey);
2975
3018
  vaultSync = startNodeVaultSync(secretCache, {
2976
3019
  onEvent: (event, detail) => console.log(`[agent] vault ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2977
3020
  });
2978
- console.log(`[agent] in-memory vault loaded for ${binding.projectKey}/${binding.environmentKey}`);
3021
+ console.log(`[agent] in-memory vault loaded for every environment in project ${binding.projectKey}`);
2979
3022
  }
2980
3023
  const attestationLoop = attestationSource && nodeApiUrl ? startNodeAttestation({
2981
3024
  apiUrl: nodeApiUrl,
@@ -3035,23 +3078,6 @@ if (import.meta.main) {
3035
3078
  const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
3036
3079
  if (control)
3037
3080
  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
3081
  const pull = pullEnabled ? startDeploymentPull({
3056
3082
  apiUrl: nodeApiUrl,
3057
3083
  nodeKey,
@@ -3086,13 +3112,10 @@ if (import.meta.main) {
3086
3112
  await new Promise((resolve2) => control.close(() => resolve2()));
3087
3113
  const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
3088
3114
  const deadline = Date.now() + deadlineMs;
3089
- const pullDrain = Promise.all([
3090
- pull?.stop() ?? Promise.resolve(),
3091
- staticWatch?.stop() ?? Promise.resolve()
3092
- ]);
3115
+ const pullDrain = pull?.stop() ?? Promise.resolve();
3093
3116
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3094
3117
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3095
- const pullWithinDeadline = pull || staticWatch ? Promise.race([
3118
+ const pullWithinDeadline = pull ? Promise.race([
3096
3119
  pullDrain.then(() => true),
3097
3120
  new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
3098
3121
  ]) : Promise.resolve(true);
@@ -3127,10 +3150,8 @@ if (import.meta.main) {
3127
3150
  }
3128
3151
  }
3129
3152
  export {
3130
- writeStaticDeploymentState,
3131
3153
  validateMetalProfile,
3132
3154
  tenantNodeApiUrl,
3133
- startStaticDeploymentWatch,
3134
3155
  startProvisioningPull,
3135
3156
  startNodeVaultSync,
3136
3157
  startNodeAttestation,
@@ -3144,10 +3165,11 @@ export {
3144
3165
  requestDeploymentCommand,
3145
3166
  requestControl,
3146
3167
  removeMetalGuest,
3147
- readStaticDeploymentState,
3148
3168
  pullProvisioningOnce,
3149
3169
  pullDeploymentOnce,
3150
3170
  provisionMetalGuest,
3171
+ projectVaultCoordinate,
3172
+ projectVaultCacheKey,
3151
3173
  metalHousekeepingDropIn,
3152
3174
  metalGuestSliceUnit,
3153
3175
  loadTextCredential,
@@ -3164,10 +3186,12 @@ export {
3164
3186
  createDeploymentManager,
3165
3187
  cloudInit,
3166
3188
  attestNodeOnce,
3189
+ assertSupportedGuestImage,
3167
3190
  applyMetalIsolation,
3168
3191
  allocateCpuPool,
3169
3192
  allocateAddress,
3170
3193
  VERSION,
3194
+ SUPPORTED_GUEST_IMAGE,
3171
3195
  DeploymentError,
3172
3196
  DEFAULT_SOCKET_PATH,
3173
3197
  DEFAULT_SEED_PATH,