@forgezero/agent 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +525 -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 +24 -1
  16. package/dist/provisioning-pull.js +30 -11
  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) {
@@ -900,160 +1112,34 @@ async function pullDeploymentOnce(options) {
900
1112
  });
901
1113
  return { status: "deployed", claim, result };
902
1114
  }
903
- function startDeploymentPull(options) {
904
- const interval = Math.max(1000, options.intervalMs ?? 5000);
905
- const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
906
- const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
907
- const emit = options.onEvent ?? (() => {});
908
- const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
909
- let stopped = false;
910
- const workers = Array.from({ length: parallelism }, () => ({ active: null }));
911
- const schedule = (worker) => {
912
- if (!stopped)
913
- worker.timer = setTimer(() => tick(worker), interval);
914
- };
915
- const tick = (worker) => {
916
- if (stopped || worker.active)
917
- return;
918
- worker.active = pullDeploymentOnce(options).then((result) => emit(result.status, result)).catch((cause) => emit("poll-failed", cause)).finally(() => {
919
- worker.active = null;
920
- schedule(worker);
921
- });
922
- };
923
- for (const worker of workers)
924
- tick(worker);
925
- return {
926
- async stop() {
927
- stopped = true;
928
- for (const worker of workers)
929
- clearTimer(worker.timer);
930
- await Promise.all(workers.map((worker) => worker.active));
931
- },
932
- get active() {
933
- return !stopped;
934
- }
935
- };
936
- }
937
-
938
- // src/deployment-watch.ts
939
- import {
940
- chmodSync as chmodSync4,
941
- closeSync,
942
- fsyncSync,
943
- mkdirSync as mkdirSync2,
944
- openSync,
945
- readFileSync as readFileSync2,
946
- renameSync as renameSync2,
947
- unlinkSync as unlinkSync3,
948
- writeFileSync as writeFileSync2
949
- } from "fs";
950
- import { dirname as dirname2 } from "path";
951
- var validState = (value) => {
952
- const state = value;
953
- return Boolean(state && /^[a-f0-9]{40}$/i.test(state.revision ?? "") && ["pending", "running", "deployed", "failed"].includes(state.outcome ?? "") && Number.isFinite(state.updatedAtTs));
954
- };
955
- function readStaticDeploymentState(path) {
956
- try {
957
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
958
- return validState(parsed) ? parsed : null;
959
- } catch {
960
- return null;
961
- }
962
- }
963
- function writeStaticDeploymentState(path, state) {
964
- const directory = dirname2(path);
965
- mkdirSync2(directory, { recursive: true, mode: 448 });
966
- const temporary = `${path}.new-${process.pid}`;
967
- let file;
968
- try {
969
- file = openSync(temporary, "w", 384);
970
- writeFileSync2(file, `${JSON.stringify(state)}
971
- `);
972
- fsyncSync(file);
973
- closeSync(file);
974
- file = undefined;
975
- chmodSync4(temporary, 384);
976
- renameSync2(temporary, path);
977
- const parent = openSync(directory, "r");
978
- try {
979
- fsyncSync(parent);
980
- } finally {
981
- closeSync(parent);
982
- }
983
- } catch (cause) {
984
- if (file !== undefined)
985
- closeSync(file);
986
- try {
987
- unlinkSync3(temporary);
988
- } catch {}
989
- throw cause;
990
- }
991
- }
992
- function startStaticDeploymentWatch(options) {
993
- const interval = Math.max(1000, options.intervalMs ?? 15000);
1115
+ function startDeploymentPull(options) {
1116
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
994
1117
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
995
1118
  const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
996
- const 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
  }
@@ -1382,7 +1306,15 @@ async function pullProvisioningOnce(options) {
1382
1306
  if (options.metalPreflight) {
1383
1307
  const report = options.metalPreflight();
1384
1308
  const accepted = await postSignedNode(options, "v1/metal/preflight", report);
1385
- options.onEvent?.("preflight", { ...report, ready: accepted.ready, state: accepted.state });
1309
+ if (options.metalHostname && accepted.hostname !== options.metalHostname) {
1310
+ throw new Error(`metal agent: configured inventory hostname ${options.metalHostname} is bound as ${accepted.hostname}; refusing work.`);
1311
+ }
1312
+ options.onEvent?.("preflight", {
1313
+ ...report,
1314
+ hostname: accepted.hostname,
1315
+ ready: accepted.ready,
1316
+ state: accepted.state
1317
+ });
1386
1318
  if (!accepted.ready)
1387
1319
  return { status: "idle" };
1388
1320
  }
@@ -1443,21 +1375,22 @@ function startProvisioningPull(options) {
1443
1375
  }
1444
1376
 
1445
1377
  // src/metal-helper-socket.ts
1446
- import { chmodSync as chmodSync6, existsSync as existsSync6, unlinkSync as unlinkSync6 } from "fs";
1378
+ import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
1447
1379
  import { connect as connect2, createServer as createServer3 } from "net";
1448
1380
 
1449
1381
  // src/metal-provision.ts
1450
1382
  import { createHash } from "crypto";
1451
1383
  import {
1452
1384
  existsSync as existsSync5,
1453
- mkdirSync as mkdirSync4,
1454
- readFileSync as readFileSync4,
1385
+ mkdirSync as mkdirSync3,
1386
+ readFileSync as readFileSync3,
1455
1387
  readdirSync,
1388
+ rmSync,
1456
1389
  statSync as statSync2,
1457
- unlinkSync as unlinkSync5,
1458
- writeFileSync as writeFileSync4
1390
+ unlinkSync as unlinkSync4,
1391
+ writeFileSync as writeFileSync3
1459
1392
  } from "fs";
1460
- import { dirname as dirname4, isAbsolute, join as join2 } from "path";
1393
+ import { dirname as dirname3, isAbsolute, join as join2 } from "path";
1461
1394
 
1462
1395
  // src/compute.ts
1463
1396
  class ComputeError extends Error {
@@ -1560,6 +1493,7 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
1560
1493
  // src/provision.ts
1561
1494
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
1562
1495
  var DEPLOYMENT_GROUP = "forgezero-deploy";
1496
+ var VAULT_GROUP = "forgezero-vault";
1563
1497
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
1564
1498
  function deploymentRunnerSocketUnit(agentUser) {
1565
1499
  return `[Unit]
@@ -1614,6 +1548,21 @@ WantedBy=multi-user.target
1614
1548
  `;
1615
1549
  }
1616
1550
 
1551
+ // src/ubuntu.ts
1552
+ var SUPPORTED_GUEST_IMAGE = Object.freeze({
1553
+ key: "ubuntu-resolute-20260731",
1554
+ family: "ubuntu-26.04",
1555
+ version: "2026-07-31",
1556
+ label: "Ubuntu 26.04 LTS Resolute",
1557
+ url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
1558
+ sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
1559
+ });
1560
+ function assertSupportedGuestImage(imageKey) {
1561
+ if (imageKey !== SUPPORTED_GUEST_IMAGE.key) {
1562
+ throw new Error(`unsupported guest image ${imageKey}; ForgeZero currently supports only ${SUPPORTED_GUEST_IMAGE.key}`);
1563
+ }
1564
+ }
1565
+
1617
1566
  // src/metal-provision.ts
1618
1567
  var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
1619
1568
  var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
@@ -1664,6 +1613,10 @@ function validateMetalProfile(profile) {
1664
1613
  throw new MetalProvisionError("metal paths must be absolute");
1665
1614
  }
1666
1615
  new URL(profile.apiUrl);
1616
+ const imageKeys = Object.keys(profile.images);
1617
+ if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
1618
+ throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
1619
+ }
1667
1620
  if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
1668
1621
  throw new MetalProvisionError("at least one exclusive CPU pool is required");
1669
1622
  }
@@ -1712,7 +1665,7 @@ function validateMetalProfile(profile) {
1712
1665
  var readManifests = (stateDir) => {
1713
1666
  if (!existsSync5(stateDir))
1714
1667
  return [];
1715
- return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync4(join2(stateDir, name), "utf8")));
1668
+ return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync3(join2(stateDir, name), "utf8")));
1716
1669
  };
1717
1670
  function allocateAddress(profile, computeKey, rows) {
1718
1671
  const existing = rows.find((row) => row.computeKey === computeKey);
@@ -1729,6 +1682,21 @@ function allocateAddress(profile, computeKey, rows) {
1729
1682
  }
1730
1683
  throw new MetalProvisionError("guest address range is full");
1731
1684
  }
1685
+ function requestedAddress(profile, claim, rows) {
1686
+ if (!claim.spec.guestAddress)
1687
+ return allocateAddress(profile, claim.computeKey, rows);
1688
+ if (!claim.spec.guestAddress.startsWith(`${profile.subnetPrefix}.`)) {
1689
+ throw new MetalProvisionError("requested guest address is outside the metal profile");
1690
+ }
1691
+ const last = Number(claim.spec.guestAddress.slice(profile.subnetPrefix.length + 1));
1692
+ if (!Number.isInteger(last) || last < profile.addressStart || last > profile.addressEnd) {
1693
+ throw new MetalProvisionError("requested guest address is outside the allocatable range");
1694
+ }
1695
+ const occupied = rows.find((row) => row.address === claim.spec.guestAddress && row.computeKey !== claim.computeKey);
1696
+ if (occupied)
1697
+ throw new MetalProvisionError("requested guest address is already allocated");
1698
+ return claim.spec.guestAddress;
1699
+ }
1732
1700
  function allocateCpuPool(profile, claim, rows) {
1733
1701
  const prior = rows.find((row) => row.computeKey === claim.computeKey);
1734
1702
  if (prior) {
@@ -1739,6 +1707,15 @@ function allocateCpuPool(profile, claim, rows) {
1739
1707
  return retained;
1740
1708
  }
1741
1709
  const used = new Set(rows.map((row) => row.cpuPoolKey));
1710
+ if (claim.spec.cpuPoolKey) {
1711
+ const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
1712
+ if (!requested || used.has(requested.key)) {
1713
+ throw new MetalProvisionError("requested CPU pool is unavailable");
1714
+ }
1715
+ if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
1716
+ throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
1717
+ return requested;
1718
+ }
1742
1719
  const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
1743
1720
  const selected = candidates[0];
1744
1721
  if (!selected)
@@ -1751,7 +1728,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
1751
1728
  encoding: b64
1752
1729
  content: ${base64(content)}
1753
1730
  `;
1754
- function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
1731
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
1755
1732
  const agentBun = "/usr/local/lib/forgezero/bun";
1756
1733
  const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
1757
1734
  # healthy and encrypted while attestation is silently impossible. Install and
@@ -1764,7 +1741,9 @@ test -c /dev/sev-guest
1764
1741
  ` : "";
1765
1742
  return `#!/usr/bin/env bash
1766
1743
  set -Eeuo pipefail
1767
- ${attestationSetup}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
1768
1747
  groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
1769
1748
  useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
1770
1749
  usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
@@ -1775,13 +1754,13 @@ install -d -o root -g root -m 0755 /opt/forgezero
1775
1754
  install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
1776
1755
  install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
1777
1756
  install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
1778
- if [[ -s /run/forgezero-enrol-token ]]; then
1757
+ ${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
1779
1758
  systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
1780
1759
  rm -f /run/forgezero-enrol-token
1781
1760
  fi
1782
1761
  chown root:root /var/lib/forgezero/enrol-token.cred
1783
1762
  chmod 0400 /var/lib/forgezero/enrol-token.cred
1784
- if [[ ! -x /usr/local/bin/bun ]]; then
1763
+ ` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
1785
1764
  curl -fsSL https://bun.sh/install -o /run/fz-bun-install
1786
1765
  printf '%s %s
1787
1766
  ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
@@ -1820,8 +1799,8 @@ systemctl daemon-reload
1820
1799
  systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
1821
1800
  `;
1822
1801
  }
1823
- function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
1824
- const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp 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
1825
1804
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1826
1805
  ` : "";
1827
1806
  const attestationDevice = attested ? `DevicePolicy=closed
@@ -1836,7 +1815,7 @@ Requires=forgezero-deploy-runner.service
1836
1815
  [Service]
1837
1816
  Type=simple
1838
1817
  User=forgezero-agent
1839
- Group=forgezero-agent
1818
+ Group=${VAULT_GROUP}
1840
1819
  SupplementaryGroups=${DEPLOYMENT_GROUP}
1841
1820
  LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
1842
1821
  LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
@@ -1847,15 +1826,15 @@ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
1847
1826
  Environment=FZ_NODE_LABEL=${name}
1848
1827
  Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
1849
1828
  Environment=FZ_DEPLOY_ROOT=/opt/forgezero
1850
- Environment=FZ_DEPLOY_PULL=true
1829
+ Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
1851
1830
  Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1852
1831
  Environment=HOME=/opt/forgezero/home
1853
1832
  ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
1854
1833
  Restart=on-failure
1855
1834
  RestartSec=5
1856
1835
  RuntimeDirectory=forgezero
1857
- RuntimeDirectoryMode=0710
1858
- UMask=0077
1836
+ RuntimeDirectoryMode=0750
1837
+ UMask=0007
1859
1838
  LimitCORE=0
1860
1839
  NoNewPrivileges=true
1861
1840
  PrivateTmp=true
@@ -1905,24 +1884,39 @@ WantedBy=multi-user.target
1905
1884
  `;
1906
1885
  }
1907
1886
  function cloudInit(profile, claim, manifest) {
1908
- const bootstrap = guestBootstrapScript(profile, claim.spec.confidential);
1909
- 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);
1910
1890
  const enrolmentDropIn = guestEnrolmentDropIn();
1911
1891
  const cleanupScript = guestEnrolmentCleanupScript();
1912
1892
  const cleanupUnit = guestEnrolmentCleanupUnit();
1913
1893
  const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
1914
1894
  const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
1895
+ const access = claim.access;
1896
+ const users = access ? `users:
1897
+ - default
1898
+ - name: ${access.sshUser}
1899
+ groups: [sudo]
1900
+ shell: /bin/bash
1901
+ sudo: ALL=(ALL) NOPASSWD:ALL
1902
+ ssh_authorized_keys:
1903
+ ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1904
+ `)}
1905
+ ` : "";
1906
+ const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1907
+ `, "0600")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}` : "";
1915
1908
  return {
1916
1909
  userData: `#cloud-config
1917
1910
  package_update: true
1911
+ ${users}disable_root: true
1918
1912
  # Git is part of the deployment transport, not a tenant-selected prerequisite:
1919
1913
  # every dynamically claimed repository must be cloneable on a clean image.
1920
1914
  packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
1921
1915
  write_files:
1922
- ${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1923
- `, "0600")}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}runcmd:
1916
+ ${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit, "0644")}runcmd:
1924
1917
  - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
1925
- - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1918
+ ${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1919
+ ` : ""}
1926
1920
  `,
1927
1921
  metaData: `instance-id: ${manifest.name}-${claim.attempt}
1928
1922
  local-hostname: ${manifest.name}
@@ -1946,7 +1940,8 @@ var checked = async (exec, argv) => {
1946
1940
  };
1947
1941
  async function provisionMetalGuest(profile, claim, exec) {
1948
1942
  validateMetalProfile(profile);
1949
- 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)
1950
1945
  throw new MetalProvisionError("invalid compute claim");
1951
1946
  const image = profile.images[claim.spec.imageKey];
1952
1947
  if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
@@ -1958,14 +1953,24 @@ async function provisionMetalGuest(profile, claim, exec) {
1958
1953
  if (digest !== image.sha256)
1959
1954
  throw new MetalProvisionError("configured image checksum mismatch");
1960
1955
  for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
1961
- mkdirSync4(path, { recursive: true, mode: 448 });
1956
+ mkdirSync3(path, { recursive: true, mode: 448 });
1962
1957
  const manifests = readManifests(profile.stateDir);
1963
- 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");
1964
1969
  const manifestPath = join2(profile.stateDir, `${name}.json`);
1965
1970
  const prior = manifests.find((row) => row.computeKey === claim.computeKey);
1966
1971
  if (prior && prior.reference !== claim.spec.reference)
1967
1972
  throw new MetalProvisionError("compute identity conflicts with host inventory");
1968
- const address = allocateAddress(profile, claim.computeKey, manifests);
1973
+ const address = requestedAddress(profile, claim, manifests);
1969
1974
  const cpuPool = allocateCpuPool(profile, claim, manifests);
1970
1975
  const manifest = prior ?? {
1971
1976
  computeKey: claim.computeKey,
@@ -1978,7 +1983,7 @@ async function provisionMetalGuest(profile, claim, exec) {
1978
1983
  allowedMemoryNodes: cpuPool.memoryNodes,
1979
1984
  phase: "allocating"
1980
1985
  };
1981
- const save = () => writeFileSync4(manifestPath, `${JSON.stringify(manifest, null, 2)}
1986
+ const save = () => writeFileSync3(manifestPath, `${JSON.stringify(manifest, null, 2)}
1982
1987
  `, { mode: 384 });
1983
1988
  save();
1984
1989
  const lv = `/dev/${profile.volumeGroup}/${name}`;
@@ -1992,9 +1997,9 @@ async function provisionMetalGuest(profile, claim, exec) {
1992
1997
  }
1993
1998
  const seedBase = join2(profile.seedDir, name);
1994
1999
  const init = cloudInit(profile, claim, manifest);
1995
- writeFileSync4(`${seedBase}-user-data`, init.userData, { mode: 384 });
1996
- writeFileSync4(`${seedBase}-meta-data`, init.metaData, { mode: 384 });
1997
- 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 });
1998
2003
  const seed = `${seedBase}-seed.iso`;
1999
2004
  await checked(exec, [
2000
2005
  "cloud-localds",
@@ -2027,8 +2032,8 @@ async function provisionMetalGuest(profile, claim, exec) {
2027
2032
  }
2028
2033
  const service = `forgezero-guest@${name}.service`;
2029
2034
  const unitPath = join2(profile.unitDir, service);
2030
- mkdirSync4(dirname4(unitPath), { recursive: true });
2031
- writeFileSync4(unitPath, guestUnit(spec), { mode: 420 });
2035
+ mkdirSync3(dirname3(unitPath), { recursive: true });
2036
+ writeFileSync3(unitPath, guestUnit(spec), { mode: 420 });
2032
2037
  await checked(exec, ["systemctl", "daemon-reload"]);
2033
2038
  if (prior?.phase === "running") {
2034
2039
  await checked(exec, ["systemctl", "restart", service]);
@@ -2044,18 +2049,22 @@ async function removeMetalGuest(profile, claim, exec) {
2044
2049
  validateMetalProfile(profile);
2045
2050
  if (claim.action !== "delete")
2046
2051
  throw new MetalProvisionError("create claim cannot remove a guest");
2047
- const name = guestNameFor(claim.computeKey);
2052
+ const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
2048
2053
  const manifestPath = join2(profile.stateDir, `${name}.json`);
2049
2054
  if (!existsSync5(manifestPath))
2050
2055
  return {};
2051
- const manifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
2052
- 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) {
2053
2060
  throw new MetalProvisionError("compute identity conflicts with host inventory");
2054
2061
  }
2055
2062
  const service = `forgezero-guest@${name}.service`;
2056
2063
  const unitPath = join2(profile.unitDir, service);
2057
2064
  if (existsSync5(unitPath))
2058
2065
  await checked(exec, ["systemctl", "disable", "--now", service]);
2066
+ else if (legacyPlatformIdentity)
2067
+ await checked(exec, ["systemctl", "disable", "--now", service]);
2059
2068
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
2060
2069
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
2061
2070
  }
@@ -2071,7 +2080,15 @@ async function removeMetalGuest(profile, claim, exec) {
2071
2080
  manifestPath
2072
2081
  ])
2073
2082
  if (existsSync5(path))
2074
- 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
+ }
2075
2092
  await checked(exec, ["systemctl", "daemon-reload"]);
2076
2093
  return {};
2077
2094
  }
@@ -2109,7 +2126,7 @@ function startMetalHelper(options) {
2109
2126
  validateMetalProfile(options.profile);
2110
2127
  const socketPath = options.socketPath ?? DEFAULT_METAL_HELPER_SOCKET;
2111
2128
  if (existsSync6(socketPath))
2112
- unlinkSync6(socketPath);
2129
+ unlinkSync5(socketPath);
2113
2130
  let tail = Promise.resolve();
2114
2131
  const server = createServer3((socket) => {
2115
2132
  let buffer = "";
@@ -2155,7 +2172,7 @@ function startMetalHelper(options) {
2155
2172
  });
2156
2173
  socket.on("error", () => socket.destroy());
2157
2174
  });
2158
- server.listen(socketPath, () => chmodSync6(socketPath, 432));
2175
+ server.listen(socketPath, () => chmodSync5(socketPath, 432));
2159
2176
  return {
2160
2177
  server,
2161
2178
  async stop() {
@@ -2195,7 +2212,7 @@ function requestMetalProvision(claim, socketPath = DEFAULT_METAL_HELPER_SOCKET)
2195
2212
  }
2196
2213
 
2197
2214
  // src/deployment-runner.ts
2198
- import { chmodSync as chmodSync7, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync7 } from "fs";
2215
+ import { chmodSync as chmodSync6, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync6 } from "fs";
2199
2216
  import { isAbsolute as isAbsolute2, resolve, sep } from "path";
2200
2217
  import { connect as connect3, createServer as createServer4 } from "net";
2201
2218
  var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
@@ -2300,7 +2317,7 @@ function startDeploymentRunner(options) {
2300
2317
  const home = resolve(options.home);
2301
2318
  const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
2302
2319
  if (options.listenFd === undefined && existsSync7(socketPath))
2303
- unlinkSync7(socketPath);
2320
+ unlinkSync6(socketPath);
2304
2321
  const active = new Set;
2305
2322
  const server = createServer4((socket) => {
2306
2323
  let buffer = "";
@@ -2344,7 +2361,7 @@ function startDeploymentRunner(options) {
2344
2361
  if (options.listenFd !== undefined)
2345
2362
  server.listen({ fd: options.listenFd });
2346
2363
  else
2347
- server.listen(socketPath, () => chmodSync7(socketPath, 432));
2364
+ server.listen(socketPath, () => chmodSync6(socketPath, 432));
2348
2365
  return {
2349
2366
  server,
2350
2367
  async stop() {
@@ -2563,7 +2580,7 @@ function startNodeAttestation(options) {
2563
2580
  }
2564
2581
 
2565
2582
  // src/metal-isolation.ts
2566
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2583
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
2567
2584
  import { join as join3 } from "path";
2568
2585
  var members = (list) => list.split(",").flatMap((part) => {
2569
2586
  const [first, last = first] = part.split("-").map(Number);
@@ -2641,16 +2658,16 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2641
2658
  validateMetalProfile(profile);
2642
2659
  await requireGuestsInSlice(exec);
2643
2660
  const unitDir = profile.unitDir;
2644
- mkdirSync5(unitDir, { recursive: true });
2645
- 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 });
2646
2663
  for (const unit of ["system.slice", "user.slice"]) {
2647
2664
  const directory = join3(unitDir, `${unit}.d`);
2648
- mkdirSync5(directory, { recursive: true });
2649
- 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 });
2650
2667
  }
2651
2668
  const initDirectory = join3(unitDir, "init.scope.d");
2652
- mkdirSync5(initDirectory, { recursive: true });
2653
- 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 });
2654
2671
  await checked2(exec, ["systemctl", "daemon-reload"]);
2655
2672
  await requireGuestsInSlice(exec);
2656
2673
  const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
@@ -2661,20 +2678,22 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2661
2678
  }
2662
2679
  }
2663
2680
 
2681
+ // src/version.ts
2682
+ var VERSION = "0.1.12";
2683
+
2664
2684
  // src/index.ts
2665
- var VERSION = "0.1.10";
2666
2685
  function loadOrCreateSeed(path) {
2667
2686
  if (existsSync9(path)) {
2668
- const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2687
+ const seed2 = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2669
2688
  if (seed2.length < 32) {
2670
2689
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
2671
2690
  }
2672
2691
  return seed2;
2673
2692
  }
2674
- mkdirSync6(dirname5(path), { recursive: true });
2693
+ mkdirSync5(dirname4(path), { recursive: true });
2675
2694
  const seed = new Uint8Array(randomBytes(32));
2676
- writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2677
- chmodSync8(path, 384);
2695
+ writeFileSync5(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2696
+ chmodSync7(path, 384);
2678
2697
  return seed;
2679
2698
  }
2680
2699
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
@@ -2687,7 +2706,7 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
2687
2706
  const path = `${directory}/${name}`;
2688
2707
  if (!existsSync9(path))
2689
2708
  throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2690
- const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2709
+ const seed = new Uint8Array(Buffer.from(readFileSync4(path, "utf8").trim(), "base64url"));
2691
2710
  if (seed.length < 32)
2692
2711
  throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2693
2712
  return seed;
@@ -2697,7 +2716,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
2697
2716
  throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2698
2717
  if (!/^[A-Za-z0-9_.-]+$/.test(name))
2699
2718
  throw new Error("agent: invalid systemd credential name.");
2700
- const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2719
+ const value = readFileSync4(`${directory}/${name}`, "utf8").trim();
2701
2720
  if (!value)
2702
2721
  throw new Error(`agent: systemd credential ${name} is empty.`);
2703
2722
  return value;
@@ -2732,9 +2751,15 @@ function runAgent(config = {}) {
2732
2751
  record: config.record
2733
2752
  };
2734
2753
  const server = startAgent(options);
2735
- return { server, keys, nodeKey, setCache: (cache) => {
2736
- options.cache = cache;
2737
- } };
2754
+ return {
2755
+ server,
2756
+ keys,
2757
+ nodeKey,
2758
+ setVault: (cache, projectKey) => {
2759
+ options.cache = cache;
2760
+ options.projectKey = projectKey;
2761
+ }
2762
+ };
2738
2763
  }
2739
2764
  if (import.meta.main) {
2740
2765
  const args = process.argv.slice(2);
@@ -2797,7 +2822,7 @@ if (import.meta.main) {
2797
2822
  nodeKey: nodeKey2,
2798
2823
  keys: keys2,
2799
2824
  label: process.env.FZ_NODE_LABEL,
2800
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? 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
2801
2826
  });
2802
2827
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2803
2828
  process.exit(0);
@@ -2806,7 +2831,7 @@ if (import.meta.main) {
2806
2831
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2807
2832
  if (!profilePath)
2808
2833
  throw new Error("metal-helper requires --profile=/absolute/path.json");
2809
- const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2834
+ const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
2810
2835
  const helper = startMetalHelper({
2811
2836
  profile,
2812
2837
  socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
@@ -2828,7 +2853,7 @@ if (import.meta.main) {
2828
2853
  const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2829
2854
  if (!profilePath)
2830
2855
  throw new Error("metal-isolation requires --profile=/absolute/path.json");
2831
- const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2856
+ const profile = JSON.parse(readFileSync4(profilePath, "utf8"));
2832
2857
  await applyMetalIsolation(profile);
2833
2858
  console.log("[metal-isolation] host and guest cgroup boundaries active");
2834
2859
  process.exit(0);
@@ -2857,9 +2882,32 @@ if (import.meta.main) {
2857
2882
  process.on("SIGINT", () => void stop());
2858
2883
  await new Promise(() => {});
2859
2884
  }
2885
+ if (command === "metal-apply") {
2886
+ const claimArg = args.find((arg) => arg.startsWith("--claim="))?.slice("--claim=".length);
2887
+ if (!claimArg)
2888
+ throw new Error("metal-apply requires --claim=- or --claim=/absolute/path.json");
2889
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
2890
+ throw new Error("metal-apply must be invoked by the authenticated root bootstrap session");
2891
+ }
2892
+ if (claimArg !== "-" && !claimArg.startsWith("/")) {
2893
+ throw new Error("metal-apply claim path must be absolute");
2894
+ }
2895
+ const raw = readFileSync4(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
2896
+ if (Buffer.byteLength(raw) > 32 * 1024)
2897
+ throw new Error("metal-apply claim exceeds 32 KiB");
2898
+ const claim = JSON.parse(raw);
2899
+ if (claim.bootstrap?.kind !== "platform-genesis" || "enrolment" in claim) {
2900
+ throw new Error("metal-apply accepts only an offline platform-genesis claim");
2901
+ }
2902
+ const result = await requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET);
2903
+ console.log(JSON.stringify({ ok: true, computeKey: claim.computeKey, ...result }));
2904
+ process.exit(0);
2905
+ }
2860
2906
  if (process.env.FZ_AGENT_ROLE === "metal") {
2861
2907
  if (!process.env.FZ_API)
2862
2908
  throw new Error("metal agent requires FZ_API");
2909
+ if (!process.env.FZ_METAL_HOSTNAME)
2910
+ throw new Error("metal agent requires FZ_METAL_HOSTNAME");
2863
2911
  const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
2864
2912
  const keys2 = deriveKeysFromSeed(seed);
2865
2913
  const nodeKey2 = process.env.FZ_NODE_KEY ?? keys2.ed25519.publicKey;
@@ -2867,6 +2915,7 @@ if (import.meta.main) {
2867
2915
  apiUrl: process.env.FZ_API,
2868
2916
  nodeKey: nodeKey2,
2869
2917
  keys: keys2,
2918
+ metalHostname: process.env.FZ_METAL_HOSTNAME,
2870
2919
  run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
2871
2920
  metalPreflight: () => ({
2872
2921
  snpHost: existsSync9("/dev/sev"),
@@ -2943,28 +2992,33 @@ if (import.meta.main) {
2943
2992
  nodeKey,
2944
2993
  keys,
2945
2994
  label: process.env.FZ_NODE_LABEL,
2946
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? 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
2947
2996
  });
2948
2997
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
2949
2998
  }
2950
- let nodeApiUrl = binding && process.env.FZ_API ? tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
2999
+ let nodeApiUrl = binding && process.env.FZ_API ? binding.realm === "platform" ? process.env.FZ_API : tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
2951
3000
  let secretCache;
2952
3001
  let vaultSync;
2953
3002
  if (binding && attestationSource && nodeApiUrl) {
2954
3003
  const result = await attestNodeOnce({ apiUrl: nodeApiUrl, nodeKey, keys, source: attestationSource });
2955
3004
  console.log(`[agent] initial SEV-SNP attestation verified ${result.measurement.slice(0, 16)}\u2026`);
2956
3005
  }
2957
- if (binding && nodeApiUrl) {
2958
- secretCache = createNodeVaultCache({ 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
+ });
2959
3013
  const loaded = await secretCache.load();
2960
3014
  if (loaded.failed.length > 0) {
2961
3015
  throw new Error(`agent: failed to load ${loaded.failed.length} assigned vault entries`);
2962
3016
  }
2963
- running.setCache(secretCache);
3017
+ running.setVault(secretCache, binding.projectKey);
2964
3018
  vaultSync = startNodeVaultSync(secretCache, {
2965
3019
  onEvent: (event, detail) => console.log(`[agent] vault ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2966
3020
  });
2967
- console.log(`[agent] in-memory vault loaded for ${binding.projectKey}/${binding.environmentKey}`);
3021
+ console.log(`[agent] in-memory vault loaded for every environment in project ${binding.projectKey}`);
2968
3022
  }
2969
3023
  const attestationLoop = attestationSource && nodeApiUrl ? startNodeAttestation({
2970
3024
  apiUrl: nodeApiUrl,
@@ -3024,23 +3078,6 @@ if (import.meta.main) {
3024
3078
  const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
3025
3079
  if (control)
3026
3080
  console.log(`[agent] deployment control listening for ${repository}@${branch}`);
3027
- const staticWatch = staticManager && process.env.FZ_DEPLOY_WATCH === "true" ? startStaticDeploymentWatch({
3028
- manager: staticManager,
3029
- statePath: process.env.FZ_DEPLOY_STATE ?? join4(root, "cache", "static-deployment.json"),
3030
- coordinator: process.env.FZ_DEPLOY_COORDINATOR === "true",
3031
- currentRevision: () => {
3032
- try {
3033
- const slot = readFileSync5(join4(root, ".forge-slot"), "utf8").trim();
3034
- const revision = readFileSync5(join4(root, "slots", slot, ".git", "HEAD"), "utf8").trim();
3035
- return /^[a-f0-9]{40}$/i.test(revision) ? revision.toLowerCase() : undefined;
3036
- } catch {
3037
- return;
3038
- }
3039
- },
3040
- onEvent: (event, detail) => console.log(`[agent] static deployment ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
3041
- }) : undefined;
3042
- if (staticWatch)
3043
- console.log(`[agent] watching ${repository}@${branch} for exact revisions`);
3044
3081
  const pull = pullEnabled ? startDeploymentPull({
3045
3082
  apiUrl: nodeApiUrl,
3046
3083
  nodeKey,
@@ -3075,13 +3112,10 @@ if (import.meta.main) {
3075
3112
  await new Promise((resolve2) => control.close(() => resolve2()));
3076
3113
  const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
3077
3114
  const deadline = Date.now() + deadlineMs;
3078
- const pullDrain = Promise.all([
3079
- pull?.stop() ?? Promise.resolve(),
3080
- staticWatch?.stop() ?? Promise.resolve()
3081
- ]);
3115
+ const pullDrain = pull?.stop() ?? Promise.resolve();
3082
3116
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3083
3117
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3084
- const pullWithinDeadline = pull || staticWatch ? Promise.race([
3118
+ const pullWithinDeadline = pull ? Promise.race([
3085
3119
  pullDrain.then(() => true),
3086
3120
  new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
3087
3121
  ]) : Promise.resolve(true);
@@ -3116,10 +3150,8 @@ if (import.meta.main) {
3116
3150
  }
3117
3151
  }
3118
3152
  export {
3119
- writeStaticDeploymentState,
3120
3153
  validateMetalProfile,
3121
3154
  tenantNodeApiUrl,
3122
- startStaticDeploymentWatch,
3123
3155
  startProvisioningPull,
3124
3156
  startNodeVaultSync,
3125
3157
  startNodeAttestation,
@@ -3133,10 +3165,11 @@ export {
3133
3165
  requestDeploymentCommand,
3134
3166
  requestControl,
3135
3167
  removeMetalGuest,
3136
- readStaticDeploymentState,
3137
3168
  pullProvisioningOnce,
3138
3169
  pullDeploymentOnce,
3139
3170
  provisionMetalGuest,
3171
+ projectVaultCoordinate,
3172
+ projectVaultCacheKey,
3140
3173
  metalHousekeepingDropIn,
3141
3174
  metalGuestSliceUnit,
3142
3175
  loadTextCredential,
@@ -3153,10 +3186,12 @@ export {
3153
3186
  createDeploymentManager,
3154
3187
  cloudInit,
3155
3188
  attestNodeOnce,
3189
+ assertSupportedGuestImage,
3156
3190
  applyMetalIsolation,
3157
3191
  allocateCpuPool,
3158
3192
  allocateAddress,
3159
3193
  VERSION,
3194
+ SUPPORTED_GUEST_IMAGE,
3160
3195
  DeploymentError,
3161
3196
  DEFAULT_SOCKET_PATH,
3162
3197
  DEFAULT_SEED_PATH,