@forgezero/agent 0.1.2 → 0.1.9

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 (46) hide show
  1. package/README.md +45 -2
  2. package/dist/attestation-client.d.ts +22 -0
  3. package/dist/attestation-client.test.d.ts +1 -0
  4. package/dist/compute.d.ts +122 -0
  5. package/dist/compute.js +150 -0
  6. package/dist/compute.test.d.ts +1 -0
  7. package/dist/control.d.ts +57 -0
  8. package/dist/control.test.d.ts +1 -0
  9. package/dist/definition.d.ts +34 -0
  10. package/dist/definition.js +159 -0
  11. package/dist/definition.test.d.ts +1 -0
  12. package/dist/deployment-pull.d.ts +60 -0
  13. package/dist/deployment-pull.test.d.ts +1 -0
  14. package/dist/deployment-runner.d.ts +23 -0
  15. package/dist/deployment-runner.js +199 -0
  16. package/dist/deployment-runner.test.d.ts +1 -0
  17. package/dist/deployment-watch.d.ts +36 -0
  18. package/dist/deployment-watch.test.d.ts +1 -0
  19. package/dist/deployment.d.ts +86 -0
  20. package/dist/deployment.test.d.ts +1 -0
  21. package/dist/fz-agent.js +2879 -158
  22. package/dist/guest-enrolment.d.ts +29 -0
  23. package/dist/guest-enrolment.js +88 -0
  24. package/dist/guest-enrolment.test.d.ts +1 -0
  25. package/dist/index.d.ts +50 -4
  26. package/dist/metal-helper-socket.d.ts +15 -0
  27. package/dist/metal-helper-socket.js +1123 -0
  28. package/dist/metal-helper-socket.test.d.ts +1 -0
  29. package/dist/metal-isolation.d.ts +14 -0
  30. package/dist/metal-isolation.test.d.ts +1 -0
  31. package/dist/metal-provision.d.ts +85 -0
  32. package/dist/metal-provision.js +1014 -0
  33. package/dist/metal-provision.test.d.ts +1 -0
  34. package/dist/node-vault.d.ts +24 -0
  35. package/dist/node-vault.js +211 -0
  36. package/dist/node-vault.test.d.ts +1 -0
  37. package/dist/provision.d.ts +50 -2
  38. package/dist/provision.js +286 -12
  39. package/dist/provisioning-pull.d.ts +75 -0
  40. package/dist/provisioning-pull.js +188 -0
  41. package/dist/provisioning-pull.test.d.ts +1 -0
  42. package/dist/signed-node-http.d.ts +14 -0
  43. package/dist/snp-attestation.d.ts +18 -0
  44. package/dist/snp-attestation.test.d.ts +1 -0
  45. package/dist/socket.d.ts +4 -23
  46. package/package.json +21 -5
package/dist/fz-agent.js CHANGED
@@ -3,8 +3,8 @@
3
3
 
4
4
  // src/index.ts
5
5
  import { randomBytes } from "crypto";
6
- import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync, chmodSync as chmodSync2 } from "fs";
7
- import { dirname } from "path";
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";
8
8
  import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
9
9
  import { DEFAULT_SOCKET } from "@forgezero/vault";
10
10
 
@@ -12,85 +12,6 @@ import { DEFAULT_SOCKET } from "@forgezero/vault";
12
12
  import { createServer } from "net";
13
13
  import { chmodSync, existsSync, unlinkSync } from "fs";
14
14
  import { signRequest } from "@forgezero/runtime/identity";
15
-
16
- // src/pipeline.ts
17
- class PipelineError extends Error {
18
- code;
19
- constructor(code, message) {
20
- super(message);
21
- this.code = code;
22
- this.name = "PipelineError";
23
- }
24
- }
25
- function redact(text, values) {
26
- let out = text;
27
- for (const value of [...values].sort((a, b) => b.length - a.length)) {
28
- if (value.length < 8)
29
- continue;
30
- out = out.split(value).join("\u2022\u2022\u2022\u2022redacted\u2022\u2022\u2022\u2022");
31
- }
32
- return out;
33
- }
34
- async function runPipeline(options) {
35
- const now = options.now ?? (() => Date.now());
36
- const { pipeline } = options;
37
- let assurance = "enrolled";
38
- if (options.attest) {
39
- try {
40
- await options.attest();
41
- assurance = "attested";
42
- } catch (cause) {
43
- if (pipeline.requireAttestation) {
44
- throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
45
- }
46
- }
47
- } else if (pipeline.requireAttestation) {
48
- throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
49
- }
50
- const steps = [];
51
- let failed = false;
52
- for (const step of pipeline.steps) {
53
- if (failed && !step.always) {
54
- steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
55
- continue;
56
- }
57
- const env = {};
58
- const values = [];
59
- for (const name of step.secrets ?? []) {
60
- try {
61
- const value = await options.secret(name);
62
- env[name] = value;
63
- values.push(value);
64
- } catch {
65
- throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
66
- }
67
- }
68
- const started = now();
69
- let exitCode;
70
- let output;
71
- try {
72
- const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
73
- exitCode = result.exitCode;
74
- output = result.output;
75
- } catch (cause) {
76
- exitCode = -1;
77
- output = cause.message;
78
- }
79
- const outcome = exitCode === 0 ? "ok" : "failed";
80
- if (outcome === "failed")
81
- failed = true;
82
- steps.push({
83
- name: step.name,
84
- outcome,
85
- exitCode,
86
- log: redact(output, values),
87
- durationMs: now() - started
88
- });
89
- }
90
- return { pipeline: pipeline.name, ok: !failed, assurance, steps };
91
- }
92
-
93
- // src/socket.ts
94
15
  var MAX_LINE_BYTES = 64 * 1024;
95
16
  function handleRequest(options, request) {
96
17
  switch (request?.op) {
@@ -151,22 +72,6 @@ function handleRequest(options, request) {
151
72
  staleForMs: options.cache.staleForMs()
152
73
  });
153
74
  }
154
- case "run": {
155
- if (!options.cache) {
156
- return Promise.resolve(refuse("NO_SCOPE", "This agent holds no project scope, so it cannot run a pipeline."));
157
- }
158
- if (!options.exec) {
159
- return Promise.resolve(refuse("PIPELINE_DISABLED", "This agent was not started with an executor, so it will not run pipelines."));
160
- }
161
- const cache = options.cache;
162
- const attestation = options.attestation;
163
- return runPipeline({
164
- pipeline: request.pipeline,
165
- secret: (name) => cache.get(name),
166
- exec: options.exec,
167
- attest: attestation ? async () => ({ report: await attestation.report(""), source: attestation.name }) : undefined
168
- }).then((result) => ({ ok: true, op: "run", result })).catch((cause) => refuse(cause.code ?? "PIPELINE_FAILED", cause.message));
169
- }
170
75
  case "attest": {
171
76
  if (!options.attestation) {
172
77
  return Promise.resolve(refuse("ATTESTATION_UNAVAILABLE", "No attestation source is configured on this guest. Attestation is refused rather " + "than faked, because a caller that believes it verified one when nothing did is " + "worse off than one told plainly it is unavailable."));
@@ -245,67 +150,1034 @@ function safeOp(line) {
245
150
  return JSON.stringify({ op: "unparseable" });
246
151
  }
247
152
  }
248
- // src/cache.ts
249
- class CacheError extends Error {
153
+
154
+ // src/deployment.ts
155
+ import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
156
+ import { randomUUID } from "crypto";
157
+ import { dirname, join } from "path";
158
+ import { createQueue } from "@forgezero/runtime/queue";
159
+
160
+ // src/definition.ts
161
+ var PIPELINE_VERSION = 1;
162
+
163
+ class DefinitionError extends Error {
164
+ constructor(message) {
165
+ super(message);
166
+ this.name = "DefinitionError";
167
+ }
168
+ }
169
+ var record = (value, where) => {
170
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
171
+ throw new DefinitionError(`${where} must be an object.`);
172
+ }
173
+ return value;
174
+ };
175
+ var text = (value, where) => {
176
+ if (typeof value !== "string" || value.trim() === "") {
177
+ throw new DefinitionError(`${where} must be a non-empty string.`);
178
+ }
179
+ return value;
180
+ };
181
+ var exactKeys = (value, allowed, where) => {
182
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
183
+ if (unknown.length > 0)
184
+ throw new DefinitionError(`${where} contains unknown field(s): ${unknown.join(", ")}.`);
185
+ };
186
+ var RESERVED_STEP_ENV = new Set([
187
+ "PATH",
188
+ "HOME",
189
+ "SHELL",
190
+ "PWD",
191
+ "BUN_INSTALL",
192
+ "NODE_OPTIONS",
193
+ "LD_PRELOAD",
194
+ "LD_LIBRARY_PATH",
195
+ "GIT_SSH",
196
+ "GIT_SSH_COMMAND"
197
+ ]);
198
+ function parseDeployDefinition(value) {
199
+ const root = record(value, "pipeline");
200
+ exactKeys(root, ["version", "name", "requireAttestation", "roles", "steps"], "pipeline");
201
+ if (root.version !== PIPELINE_VERSION) {
202
+ throw new DefinitionError(`pipeline.version must be ${PIPELINE_VERSION}.`);
203
+ }
204
+ if (!Array.isArray(root.roles) || root.roles.length === 0) {
205
+ throw new DefinitionError("pipeline.roles must contain at least one role.");
206
+ }
207
+ if (!Array.isArray(root.steps) || root.steps.length === 0) {
208
+ throw new DefinitionError("pipeline.steps must contain at least one step.");
209
+ }
210
+ const roles = root.roles.map((raw, index) => {
211
+ const role = record(raw, `roles[${index}]`);
212
+ exactKeys(role, ["name", "count", "software"], `roles[${index}]`);
213
+ const count = Number(role.count);
214
+ if (!Number.isSafeInteger(count) || count < 1) {
215
+ throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
216
+ }
217
+ if (!Array.isArray(role.software)) {
218
+ throw new DefinitionError(`roles[${index}].software must be an array.`);
219
+ }
220
+ return {
221
+ name: text(role.name, `roles[${index}].name`),
222
+ count,
223
+ software: role.software.map((rawSoftware, softwareIndex) => {
224
+ const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
225
+ exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
226
+ return {
227
+ name: text(software.name, `roles[${index}].software[${softwareIndex}].name`),
228
+ check: text(software.check, `roles[${index}].software[${softwareIndex}].check`),
229
+ install: text(software.install, `roles[${index}].software[${softwareIndex}].install`)
230
+ };
231
+ })
232
+ };
233
+ });
234
+ if (new Set(roles.map((role) => role.name)).size !== roles.length) {
235
+ throw new DefinitionError("pipeline.roles must have unique names.");
236
+ }
237
+ const phases = new Set(["build", "release", "migrate", "health"]);
238
+ const steps = root.steps.map((raw, index) => {
239
+ const step = record(raw, `steps[${index}]`);
240
+ exactKeys(step, ["name", "run", "phase", "secrets", "once", "always", "timeoutMs", "when"], `steps[${index}]`);
241
+ const phase = text(step.phase, `steps[${index}].phase`);
242
+ if (!phases.has(phase))
243
+ throw new DefinitionError(`steps[${index}].phase is not supported.`);
244
+ if (step.secrets !== undefined && (!Array.isArray(step.secrets) || step.secrets.some((name) => typeof name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(name)))) {
245
+ throw new DefinitionError(`steps[${index}].secrets must contain names only.`);
246
+ }
247
+ if (Array.isArray(step.secrets) && new Set(step.secrets).size !== step.secrets.length) {
248
+ throw new DefinitionError(`steps[${index}].secrets must not contain duplicates.`);
249
+ }
250
+ if (Array.isArray(step.secrets) && step.secrets.some((name) => RESERVED_STEP_ENV.has(String(name)))) {
251
+ throw new DefinitionError(`steps[${index}].secrets may not replace process-control environment variables.`);
252
+ }
253
+ const timeoutMs = step.timeoutMs === undefined ? undefined : Number(step.timeoutMs);
254
+ if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 86400000)) {
255
+ throw new DefinitionError(`steps[${index}].timeoutMs must be an integer from 1 to 86400000.`);
256
+ }
257
+ let when;
258
+ if (step.when !== undefined) {
259
+ const conditions = record(step.when, `steps[${index}].when`);
260
+ when = {};
261
+ for (const [name, expected] of Object.entries(conditions)) {
262
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || typeof expected !== "string" || expected.length === 0) {
263
+ throw new DefinitionError(`steps[${index}].when must map environment names to non-empty strings.`);
264
+ }
265
+ when[name] = expected;
266
+ }
267
+ if (Object.keys(when).length === 0)
268
+ throw new DefinitionError(`steps[${index}].when must not be empty.`);
269
+ }
270
+ return {
271
+ name: text(step.name, `steps[${index}].name`),
272
+ run: text(step.run, `steps[${index}].run`),
273
+ phase,
274
+ secrets: step.secrets,
275
+ once: step.once === true,
276
+ always: step.always === true,
277
+ timeoutMs,
278
+ when
279
+ };
280
+ });
281
+ if (new Set(steps.map((step) => step.name)).size !== steps.length) {
282
+ throw new DefinitionError("pipeline.steps must have unique names.");
283
+ }
284
+ return {
285
+ version: PIPELINE_VERSION,
286
+ name: text(root.name, "pipeline.name"),
287
+ requireAttestation: root.requireAttestation === true,
288
+ roles,
289
+ steps
290
+ };
291
+ }
292
+ function prerequisitePipeline(definition, roleName) {
293
+ const role = definition.roles.find((candidate) => candidate.name === roleName);
294
+ if (!role)
295
+ throw new DefinitionError(`pipeline role does not exist: ${roleName}.`);
296
+ return {
297
+ name: `${definition.name}:prerequisites:${role.name}`,
298
+ requireAttestation: definition.requireAttestation,
299
+ steps: role.software.map((software) => ({
300
+ name: `prepare ${software.name}`,
301
+ run: `${software.check} >/dev/null 2>&1 || { ${software.install}; ${software.check}; }`
302
+ }))
303
+ };
304
+ }
305
+
306
+ // src/pipeline.ts
307
+ class PipelineError extends Error {
250
308
  code;
251
309
  constructor(code, message) {
252
310
  super(message);
253
311
  this.code = code;
254
- this.name = "CacheError";
312
+ this.name = "PipelineError";
255
313
  }
256
314
  }
257
- var DEFAULT_TTL_MS = 60000;
258
- var DEFAULT_MAX_STALE_MS = 300000;
259
- function createSecretCache(options) {
260
- const entries = new Map;
315
+ function redact(text2, values) {
316
+ let out = text2;
317
+ for (const value of [...values].sort((a, b) => b.length - a.length)) {
318
+ if (value.length < 8)
319
+ continue;
320
+ out = out.split(value).join("\u2022\u2022\u2022\u2022redacted\u2022\u2022\u2022\u2022");
321
+ }
322
+ return out;
323
+ }
324
+ async function runPipeline(options) {
261
325
  const now = options.now ?? (() => Date.now());
262
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
263
- const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
264
- let cursor = 0;
265
- let replicated = false;
266
- let lastSyncOkMs = now();
267
- const loadScope = async () => {
268
- if (!options.list)
269
- return { loaded: 0, failed: [] };
270
- const names = await options.list();
271
- const failed = [];
272
- let loaded = 0;
273
- for (const name of names) {
326
+ const { pipeline } = options;
327
+ let assurance = "enrolled";
328
+ if (options.attest) {
329
+ try {
330
+ await options.attest();
331
+ assurance = "attested";
332
+ } catch (cause) {
333
+ if (pipeline.requireAttestation) {
334
+ throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
335
+ }
336
+ }
337
+ } else if (pipeline.requireAttestation) {
338
+ throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
339
+ }
340
+ const steps = [];
341
+ let failed = false;
342
+ for (const step of pipeline.steps) {
343
+ if (failed && !step.always) {
344
+ steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
345
+ continue;
346
+ }
347
+ const env = {};
348
+ const values = [];
349
+ for (const name of step.secrets ?? []) {
274
350
  try {
275
- const result = await options.fetch(name);
276
- entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
277
- loaded += 1;
351
+ const value = await options.secret(name);
352
+ env[name] = value;
353
+ values.push(value);
278
354
  } catch {
279
- failed.push(name);
355
+ throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
280
356
  }
281
357
  }
282
- replicated = true;
283
- return { loaded, failed };
358
+ const started = now();
359
+ let exitCode;
360
+ let output;
361
+ try {
362
+ const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
363
+ exitCode = result.exitCode;
364
+ output = result.output;
365
+ } catch (cause) {
366
+ exitCode = -1;
367
+ output = cause.message;
368
+ }
369
+ const outcome = exitCode === 0 ? "ok" : "failed";
370
+ if (outcome === "failed")
371
+ failed = true;
372
+ steps.push({
373
+ name: step.name,
374
+ outcome,
375
+ exitCode,
376
+ log: redact(output, values),
377
+ durationMs: now() - started
378
+ });
379
+ }
380
+ return { pipeline: pipeline.name, ok: !failed, assurance, steps };
381
+ }
382
+
383
+ // src/deployment.ts
384
+ class DeploymentError extends Error {
385
+ code;
386
+ constructor(code, message) {
387
+ super(message);
388
+ this.code = code;
389
+ this.name = "DeploymentError";
390
+ }
391
+ }
392
+ var shell = async (input) => {
393
+ const child = Bun.spawn(["bash", "-Eeuo", "pipefail", "-c", input.command], {
394
+ cwd: input.cwd,
395
+ env: { ...process.env, ...input.env },
396
+ detached: true,
397
+ stdout: "pipe",
398
+ stderr: "pipe"
399
+ });
400
+ let forceTimer;
401
+ const killGroup = (signal) => {
402
+ try {
403
+ process.kill(-child.pid, signal);
404
+ } catch (cause) {
405
+ if (cause.code !== "ESRCH")
406
+ throw cause;
407
+ }
408
+ };
409
+ const timer = input.timeoutMs ? setTimeout(() => {
410
+ killGroup("SIGTERM");
411
+ forceTimer = setTimeout(() => killGroup("SIGKILL"), 2000);
412
+ }, input.timeoutMs) : undefined;
413
+ const [stdout, stderr, exitCode] = await Promise.all([
414
+ new Response(child.stdout).text(),
415
+ new Response(child.stderr).text(),
416
+ child.exited
417
+ ]);
418
+ if (timer)
419
+ clearTimeout(timer);
420
+ if (forceTimer)
421
+ clearTimeout(forceTimer);
422
+ return { exitCode, output: `${stdout}${stderr}` };
423
+ };
424
+ var quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
425
+ function createDeploymentManager(options) {
426
+ const queue = createQueue({ width: options.width ?? 4 });
427
+ const activeRevisions = new Map;
428
+ const exec = options.exec ?? shell;
429
+ const projectExec = options.projectExec ?? exec;
430
+ const now = options.now ?? Date.now;
431
+ const readDefinition = options.readDefinition ?? ((path) => Bun.YAML.parse(readFileSync(path, "utf8")));
432
+ const credentialsDirectory = process.env.CREDENTIALS_DIRECTORY;
433
+ const gitCredentialPath = options.gitCredentialPath ?? (credentialsDirectory ? join(credentialsDirectory, "git-deploy-key") : undefined);
434
+ const knownHostsPath = options.knownHostsPath ?? "/etc/forgezero/git/known_hosts";
435
+ const persistKnownHosts = () => {
436
+ if (!options.knownHostsContent)
437
+ return;
438
+ const content = `${options.knownHostsContent.trim()}
439
+ `;
440
+ if (content.length > 16385 || /\r|\0/.test(content)) {
441
+ throw new DeploymentError("SOURCE_FAILED", "The pinned Git host keys are malformed.");
442
+ }
443
+ if (existsSync2(knownHostsPath) && readFileSync(knownHostsPath, "utf8") === content)
444
+ return;
445
+ mkdirSync(dirname(knownHostsPath), { recursive: true, mode: 448 });
446
+ const next = `${knownHostsPath}.${process.pid}.${randomUUID()}.next`;
447
+ writeFileSync(next, content, { mode: 384, flag: "wx" });
448
+ renameSync(next, knownHostsPath);
449
+ chmodSync2(knownHostsPath, 384);
450
+ };
451
+ const gitEnvironment = () => {
452
+ if (/^https:\/\//i.test(options.repository)) {
453
+ return { GIT_TERMINAL_PROMPT: "0" };
454
+ }
455
+ if (!gitCredentialPath) {
456
+ throw new DeploymentError("SOURCE_FAILED", "No Git deploy-key credential was loaded for the agent.");
457
+ }
458
+ persistKnownHosts();
459
+ return {
460
+ GIT_TERMINAL_PROMPT: "0",
461
+ GIT_SSH_COMMAND: `ssh -i ${quote(gitCredentialPath)} -o IdentitiesOnly=yes -o BatchMode=yes ` + `-o ConnectTimeout=15 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${quote(knownHostsPath)}`
462
+ };
463
+ };
464
+ const checked = async (input, code) => {
465
+ const result = await exec(input);
466
+ if (result.exitCode !== 0) {
467
+ throw new DeploymentError(code, result.output.trim() || `${input.command} exited ${result.exitCode}.`);
468
+ }
469
+ return result;
470
+ };
471
+ const deploy = async (request) => {
472
+ if (request.revision && !/^[a-f0-9]{40}$/i.test(request.revision)) {
473
+ throw new DeploymentError("BAD_REVISION", "A deployment revision must be a full 40-character Git commit.");
474
+ }
475
+ const stamp = `${new Date(now()).toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}-${randomUUID().slice(0, 8)}`;
476
+ const release = join(options.root, "releases", stamp);
477
+ const env = gitEnvironment();
478
+ await checked({
479
+ command: `test -d ${quote(join(options.root, "releases"))} && test -w ${quote(join(options.root, "releases"))}`
480
+ }, "SOURCE_FAILED");
481
+ await checked({
482
+ command: `umask 0007 && git clone --quiet --no-checkout --branch ${quote(options.branch)} --depth 100 ` + `${quote(options.repository)} ${quote(release)}`,
483
+ env
484
+ }, "SOURCE_FAILED");
485
+ if (request.revision) {
486
+ let present = await exec({
487
+ command: `git cat-file -e ${quote(`${request.revision}^{commit}`)}`,
488
+ cwd: release,
489
+ env
490
+ });
491
+ if (present.exitCode !== 0) {
492
+ await checked({
493
+ command: `git fetch --quiet --deepen=1000 origin ${quote(options.branch)}`,
494
+ cwd: release,
495
+ env
496
+ }, "SOURCE_FAILED");
497
+ present = await exec({ command: `git cat-file -e ${quote(`${request.revision}^{commit}`)}`, cwd: release, env });
498
+ }
499
+ if (present.exitCode !== 0) {
500
+ throw new DeploymentError("SOURCE_FAILED", `${request.revision} is not available from ${options.branch}.`);
501
+ }
502
+ await checked({
503
+ command: `git merge-base --is-ancestor ${quote(request.revision)} ${quote(`origin/${options.branch}`)}`,
504
+ cwd: release,
505
+ env
506
+ }, "SOURCE_FAILED");
507
+ }
508
+ await checked({
509
+ command: `git checkout --quiet --detach ${quote(request.revision ?? `origin/${options.branch}`)}`,
510
+ cwd: release,
511
+ env
512
+ }, "SOURCE_FAILED");
513
+ const head = (await checked({ command: "git rev-parse HEAD", cwd: release, env }, "SOURCE_FAILED")).output.trim();
514
+ if (request.revision && head !== request.revision) {
515
+ throw new DeploymentError("SOURCE_FAILED", `Git checked out ${head}, not requested ${request.revision}.`);
516
+ }
517
+ const definition = parseDeployDefinition(readDefinition(join(release, ".fz", "deploy.yaml")));
518
+ const phaseEnvironment = {
519
+ ...options.environment ?? {},
520
+ FZ_RELEASE: release,
521
+ FZ_DEPLOY_REVISION: head,
522
+ FZ_DEPLOY_BRANCH: options.branch,
523
+ FZ_DEPLOY_ROLE: options.role,
524
+ ...options.publicApiUrl ? { PUBLIC_API_URL: options.publicApiUrl } : {}
525
+ };
526
+ const phaseExec = ({ command, env: secrets, timeoutMs }) => projectExec({ command, cwd: release, env: { ...phaseEnvironment, ...secrets }, timeoutMs });
527
+ const secret = async (name) => {
528
+ if (!options.cache) {
529
+ throw new DeploymentError("SECRET_MISSING", `${name} is not available on this platform-only agent.`);
530
+ }
531
+ return options.cache.get(name);
532
+ };
533
+ const attest = options.attestation ? async () => ({ report: await options.attestation.report(head), source: options.attestation.name }) : undefined;
534
+ const pipelines = [
535
+ prerequisitePipeline(definition, options.role),
536
+ ...["build", "migrate", "release", "health"].map((phase) => {
537
+ return {
538
+ name: `${definition.name}:${phase}`,
539
+ requireAttestation: definition.requireAttestation,
540
+ steps: definition.steps.filter((step) => step.phase === phase && (!step.once || request.coordinator === true) && (!step.when || Object.entries(step.when).every(([name, expected]) => phaseEnvironment[name] === expected)))
541
+ };
542
+ })
543
+ ].filter((pipeline) => pipeline.steps.length > 0);
544
+ const phases = [];
545
+ for (const pipeline of pipelines) {
546
+ const result = await runPipeline({ pipeline, secret, exec: phaseExec, attest });
547
+ phases.push(result);
548
+ if (!result.ok) {
549
+ const failedStep = result.steps.find((step) => step.outcome === "failed");
550
+ const detail = failedStep?.log.trim().slice(0, 2000);
551
+ throw new DeploymentError("PIPELINE_FAILED", `${pipeline.name} failed at ${failedStep?.name ?? "unknown step"}` + (detail ? `: ${detail}` : ` (exit ${failedStep?.exitCode ?? "unknown"}).`));
552
+ }
553
+ }
554
+ return {
555
+ key: options.key,
556
+ repository: options.repository,
557
+ branch: options.branch,
558
+ revision: head,
559
+ release,
560
+ ok: true,
561
+ phases
562
+ };
284
563
  };
285
564
  return {
286
- names: () => [...entries.keys()],
287
- get replica() {
288
- return replicated;
289
- },
290
- load: loadScope,
291
- get cursor() {
292
- return cursor;
565
+ async latestRevision() {
566
+ const result = await checked({
567
+ command: `git ls-remote --exit-code ${quote(options.repository)} ${quote(`refs/heads/${options.branch}`)}`,
568
+ env: gitEnvironment()
569
+ }, "SOURCE_FAILED");
570
+ const revision = result.output.trim().split(/\s+/)[0] ?? "";
571
+ if (!/^[a-f0-9]{40}$/i.test(revision)) {
572
+ throw new DeploymentError("SOURCE_FAILED", `Could not resolve ${options.branch} to one exact commit.`);
573
+ }
574
+ return revision.toLowerCase();
293
575
  },
294
- async get(name) {
295
- const staleFor = now() - lastSyncOkMs;
296
- if (staleFor > maxStaleMs) {
297
- 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.");
576
+ deploy(request = {}) {
577
+ const revisionKey = request.revision ? `${request.revision.toLowerCase()}:${request.coordinator === true ? "coordinator" : "node"}` : undefined;
578
+ if (revisionKey) {
579
+ const existing = activeRevisions.get(revisionKey);
580
+ if (existing)
581
+ return existing;
298
582
  }
299
- const cached = entries.get(name);
300
- if (cached && now() - cached.fetchedAtMs < ttlMs)
301
- return cached.value;
302
- let fetched;
303
- try {
304
- fetched = await options.fetch(name);
305
- } catch (cause) {
306
- if (cached)
307
- return cached.value;
308
- throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
583
+ const task = queue.run(options.key, () => deploy(request));
584
+ if (revisionKey) {
585
+ activeRevisions.set(revisionKey, task);
586
+ task.result.finally(() => {
587
+ if (activeRevisions.get(revisionKey) === task)
588
+ activeRevisions.delete(revisionKey);
589
+ }).catch(() => {
590
+ return;
591
+ });
592
+ }
593
+ return task;
594
+ },
595
+ snapshot: queue.snapshot,
596
+ pause: queue.pause,
597
+ resume: queue.resume,
598
+ pauseKey: queue.pauseKey,
599
+ resumeKey: queue.resumeKey,
600
+ stopKey: queue.stopKey,
601
+ startKey: queue.startKey,
602
+ cancel: queue.cancel,
603
+ stop(deadlineMs) {
604
+ return queue.stop(deadlineMs);
605
+ }
606
+ };
607
+ }
608
+
609
+ // src/control.ts
610
+ import { chmodSync as chmodSync3, existsSync as existsSync3, unlinkSync as unlinkSync2 } from "fs";
611
+ import { connect, createServer as createServer2 } from "net";
612
+ var DEFAULT_CONTROL_SOCKET = "/run/forgezero/control.sock";
613
+ var MAX_REQUEST_BYTES = 16 * 1024;
614
+ var refused = (code, message) => ({
615
+ ok: false,
616
+ error: { code, message }
617
+ });
618
+ async function handleControl(manager, request) {
619
+ switch (request?.op) {
620
+ case "deploy": {
621
+ try {
622
+ const requested = request.request ?? {};
623
+ const revision = requested.revision ?? await manager.latestRevision();
624
+ const task = manager.deploy({ ...requested, revision });
625
+ return { ok: true, op: "deploy", taskId: task.id, result: await task.result };
626
+ } catch (cause) {
627
+ return refused(cause.code ?? "DEPLOY_FAILED", cause instanceof Error ? cause.message : "Deployment failed.");
628
+ }
629
+ }
630
+ case "status":
631
+ return { ok: true, op: "status", queue: manager.snapshot() };
632
+ case "pause":
633
+ manager.pause();
634
+ return { ok: true, op: "pause", changed: true };
635
+ case "resume":
636
+ manager.resume();
637
+ return { ok: true, op: "resume", changed: true };
638
+ case "pause-key":
639
+ if (!request.key)
640
+ return refused("BAD_REQUEST", "pause-key needs a key.");
641
+ manager.pauseKey(request.key);
642
+ return { ok: true, op: "pause-key", changed: true };
643
+ case "resume-key":
644
+ if (!request.key)
645
+ return refused("BAD_REQUEST", "resume-key needs a key.");
646
+ manager.resumeKey(request.key);
647
+ return { ok: true, op: "resume-key", changed: true };
648
+ case "stop-key": {
649
+ if (!request.key)
650
+ return refused("BAD_REQUEST", "stop-key needs a key.");
651
+ const removed = manager.stopKey(request.key);
652
+ return { ok: true, op: "stop-key", changed: true, removed };
653
+ }
654
+ case "start-key":
655
+ if (!request.key)
656
+ return refused("BAD_REQUEST", "start-key needs a key.");
657
+ return { ok: true, op: "start-key", changed: manager.startKey(request.key) };
658
+ case "cancel":
659
+ if (!request.id)
660
+ return refused("BAD_REQUEST", "cancel needs a task id.");
661
+ return { ok: true, op: "cancel", changed: manager.cancel(request.id) };
662
+ default:
663
+ return refused("UNKNOWN_OP", "Unknown agent control operation.");
664
+ }
665
+ }
666
+ function startControlServer(manager, socketPath = DEFAULT_CONTROL_SOCKET) {
667
+ if (existsSync3(socketPath))
668
+ unlinkSync2(socketPath);
669
+ const server = createServer2((socket) => {
670
+ let buffer = "";
671
+ socket.on("data", (chunk) => {
672
+ buffer += chunk.toString("utf8");
673
+ if (buffer.length > MAX_REQUEST_BYTES) {
674
+ socket.end(`${JSON.stringify(refused("TOO_LARGE", "Control request too large."))}
675
+ `);
676
+ return;
677
+ }
678
+ const newline = buffer.indexOf(`
679
+ `);
680
+ if (newline === -1)
681
+ return;
682
+ const line = buffer.slice(0, newline);
683
+ buffer = "";
684
+ Promise.resolve().then(() => JSON.parse(line)).then((request) => handleControl(manager, request)).catch((cause) => refused("BAD_JSON", cause instanceof Error ? cause.message : "Invalid request.")).then((response) => socket.end(`${JSON.stringify(response)}
685
+ `));
686
+ });
687
+ socket.on("error", () => socket.destroy());
688
+ });
689
+ server.listen(socketPath, () => chmodSync3(socketPath, 384));
690
+ return server;
691
+ }
692
+ function requestControl(request, socketPath = DEFAULT_CONTROL_SOCKET) {
693
+ return new Promise((resolve, reject) => {
694
+ const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
695
+ `));
696
+ let buffer = "";
697
+ socket.on("data", (chunk) => {
698
+ buffer += chunk.toString("utf8");
699
+ const newline = buffer.indexOf(`
700
+ `);
701
+ if (newline === -1)
702
+ return;
703
+ socket.end();
704
+ try {
705
+ resolve(JSON.parse(buffer.slice(0, newline)));
706
+ } catch (cause) {
707
+ reject(cause);
708
+ }
709
+ });
710
+ socket.on("error", reject);
711
+ });
712
+ }
713
+
714
+ // src/signed-node-http.ts
715
+ import { signRequest as signRequest2 } from "@forgezero/runtime/identity";
716
+
717
+ class SignedNodeHttpError extends Error {
718
+ status;
719
+ constructor(status, message) {
720
+ super(message);
721
+ this.status = status;
722
+ this.name = "SignedNodeHttpError";
723
+ }
724
+ }
725
+ var signatureHeader = (envelope) => Buffer.from(JSON.stringify({
726
+ timestamp: envelope.timestamp,
727
+ nonce: envelope.nonce,
728
+ edSignature: envelope.edSignature,
729
+ mlDsaSignature: envelope.mlDsaSignature
730
+ })).toString("base64url");
731
+ async function postSignedNode(options, path, body) {
732
+ const url = new URL(options.apiUrl);
733
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
734
+ url.search = "";
735
+ url.hash = "";
736
+ const raw = JSON.stringify(body);
737
+ const envelope = signRequest2(options.keys, options.nodeKey, {
738
+ method: "POST",
739
+ path: url.pathname,
740
+ query: "",
741
+ body: raw
742
+ });
743
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
744
+ method: "POST",
745
+ headers: {
746
+ "content-type": "application/json",
747
+ "x-fz-node": options.nodeKey,
748
+ "x-fz-signature": signatureHeader(envelope)
749
+ },
750
+ body: raw,
751
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
752
+ });
753
+ const payload = await response.json().catch(() => null);
754
+ if (!response.ok) {
755
+ const failure = payload;
756
+ const reason = failure ? failure.error?.message ?? failure.message : undefined;
757
+ throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
758
+ }
759
+ return payload;
760
+ }
761
+
762
+ // src/deployment-pull.ts
763
+ class DeploymentClaimLostError extends Error {
764
+ constructor(message) {
765
+ super(message);
766
+ this.name = "DeploymentClaimLostError";
767
+ }
768
+ }
769
+ async function postSigned(options, operation, body) {
770
+ return postSignedNode(options, `v1/node/deployments/${operation}`, body);
771
+ }
772
+ async function deployClaim(options, claim) {
773
+ const manager = options.manager ?? options.managerFor?.(claim);
774
+ if (!manager)
775
+ throw new Error("deployment pull has no manager for this claim");
776
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
777
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
778
+ const now = options.now ?? Date.now;
779
+ let expires = claim.claimExpiresAtTs;
780
+ let stopped = false;
781
+ let timer;
782
+ let renewal = null;
783
+ let claimLost = null;
784
+ const schedule = (overrideMs) => {
785
+ if (stopped)
786
+ return;
787
+ const remaining = Math.max(0, expires - now());
788
+ const delay = overrideMs ?? Math.max(1000, Math.min(5 * 60000, Math.floor(remaining / 3)));
789
+ timer = setTimer(() => {
790
+ if (stopped || renewal)
791
+ return;
792
+ let retryDelay;
793
+ renewal = postSigned(options, "renew", {
794
+ runKey: claim.runKey,
795
+ claimToken: claim.claimToken
796
+ }).then((response) => {
797
+ expires = response.claimExpiresAtTs;
798
+ options.onEvent?.("lease-renewed", { runKey: claim.runKey, claimExpiresAtTs: expires });
799
+ }).catch((cause) => {
800
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
801
+ claimLost = new DeploymentClaimLostError(cause.message);
802
+ stopped = true;
803
+ } else {
804
+ retryDelay = Math.max(1000, options.renewRetryMs ?? 5000);
805
+ options.onEvent?.("lease-renew-failed", cause);
806
+ }
807
+ }).finally(() => {
808
+ renewal = null;
809
+ if (!stopped)
810
+ schedule(retryDelay);
811
+ });
812
+ }, delay);
813
+ };
814
+ schedule();
815
+ let result;
816
+ try {
817
+ result = await manager.deploy({ revision: claim.revision, coordinator: true }).result;
818
+ } finally {
819
+ stopped = true;
820
+ clearTimer(timer);
821
+ await renewal;
822
+ }
823
+ if (claimLost)
824
+ throw claimLost;
825
+ return result;
826
+ }
827
+ async function completeSigned(options, body) {
828
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
829
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
830
+ let last;
831
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
832
+ try {
833
+ await postSigned(options, "complete", body);
834
+ return;
835
+ } catch (cause) {
836
+ last = cause;
837
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
838
+ throw cause;
839
+ if (attempt < attempts)
840
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
841
+ }
842
+ }
843
+ throw last;
844
+ }
845
+ async function pullDeploymentOnce(options) {
846
+ const claimed = await postSigned(options, "claim", {});
847
+ if (!claimed.claim)
848
+ return { status: "idle" };
849
+ const claim = claimed.claim;
850
+ let result;
851
+ try {
852
+ result = await deployClaim(options, claim);
853
+ } catch (cause) {
854
+ if (cause instanceof DeploymentClaimLostError)
855
+ throw cause;
856
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
857
+ await completeSigned(options, {
858
+ runKey: claim.runKey,
859
+ claimToken: claim.claimToken,
860
+ ok: false,
861
+ detail: reason
862
+ });
863
+ return { status: "failed", claim, reason };
864
+ }
865
+ await completeSigned(options, {
866
+ runKey: claim.runKey,
867
+ claimToken: claim.claimToken,
868
+ ok: true,
869
+ detail: `Deployed ${result.revision}.`,
870
+ release: result.release
871
+ });
872
+ return { status: "deployed", claim, result };
873
+ }
874
+ function startDeploymentPull(options) {
875
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
876
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
877
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
878
+ const emit = options.onEvent ?? (() => {});
879
+ const parallelism = Math.max(1, Math.min(options.parallelism ?? 4, 32));
880
+ let stopped = false;
881
+ const workers = Array.from({ length: parallelism }, () => ({ active: null }));
882
+ const schedule = (worker) => {
883
+ if (!stopped)
884
+ worker.timer = setTimer(() => tick(worker), interval);
885
+ };
886
+ const tick = (worker) => {
887
+ if (stopped || worker.active)
888
+ return;
889
+ worker.active = pullDeploymentOnce(options).then((result) => emit(result.status, result)).catch((cause) => emit("poll-failed", cause)).finally(() => {
890
+ worker.active = null;
891
+ schedule(worker);
892
+ });
893
+ };
894
+ for (const worker of workers)
895
+ tick(worker);
896
+ return {
897
+ async stop() {
898
+ stopped = true;
899
+ for (const worker of workers)
900
+ clearTimer(worker.timer);
901
+ await Promise.all(workers.map((worker) => worker.active));
902
+ },
903
+ get active() {
904
+ return !stopped;
905
+ }
906
+ };
907
+ }
908
+
909
+ // src/deployment-watch.ts
910
+ import {
911
+ chmodSync as chmodSync4,
912
+ closeSync,
913
+ fsyncSync,
914
+ mkdirSync as mkdirSync2,
915
+ openSync,
916
+ readFileSync as readFileSync2,
917
+ renameSync as renameSync2,
918
+ unlinkSync as unlinkSync3,
919
+ writeFileSync as writeFileSync2
920
+ } from "fs";
921
+ import { dirname as dirname2 } from "path";
922
+ var validState = (value) => {
923
+ const state = value;
924
+ return Boolean(state && /^[a-f0-9]{40}$/i.test(state.revision ?? "") && ["pending", "running", "deployed", "failed"].includes(state.outcome ?? "") && Number.isFinite(state.updatedAtTs));
925
+ };
926
+ function readStaticDeploymentState(path) {
927
+ try {
928
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
929
+ return validState(parsed) ? parsed : null;
930
+ } catch {
931
+ return null;
932
+ }
933
+ }
934
+ function writeStaticDeploymentState(path, state) {
935
+ const directory = dirname2(path);
936
+ mkdirSync2(directory, { recursive: true, mode: 448 });
937
+ const temporary = `${path}.new-${process.pid}`;
938
+ let file;
939
+ try {
940
+ file = openSync(temporary, "w", 384);
941
+ writeFileSync2(file, `${JSON.stringify(state)}
942
+ `);
943
+ fsyncSync(file);
944
+ closeSync(file);
945
+ file = undefined;
946
+ chmodSync4(temporary, 384);
947
+ renameSync2(temporary, path);
948
+ const parent = openSync(directory, "r");
949
+ try {
950
+ fsyncSync(parent);
951
+ } finally {
952
+ closeSync(parent);
953
+ }
954
+ } catch (cause) {
955
+ if (file !== undefined)
956
+ closeSync(file);
957
+ try {
958
+ unlinkSync3(temporary);
959
+ } catch {}
960
+ throw cause;
961
+ }
962
+ }
963
+ function startStaticDeploymentWatch(options) {
964
+ const interval = Math.max(1000, options.intervalMs ?? 15000);
965
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
966
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
967
+ const now = options.now ?? Date.now;
968
+ const readState = options.readState ?? (() => readStaticDeploymentState(options.statePath));
969
+ const writeState = options.writeState ?? ((state) => writeStaticDeploymentState(options.statePath, state));
970
+ const emit = options.onEvent ?? (() => {
971
+ return;
972
+ });
973
+ let stopped = false;
974
+ let timer;
975
+ let inFlight = null;
976
+ const persist = (revision, outcome, detail) => {
977
+ writeState({ revision, outcome, updatedAtTs: now(), ...detail ? { detail: detail.slice(0, 2000) } : {} });
978
+ };
979
+ const schedule = () => {
980
+ if (!stopped)
981
+ timer = setTimer(tick, interval);
982
+ };
983
+ const run = async () => {
984
+ const prior = readState();
985
+ const revision = prior && (prior.outcome === "pending" || prior.outcome === "running") ? prior.revision : await options.manager.latestRevision();
986
+ const current = options.currentRevision?.();
987
+ if (prior?.outcome === "deployed" && prior.revision === revision) {
988
+ emit("unchanged", { revision });
989
+ return;
990
+ }
991
+ if (prior?.outcome === "failed" && prior.revision === revision) {
992
+ emit("failed-unchanged", { revision, detail: prior.detail });
993
+ return;
994
+ }
995
+ if (!prior && current === revision) {
996
+ persist(revision, "deployed", "Adopted the already active bootstrap release.");
997
+ emit("adopted", { revision });
998
+ return;
999
+ }
1000
+ persist(revision, "pending", "Observed at the configured branch head.");
1001
+ persist(revision, "running", "Submitted to the keyed deployment queue.");
1002
+ let result;
1003
+ try {
1004
+ result = await options.manager.deploy({ revision, coordinator: options.coordinator }).result;
1005
+ } catch (cause) {
1006
+ const detail = cause instanceof Error ? cause.message : String(cause);
1007
+ persist(revision, "failed", detail);
1008
+ emit("failed", { revision, detail });
1009
+ return;
1010
+ }
1011
+ persist(revision, "deployed", `Activated ${result.release}.`);
1012
+ emit("deployed", { revision, release: result.release });
1013
+ };
1014
+ function tick() {
1015
+ if (stopped || inFlight)
1016
+ return;
1017
+ inFlight = run().catch((cause) => emit("poll-failed", cause)).finally(() => {
1018
+ inFlight = null;
1019
+ schedule();
1020
+ });
1021
+ }
1022
+ tick();
1023
+ return {
1024
+ async stop() {
1025
+ stopped = true;
1026
+ clearTimer(timer);
1027
+ await inFlight;
1028
+ },
1029
+ get active() {
1030
+ return !stopped;
1031
+ }
1032
+ };
1033
+ }
1034
+
1035
+ // src/guest-enrolment.ts
1036
+ import {
1037
+ chmodSync as chmodSync5,
1038
+ existsSync as existsSync4,
1039
+ mkdirSync as mkdirSync3,
1040
+ readFileSync as readFileSync3,
1041
+ renameSync as renameSync3,
1042
+ statSync,
1043
+ unlinkSync as unlinkSync4,
1044
+ writeFileSync as writeFileSync3
1045
+ } from "fs";
1046
+ import { dirname as dirname3 } from "path";
1047
+ var validBinding = (value, expectedNodeKey) => {
1048
+ if (!value || typeof value !== "object")
1049
+ return false;
1050
+ const row = value;
1051
+ return ["nodeKey", "computeReference", "projectKey", "environmentKey", "tenantSlug"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
1052
+ };
1053
+ function loadGuestBinding(path, expectedNodeKey) {
1054
+ if (!existsSync4(path))
1055
+ return null;
1056
+ const mode = statSync(path).mode & 511;
1057
+ if ((mode & 63) !== 0) {
1058
+ throw new Error(`guest enrolment state at ${path} is not private`);
1059
+ }
1060
+ let parsed;
1061
+ try {
1062
+ parsed = JSON.parse(readFileSync3(path, "utf8"));
1063
+ } catch {
1064
+ throw new Error(`guest enrolment state at ${path} is malformed`);
1065
+ }
1066
+ if (!validBinding(parsed, expectedNodeKey)) {
1067
+ throw new Error(`guest enrolment state at ${path} does not match this node identity`);
1068
+ }
1069
+ return parsed;
1070
+ }
1071
+ function persistGuestBinding(path, binding) {
1072
+ mkdirSync3(dirname3(path), { recursive: true, mode: 448 });
1073
+ const temporary = `${path}.next`;
1074
+ writeFileSync3(temporary, `${JSON.stringify(binding)}
1075
+ `, { mode: 384 });
1076
+ chmodSync5(temporary, 384);
1077
+ renameSync3(temporary, path);
1078
+ }
1079
+ async function enrolGuestIdentity(options) {
1080
+ const token = options.token?.trim() ?? (options.tokenPath ? readFileSync3(options.tokenPath, "utf8").trim() : "");
1081
+ if (!token.startsWith("fze_"))
1082
+ throw new Error("guest enrolment credential is malformed");
1083
+ const url = new URL(options.apiUrl);
1084
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/v1/compute/enrol`.replace(/\/+/g, "/");
1085
+ url.search = "";
1086
+ url.hash = "";
1087
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
1088
+ method: "POST",
1089
+ headers: { "content-type": "application/json" },
1090
+ body: JSON.stringify({
1091
+ token,
1092
+ label: options.label,
1093
+ gitDeployPublicKey: options.gitDeployPublicKey,
1094
+ publicKeys: {
1095
+ ed25519: options.keys.ed25519.publicKey,
1096
+ mlDsa: options.keys.mlDsa.publicKey
1097
+ }
1098
+ }),
1099
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
1100
+ });
1101
+ const payload = await response.json().catch(() => null);
1102
+ if (!response.ok || !payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || !payload.tenantSlug) {
1103
+ throw new Error(payload?.error?.message || `guest enrolment returned HTTP ${response.status}`);
1104
+ }
1105
+ const binding = {
1106
+ nodeKey: payload.nodeKey,
1107
+ computeReference: payload.computeReference,
1108
+ projectKey: payload.projectKey,
1109
+ environmentKey: payload.environmentKey,
1110
+ tenantSlug: payload.tenantSlug
1111
+ };
1112
+ persistGuestBinding(options.statePath, binding);
1113
+ if (options.consume)
1114
+ await options.consume();
1115
+ else if (options.tokenPath)
1116
+ unlinkSync4(options.tokenPath);
1117
+ return binding;
1118
+ }
1119
+
1120
+ // src/cache.ts
1121
+ class CacheError extends Error {
1122
+ code;
1123
+ constructor(code, message) {
1124
+ super(message);
1125
+ this.code = code;
1126
+ this.name = "CacheError";
1127
+ }
1128
+ }
1129
+ var DEFAULT_TTL_MS = 60000;
1130
+ var DEFAULT_MAX_STALE_MS = 300000;
1131
+ function createSecretCache(options) {
1132
+ const entries = new Map;
1133
+ const now = options.now ?? (() => Date.now());
1134
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1135
+ const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
1136
+ let cursor = 0;
1137
+ let replicated = false;
1138
+ let lastSyncOkMs = now();
1139
+ const loadScope = async () => {
1140
+ if (!options.list)
1141
+ return { loaded: 0, failed: [] };
1142
+ const names = await options.list();
1143
+ const failed = [];
1144
+ let loaded = 0;
1145
+ for (const name of names) {
1146
+ try {
1147
+ const result = await options.fetch(name);
1148
+ entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
1149
+ loaded += 1;
1150
+ } catch {
1151
+ failed.push(name);
1152
+ }
1153
+ }
1154
+ replicated = true;
1155
+ return { loaded, failed };
1156
+ };
1157
+ return {
1158
+ names: () => [...entries.keys()],
1159
+ get replica() {
1160
+ return replicated;
1161
+ },
1162
+ load: loadScope,
1163
+ get cursor() {
1164
+ return cursor;
1165
+ },
1166
+ async get(name) {
1167
+ const staleFor = now() - lastSyncOkMs;
1168
+ if (staleFor > maxStaleMs) {
1169
+ 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.");
1170
+ }
1171
+ const cached = entries.get(name);
1172
+ if (cached && now() - cached.fetchedAtMs < ttlMs)
1173
+ return cached.value;
1174
+ let fetched;
1175
+ try {
1176
+ fetched = await options.fetch(name);
1177
+ } catch (cause) {
1178
+ if (cached)
1179
+ return cached.value;
1180
+ throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
309
1181
  }
310
1182
  entries.set(name, { ...fetched, fetchedAtMs: now() });
311
1183
  return fetched.value;
@@ -338,37 +1210,1502 @@ function createSecretCache(options) {
338
1210
  };
339
1211
  }
340
1212
 
1213
+ // src/node-vault.ts
1214
+ function tenantNodeApiUrl(apiUrl, tenantSlug) {
1215
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
1216
+ throw new Error("tenant slug is malformed");
1217
+ const url = new URL(apiUrl);
1218
+ url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
1219
+ url.search = "";
1220
+ url.hash = "";
1221
+ return url.toString().replace(/\/$/, "");
1222
+ }
1223
+ function createNodeVaultCache(options) {
1224
+ const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
1225
+ return createSecretCache({
1226
+ ttlMs: options.ttlMs,
1227
+ maxStaleMs: options.maxStaleMs,
1228
+ list: async () => {
1229
+ const payload = await post("list", {});
1230
+ if (!Array.isArray(payload.names) || payload.names.some((name) => typeof name !== "string")) {
1231
+ throw new Error("node vault list response is malformed");
1232
+ }
1233
+ return payload.names;
1234
+ },
1235
+ fetch: async (name) => {
1236
+ const payload = await post("read", { name });
1237
+ if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
1238
+ throw new Error("node vault read response is malformed");
1239
+ }
1240
+ return { value: payload.value, version: payload.version };
1241
+ },
1242
+ changes: async (since) => {
1243
+ const payload = await post("changes", { since });
1244
+ if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((name) => typeof name !== "string"))
1245
+ throw new Error("node vault changes response is malformed");
1246
+ return { version: payload.version, changed: payload.changed, resync: payload.resync };
1247
+ }
1248
+ });
1249
+ }
1250
+ function startNodeVaultSync(cache, options = {}) {
1251
+ const interval = Math.max(1000, options.intervalMs ?? 30000);
1252
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1253
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1254
+ let stopped = false;
1255
+ let timer;
1256
+ let active = null;
1257
+ const schedule = () => {
1258
+ if (!stopped)
1259
+ timer = setTimer(tick, interval);
1260
+ };
1261
+ const tick = () => {
1262
+ if (stopped || active)
1263
+ return;
1264
+ active = cache.sync().then((result) => options.onEvent?.("synced", result)).catch((cause) => options.onEvent?.("sync-failed", cause)).finally(() => {
1265
+ active = null;
1266
+ schedule();
1267
+ });
1268
+ };
1269
+ schedule();
1270
+ return {
1271
+ async stop() {
1272
+ stopped = true;
1273
+ clearTimer(timer);
1274
+ await active;
1275
+ }
1276
+ };
1277
+ }
1278
+
1279
+ // src/provisioning-pull.ts
1280
+ class ProvisionClaimLostError extends Error {
1281
+ }
1282
+ var post = (options, operation, body) => postSignedNode(options, `v1/metal/computes/${operation}`, body);
1283
+ async function runClaim(options, claim) {
1284
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1285
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1286
+ const now = options.now ?? Date.now;
1287
+ let expires = claim.claimExpiresAtTs;
1288
+ let stopped = false;
1289
+ let timer;
1290
+ let renewal = null;
1291
+ let lost = null;
1292
+ const schedule = (override) => {
1293
+ if (stopped)
1294
+ return;
1295
+ const delay = override ?? Math.max(1000, Math.floor(Math.max(0, expires - now()) / 3));
1296
+ timer = setTimer(() => {
1297
+ if (stopped || renewal)
1298
+ return;
1299
+ let retry;
1300
+ renewal = post(options, "renew", {
1301
+ computeKey: claim.computeKey,
1302
+ claimToken: claim.claimToken
1303
+ }).then((response) => {
1304
+ expires = response.claimExpiresAtTs;
1305
+ options.onEvent?.("lease-renewed", { computeKey: claim.computeKey, claimExpiresAtTs: expires });
1306
+ }).catch((cause) => {
1307
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
1308
+ lost = new ProvisionClaimLostError(cause.message);
1309
+ stopped = true;
1310
+ } else {
1311
+ retry = Math.max(1000, options.renewRetryMs ?? 5000);
1312
+ options.onEvent?.("lease-renew-failed", cause);
1313
+ }
1314
+ }).finally(() => {
1315
+ renewal = null;
1316
+ if (!stopped)
1317
+ schedule(retry);
1318
+ });
1319
+ }, delay);
1320
+ };
1321
+ schedule();
1322
+ let result;
1323
+ try {
1324
+ result = await options.run(claim);
1325
+ } finally {
1326
+ stopped = true;
1327
+ clearTimer(timer);
1328
+ await renewal;
1329
+ }
1330
+ if (lost)
1331
+ throw lost;
1332
+ return result;
1333
+ }
1334
+ async function complete(options, body) {
1335
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
1336
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1337
+ let last;
1338
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
1339
+ try {
1340
+ await post(options, "complete", body);
1341
+ return;
1342
+ } catch (cause) {
1343
+ last = cause;
1344
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
1345
+ throw cause;
1346
+ if (attempt < attempts)
1347
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
1348
+ }
1349
+ }
1350
+ throw last;
1351
+ }
1352
+ async function pullProvisioningOnce(options) {
1353
+ if (options.metalPreflight) {
1354
+ const report = options.metalPreflight();
1355
+ const accepted = await postSignedNode(options, "v1/metal/preflight", report);
1356
+ options.onEvent?.("preflight", { ...report, ready: accepted.ready, state: accepted.state });
1357
+ if (!accepted.ready)
1358
+ return { status: "idle" };
1359
+ }
1360
+ const response = await post(options, "claim", {});
1361
+ if (!response.claim)
1362
+ return { status: "idle" };
1363
+ const claim = response.claim;
1364
+ let result;
1365
+ try {
1366
+ result = await runClaim(options, claim);
1367
+ } catch (cause) {
1368
+ if (cause instanceof ProvisionClaimLostError)
1369
+ throw cause;
1370
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
1371
+ await complete(options, {
1372
+ computeKey: claim.computeKey,
1373
+ claimToken: claim.claimToken,
1374
+ ok: false,
1375
+ detail: reason
1376
+ });
1377
+ return { status: "failed", claim, reason };
1378
+ }
1379
+ await complete(options, {
1380
+ computeKey: claim.computeKey,
1381
+ claimToken: claim.claimToken,
1382
+ ok: true,
1383
+ ...result.guestAddress ? { guestAddress: result.guestAddress } : {}
1384
+ });
1385
+ return { status: claim.action === "delete" ? "terminated" : "running", claim, result };
1386
+ }
1387
+ function startProvisioningPull(options) {
1388
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
1389
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
1390
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
1391
+ let stopped = false;
1392
+ let timer;
1393
+ let active = null;
1394
+ const tick = () => {
1395
+ if (stopped || active)
1396
+ return;
1397
+ active = pullProvisioningOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
1398
+ active = null;
1399
+ if (!stopped)
1400
+ timer = setTimer(tick, interval);
1401
+ });
1402
+ };
1403
+ tick();
1404
+ return {
1405
+ async stop() {
1406
+ stopped = true;
1407
+ clearTimer(timer);
1408
+ await active;
1409
+ },
1410
+ get active() {
1411
+ return !stopped;
1412
+ }
1413
+ };
1414
+ }
1415
+
1416
+ // src/metal-helper-socket.ts
1417
+ import { chmodSync as chmodSync6, existsSync as existsSync6, unlinkSync as unlinkSync6 } from "fs";
1418
+ import { connect as connect2, createServer as createServer3 } from "net";
1419
+
1420
+ // src/metal-provision.ts
1421
+ import { createHash } from "crypto";
1422
+ import {
1423
+ existsSync as existsSync5,
1424
+ mkdirSync as mkdirSync4,
1425
+ readFileSync as readFileSync4,
1426
+ readdirSync,
1427
+ statSync as statSync2,
1428
+ unlinkSync as unlinkSync5,
1429
+ writeFileSync as writeFileSync4
1430
+ } from "fs";
1431
+ import { dirname as dirname4, isAbsolute, join as join2 } from "path";
1432
+
1433
+ // src/compute.ts
1434
+ class ComputeError extends Error {
1435
+ code;
1436
+ constructor(code, message) {
1437
+ super(message);
1438
+ this.code = code;
1439
+ this.name = "ComputeError";
1440
+ }
1441
+ }
1442
+ function qemuArgv(spec) {
1443
+ if (spec.vcpu < 1 || spec.memoryGib < 1) {
1444
+ throw new ComputeError("BAD_SPEC", "a guest needs at least 1 vCPU and 1 GiB");
1445
+ }
1446
+ const argv = [
1447
+ "/usr/bin/qemu-system-x86_64",
1448
+ "-name",
1449
+ spec.name,
1450
+ "-accel",
1451
+ "kvm",
1452
+ "-cpu",
1453
+ "host",
1454
+ "-m",
1455
+ `${spec.memoryGib}G`,
1456
+ "-smp",
1457
+ String(spec.vcpu)
1458
+ ];
1459
+ if (spec.confidential) {
1460
+ const { cbitpos, reducedPhysBits, policy } = spec.confidential;
1461
+ argv.push("-machine", `q35,confidential-guest-support=snp,memory-backend=ram`, "-object", `memory-backend-memfd,id=ram,size=${spec.memoryGib}G,share=true`, "-object", `sev-snp-guest,id=snp,cbitpos=${cbitpos},reduced-phys-bits=${reducedPhysBits},policy=${policy}`, "-bios", "/usr/share/ovmf/OVMF.fd");
1462
+ } else {
1463
+ argv.push("-machine", "q35");
1464
+ }
1465
+ argv.push("-drive", `file=${spec.disk},format=raw,if=none,id=disk0,cache=none,aio=native`, "-device", "virtio-blk-pci,drive=disk0,iommu_platform=on", "-drive", `file=${spec.seed},format=raw,if=none,id=seed0,readonly=on`, "-device", "virtio-blk-pci,drive=seed0,iommu_platform=on", "-netdev", spec.tap ? `tap,id=net0,ifname=${spec.tap},script=no,downscript=no` : `bridge,id=net0,br=${spec.bridge}`, "-device", `virtio-net-pci,netdev=net0,mac=${spec.mac},iommu_platform=on`, "-display", "none");
1466
+ if (spec.consoleLog)
1467
+ argv.push("-serial", `file:${spec.consoleLog}`);
1468
+ return argv;
1469
+ }
1470
+ function guestUnit(spec) {
1471
+ const argv = qemuArgv(spec).map((part) => /[\s"']/.test(part) ? JSON.stringify(part) : part).join(" ");
1472
+ if (spec.egress && !spec.tap) {
1473
+ throw new ComputeError("BAD_SPEC", "shaped egress needs a stable tap device");
1474
+ }
1475
+ const tapSetup = spec.tap ? [
1476
+ `ExecStartPre=-/usr/sbin/ip link del ${spec.tap}`,
1477
+ `ExecStartPre=/usr/sbin/ip tuntap add dev ${spec.tap} mode tap`,
1478
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} master ${spec.bridge}`,
1479
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} up`,
1480
+ ...spec.egress ? shapeEgressUnitDirectives(spec.tap, spec.egress.guaranteedMbps, spec.egress.burstMbps) : [],
1481
+ `ExecStopPost=-/usr/sbin/ip link del ${spec.tap}`
1482
+ ].join(`
1483
+ `) : "";
1484
+ return `[Unit]
1485
+ Description=ForgeZero guest ${spec.name}
1486
+ Documentation=https://www.forgezero.net
1487
+ After=network-online.target
1488
+ Wants=network-online.target
1489
+
1490
+ [Service]
1491
+ Type=simple
1492
+ Slice=forgezero-guests.slice
1493
+ ${tapSetup}
1494
+ ExecStart=${argv}
1495
+ Restart=always
1496
+ RestartSec=5
1497
+ ${spec.allowedCpus ? `AllowedCPUs=${spec.allowedCpus}
1498
+ ` : ""}${spec.allowedMemoryNodes ? `AllowedMemoryNodes=${spec.allowedMemoryNodes}
1499
+ ` : ""}# The affinity belongs to the unit rather than only QEMU's vCPU threads. Its
1500
+ # emulator and IO threads can otherwise run on a different tenant's cores.
1501
+ # The agent is NOT the parent. Restarting or upgrading fz-agent must never stop
1502
+ # a tenant's compute, which is the whole reason this is a unit rather than a
1503
+ # child process.
1504
+ KillMode=mixed
1505
+ TimeoutStopSec=120
1506
+
1507
+ [Install]
1508
+ WantedBy=multi-user.target
1509
+ `;
1510
+ }
1511
+ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
1512
+ if (!/^[a-z][a-z0-9]{0,14}$/.test(tap)) {
1513
+ throw new ComputeError("BAD_SPEC", `"${tap}" is not a device name`);
1514
+ }
1515
+ if (!Number.isInteger(guaranteedMbps) || guaranteedMbps < 0 || !Number.isInteger(burstMbps) || burstMbps < guaranteedMbps) {
1516
+ throw new ComputeError("BAD_SPEC", "egress rates must be whole numbers and burst must cover the guarantee");
1517
+ }
1518
+ const clear = [
1519
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} root`,
1520
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} ingress`
1521
+ ];
1522
+ if (guaranteedMbps === 0)
1523
+ return clear;
1524
+ return [
1525
+ ...clear,
1526
+ `ExecStartPre=/usr/sbin/tc qdisc add dev ${tap} handle ffff: ingress`,
1527
+ `ExecStartPre=/usr/sbin/tc filter add dev ${tap} parent ffff: protocol all u32 match u32 0 0 action police rate ${burstMbps}mbit burst 16mb conform-exceed drop`
1528
+ ];
1529
+ }
1530
+
1531
+ // src/provision.ts
1532
+ var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
1533
+ var DEPLOYMENT_GROUP = "forgezero-deploy";
1534
+ var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
1535
+ function deploymentRunnerSocketUnit(agentUser) {
1536
+ return `[Unit]
1537
+ Description=ForgeZero private project-command socket
1538
+
1539
+ [Socket]
1540
+ ListenStream=${DEPLOYMENT_RUNNER_SOCKET}
1541
+ SocketUser=${agentUser}
1542
+ SocketGroup=${agentUser}
1543
+ SocketMode=0600
1544
+ DirectoryMode=0710
1545
+ RemoveOnStop=true
1546
+
1547
+ [Install]
1548
+ WantedBy=sockets.target
1549
+ `;
1550
+ }
1551
+ function deploymentRunnerUnit(options) {
1552
+ const bin = options.binPath ?? "fz-agent";
1553
+ const root = options.deployRoot ?? "/opt/forgezero";
1554
+ return `[Unit]
1555
+ Description=ForgeZero credential-free project command runner
1556
+ Documentation=https://www.forgezero.net/docs/agent
1557
+ After=forgezero-deploy-runner.socket
1558
+ Requires=forgezero-deploy-runner.socket
1559
+
1560
+ [Service]
1561
+ Type=simple
1562
+ User=${DEPLOYMENT_RUNNER_USER}
1563
+ Group=${DEPLOYMENT_GROUP}
1564
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1565
+ Sockets=forgezero-deploy-runner.socket
1566
+ ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
1567
+ Restart=always
1568
+ RestartSec=2
1569
+ UMask=0007
1570
+ LimitCORE=0
1571
+ NoNewPrivileges=false
1572
+ PrivateTmp=true
1573
+ ProtectSystem=strict
1574
+ ProtectHome=true
1575
+ ProtectKernelTunables=true
1576
+ ProtectKernelModules=true
1577
+ ProtectControlGroups=true
1578
+ RestrictRealtime=true
1579
+ MemoryDenyWriteExecute=true
1580
+ LockPersonality=true
1581
+ ReadWritePaths=${root}/releases ${root}/runner-home
1582
+
1583
+ [Install]
1584
+ WantedBy=multi-user.target
1585
+ `;
1586
+ }
1587
+
1588
+ // src/metal-provision.ts
1589
+ var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
1590
+ var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
1591
+ var SHA256 = /^[a-f0-9]{64}$/;
1592
+ var IPV4_PREFIX = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
1593
+ var LINUX_LIST = /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/;
1594
+
1595
+ class MetalProvisionError extends Error {
1596
+ }
1597
+ function membersOfLinuxList(value, label) {
1598
+ if (!LINUX_LIST.test(value))
1599
+ throw new MetalProvisionError(`invalid ${label} list`);
1600
+ const members = [];
1601
+ for (const part of value.split(",")) {
1602
+ const [startText, endText = startText] = part.split("-");
1603
+ const start = Number(startText);
1604
+ const end = Number(endText);
1605
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > 65535) {
1606
+ throw new MetalProvisionError(`invalid ${label} list`);
1607
+ }
1608
+ for (let value2 = start;value2 <= end; value2 += 1)
1609
+ members.push(value2);
1610
+ }
1611
+ if (new Set(members).size !== members.length)
1612
+ throw new MetalProvisionError(`${label} list overlaps itself`);
1613
+ return members;
1614
+ }
1615
+ var guestNameFor = (computeKey) => `fzg-${createHash("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
1616
+ var tapNameFor = (computeKey) => `fzt${createHash("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
1617
+ var macForAddress = (address) => {
1618
+ const octets = address.split(".").map(Number);
1619
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
1620
+ throw new MetalProvisionError("invalid guest address");
1621
+ }
1622
+ return `52:54:00:f0:${octets[2].toString(16).padStart(2, "0")}:${octets[3].toString(16).padStart(2, "0")}`;
1623
+ };
1624
+ function validateMetalProfile(profile) {
1625
+ if (!SAFE_NAME.test(profile.volumeGroup))
1626
+ throw new MetalProvisionError("invalid volume group");
1627
+ if (!DEVICE.test(profile.bridge))
1628
+ throw new MetalProvisionError("invalid bridge");
1629
+ if (!IPV4_PREFIX.test(profile.subnetPrefix))
1630
+ throw new MetalProvisionError("invalid subnet prefix");
1631
+ if (!Number.isInteger(profile.addressStart) || !Number.isInteger(profile.addressEnd) || profile.addressStart < 2 || profile.addressEnd > 254 || profile.addressStart > profile.addressEnd)
1632
+ throw new MetalProvisionError("invalid guest address range");
1633
+ for (const path of [profile.stateDir, profile.seedDir, profile.unitDir]) {
1634
+ if (!isAbsolute(path))
1635
+ throw new MetalProvisionError("metal paths must be absolute");
1636
+ }
1637
+ new URL(profile.apiUrl);
1638
+ if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
1639
+ throw new MetalProvisionError("at least one exclusive CPU pool is required");
1640
+ }
1641
+ const keys = new Set;
1642
+ const assigned = new Set;
1643
+ const assignedMemory = new Set;
1644
+ let poolsWithMemory = 0;
1645
+ for (const pool of profile.cpuPools) {
1646
+ if (!SAFE_NAME.test(pool.key) || keys.has(pool.key))
1647
+ throw new MetalProvisionError("invalid or duplicate CPU pool key");
1648
+ keys.add(pool.key);
1649
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
1650
+ if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
1651
+ throw new MetalProvisionError("invalid CPU pool physical-core count");
1652
+ }
1653
+ for (const cpu of cpus) {
1654
+ if (assigned.has(cpu))
1655
+ throw new MetalProvisionError("CPU pools overlap");
1656
+ assigned.add(cpu);
1657
+ }
1658
+ if (pool.memoryNodes) {
1659
+ poolsWithMemory += 1;
1660
+ for (const node of membersOfLinuxList(pool.memoryNodes, "memory-node")) {
1661
+ if (assignedMemory.has(node))
1662
+ throw new MetalProvisionError("guest memory-node pools overlap");
1663
+ assignedMemory.add(node);
1664
+ }
1665
+ }
1666
+ }
1667
+ if (poolsWithMemory !== 0 && poolsWithMemory !== profile.cpuPools.length) {
1668
+ throw new MetalProvisionError("every CPU pool must name memory nodes when NUMA isolation is enabled");
1669
+ }
1670
+ const housekeeping = membersOfLinuxList(profile.housekeepingCpus, "housekeeping CPU");
1671
+ if (housekeeping.some((cpu) => assigned.has(cpu))) {
1672
+ throw new MetalProvisionError("housekeeping CPUs overlap guest CPU pools");
1673
+ }
1674
+ if (profile.housekeepingMemoryNodes) {
1675
+ const housekeepingMemory = membersOfLinuxList(profile.housekeepingMemoryNodes, "housekeeping memory-node");
1676
+ if (housekeepingMemory.some((node) => assignedMemory.has(node))) {
1677
+ throw new MetalProvisionError("housekeeping memory nodes overlap guest memory-node pools");
1678
+ }
1679
+ } else if (assignedMemory.size > 0) {
1680
+ throw new MetalProvisionError("NUMA-isolated guest pools require housekeeping memory nodes");
1681
+ }
1682
+ }
1683
+ var readManifests = (stateDir) => {
1684
+ if (!existsSync5(stateDir))
1685
+ return [];
1686
+ return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync4(join2(stateDir, name), "utf8")));
1687
+ };
1688
+ function allocateAddress(profile, computeKey, rows) {
1689
+ const existing = rows.find((row) => row.computeKey === computeKey);
1690
+ if (existing)
1691
+ return existing.address;
1692
+ const used = new Set(rows.map((row) => row.address));
1693
+ const width = profile.addressEnd - profile.addressStart + 1;
1694
+ const start = createHash("sha256").update(computeKey).digest().readUInt16BE(0) % width;
1695
+ for (let offset = 0;offset < width; offset += 1) {
1696
+ const last = profile.addressStart + (start + offset) % width;
1697
+ const address = `${profile.subnetPrefix}.${last}`;
1698
+ if (!used.has(address))
1699
+ return address;
1700
+ }
1701
+ throw new MetalProvisionError("guest address range is full");
1702
+ }
1703
+ function allocateCpuPool(profile, claim, rows) {
1704
+ const prior = rows.find((row) => row.computeKey === claim.computeKey);
1705
+ if (prior) {
1706
+ const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
1707
+ if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
1708
+ throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
1709
+ }
1710
+ return retained;
1711
+ }
1712
+ const used = new Set(rows.map((row) => row.cpuPoolKey));
1713
+ 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));
1714
+ const selected = candidates[0];
1715
+ if (!selected)
1716
+ throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
1717
+ return selected;
1718
+ }
1719
+ var base64 = (value) => Buffer.from(value).toString("base64");
1720
+ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)}
1721
+ permissions: '${permissions}'
1722
+ encoding: b64
1723
+ content: ${base64(content)}
1724
+ `;
1725
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
1726
+ const agentBun = "/usr/local/lib/forgezero/bun";
1727
+ const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
1728
+ # healthy and encrypted while attestation is silently impossible. Install and
1729
+ # load the exact running-kernel module before the agent is allowed to start.
1730
+ DEBIAN_FRONTEND=noninteractive apt-get install -y linux-image-generic "linux-modules-extra-$(uname -r)"
1731
+ printf 'sev-guest
1732
+ ' >/etc/modules-load.d/sev-guest.conf
1733
+ modprobe sev-guest
1734
+ test -c /dev/sev-guest
1735
+ ` : "";
1736
+ return `#!/usr/bin/env bash
1737
+ set -Eeuo pipefail
1738
+ ${attestationSetup}useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
1739
+ groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
1740
+ useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
1741
+ usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
1742
+ install -d -o root -g root -m 0700 /etc/forgezero/creds
1743
+ install -d -o root -g root -m 0755 /etc/forgezero/git
1744
+ install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
1745
+ install -d -o root -g root -m 0755 /opt/forgezero
1746
+ install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
1747
+ install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
1748
+ install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
1749
+ if [[ -s /run/forgezero-enrol-token ]]; then
1750
+ systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
1751
+ rm -f /run/forgezero-enrol-token
1752
+ fi
1753
+ chown root:root /var/lib/forgezero/enrol-token.cred
1754
+ chmod 0400 /var/lib/forgezero/enrol-token.cred
1755
+ if [[ ! -x /usr/local/bin/bun ]]; then
1756
+ curl -fsSL https://bun.sh/install -o /run/fz-bun-install
1757
+ printf '%s %s
1758
+ ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
1759
+ BUN_INSTALL=${agentBun} BUN_VERSION=${profile.bunVersion} bash /run/fz-bun-install
1760
+ install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
1761
+ rm -f /run/fz-bun-install
1762
+ fi
1763
+ if [[ ! -x /usr/local/bin/fz-agent ]]; then
1764
+ env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g @forgezero/agent@${profile.agentVersion}
1765
+ ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
1766
+ fi
1767
+ if [[ ! -s /etc/forgezero/creds/agent-seed.cred ]]; then
1768
+ umask 077
1769
+ openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
1770
+ ' >/run/fz-agent-seed
1771
+ systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
1772
+ rm -f /run/fz-agent-seed
1773
+ fi
1774
+ chmod 0400 /etc/forgezero/creds/agent-seed.cred
1775
+ if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
1776
+ umask 077
1777
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1778
+ ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
1779
+ systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
1780
+ install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1781
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1782
+ fi
1783
+ if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
1784
+ systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
1785
+ ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
1786
+ install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
1787
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
1788
+ fi
1789
+ chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
1790
+ systemctl daemon-reload
1791
+ systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
1792
+ `;
1793
+ }
1794
+ function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
1795
+ const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp forgezero-agent /dev/sev-guest
1796
+ ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1797
+ ` : "";
1798
+ const attestationDevice = attested ? `DevicePolicy=closed
1799
+ DeviceAllow=/dev/sev-guest rw
1800
+ ` : "";
1801
+ return `[Unit]
1802
+ Description=ForgeZero compute agent (${name})
1803
+ After=network-online.target forgezero-deploy-runner.service
1804
+ Wants=network-online.target
1805
+ Requires=forgezero-deploy-runner.service
1806
+
1807
+ [Service]
1808
+ Type=simple
1809
+ User=forgezero-agent
1810
+ Group=forgezero-agent
1811
+ SupplementaryGroups=${DEPLOYMENT_GROUP}
1812
+ LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
1813
+ LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
1814
+ Environment=FZ_SEED_CREDENTIAL=agent-seed
1815
+ Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
1816
+ Environment=FZ_API=${profile.apiUrl}
1817
+ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
1818
+ Environment=FZ_NODE_LABEL=${name}
1819
+ Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
1820
+ Environment=FZ_DEPLOY_ROOT=/opt/forgezero
1821
+ Environment=FZ_DEPLOY_PULL=true
1822
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
1823
+ Environment=HOME=/opt/forgezero/home
1824
+ ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
1825
+ Restart=on-failure
1826
+ RestartSec=5
1827
+ RuntimeDirectory=forgezero
1828
+ RuntimeDirectoryMode=0710
1829
+ UMask=0077
1830
+ LimitCORE=0
1831
+ NoNewPrivileges=true
1832
+ PrivateTmp=true
1833
+ ProtectSystem=strict
1834
+ ProtectHome=true
1835
+ ReadWritePaths=/var/lib/forgezero /opt/forgezero
1836
+ ${attestationDevice}
1837
+
1838
+ [Install]
1839
+ WantedBy=multi-user.target
1840
+ `;
1841
+ }
1842
+ function guestEnrolmentDropIn() {
1843
+ return `[Service]
1844
+ LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
1845
+ Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
1846
+ `;
1847
+ }
1848
+ function guestEnrolmentCleanupScript() {
1849
+ return `#!/usr/bin/env bash
1850
+ set -Eeuo pipefail
1851
+ for _ in $(seq 1 180); do
1852
+ [[ -s /var/lib/forgezero/enrolment.json ]] && break
1853
+ sleep 1
1854
+ done
1855
+ [[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
1856
+ rm -f /var/lib/forgezero/enrol-token.cred
1857
+ rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
1858
+ systemctl daemon-reload
1859
+ systemctl disable forgezero-enrolment-cleanup.service
1860
+ `;
1861
+ }
1862
+ function guestEnrolmentCleanupUnit() {
1863
+ return `[Unit]
1864
+ Description=Remove the consumed ForgeZero guest enrolment credential
1865
+ After=forgezero-agent.service
1866
+ Requires=forgezero-agent.service
1867
+ ConditionPathExists=/var/lib/forgezero/enrol-token.cred
1868
+
1869
+ [Service]
1870
+ Type=oneshot
1871
+ ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
1872
+ TimeoutStartSec=4min
1873
+
1874
+ [Install]
1875
+ WantedBy=multi-user.target
1876
+ `;
1877
+ }
1878
+ function cloudInit(profile, claim, manifest) {
1879
+ const bootstrap = guestBootstrapScript(profile, claim.spec.confidential);
1880
+ const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential);
1881
+ const enrolmentDropIn = guestEnrolmentDropIn();
1882
+ const cleanupScript = guestEnrolmentCleanupScript();
1883
+ const cleanupUnit = guestEnrolmentCleanupUnit();
1884
+ const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
1885
+ const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
1886
+ return {
1887
+ userData: `#cloud-config
1888
+ package_update: true
1889
+ # Git is part of the deployment transport, not a tenant-selected prerequisite:
1890
+ # every dynamically claimed repository must be cloneable on a clean image.
1891
+ packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
1892
+ write_files:
1893
+ ${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
1894
+ `, "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:
1895
+ - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
1896
+ - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
1897
+ `,
1898
+ metaData: `instance-id: ${manifest.name}-${claim.attempt}
1899
+ local-hostname: ${manifest.name}
1900
+ `,
1901
+ networkConfig: `version: 2
1902
+ ethernets:
1903
+ primary:
1904
+ match: { name: "en*" }
1905
+ addresses: [ ${manifest.address}/24 ]
1906
+ gateway4: ${profile.gateway}
1907
+ nameservers: { addresses: [${(profile.nameservers ?? ["1.1.1.1", "9.9.9.9"]).join(", ")}] }
1908
+ `
1909
+ };
1910
+ }
1911
+ var checked = async (exec, argv) => {
1912
+ const result = await exec(argv);
1913
+ if (result.exitCode !== 0) {
1914
+ throw new MetalProvisionError(`${argv[0]} failed: ${(result.stderr || result.stdout).trim()}`);
1915
+ }
1916
+ return result;
1917
+ };
1918
+ async function provisionMetalGuest(profile, claim, exec) {
1919
+ validateMetalProfile(profile);
1920
+ 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)
1921
+ throw new MetalProvisionError("invalid compute claim");
1922
+ const image = profile.images[claim.spec.imageKey];
1923
+ if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
1924
+ throw new MetalProvisionError(`image ${claim.spec.imageKey} is not configured locally`);
1925
+ }
1926
+ if (!statSync2(image.path).isFile())
1927
+ throw new MetalProvisionError("configured image is not a file");
1928
+ const digest = (await checked(exec, ["sha256sum", image.path])).stdout.trim().split(/\s+/)[0];
1929
+ if (digest !== image.sha256)
1930
+ throw new MetalProvisionError("configured image checksum mismatch");
1931
+ for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
1932
+ mkdirSync4(path, { recursive: true, mode: 448 });
1933
+ const manifests = readManifests(profile.stateDir);
1934
+ const name = guestNameFor(claim.computeKey);
1935
+ const manifestPath = join2(profile.stateDir, `${name}.json`);
1936
+ const prior = manifests.find((row) => row.computeKey === claim.computeKey);
1937
+ if (prior && prior.reference !== claim.spec.reference)
1938
+ throw new MetalProvisionError("compute identity conflicts with host inventory");
1939
+ const address = allocateAddress(profile, claim.computeKey, manifests);
1940
+ const cpuPool = allocateCpuPool(profile, claim, manifests);
1941
+ const manifest = prior ?? {
1942
+ computeKey: claim.computeKey,
1943
+ reference: claim.spec.reference,
1944
+ name,
1945
+ address,
1946
+ mac: macForAddress(address),
1947
+ cpuPoolKey: cpuPool.key,
1948
+ allowedCpus: cpuPool.cpus,
1949
+ allowedMemoryNodes: cpuPool.memoryNodes,
1950
+ phase: "allocating"
1951
+ };
1952
+ const save = () => writeFileSync4(manifestPath, `${JSON.stringify(manifest, null, 2)}
1953
+ `, { mode: 384 });
1954
+ save();
1955
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
1956
+ const exists = (await exec(["lvs", "--noheadings", lv])).exitCode === 0;
1957
+ if (!exists)
1958
+ await checked(exec, ["lvcreate", "-y", "-n", name, "-L", `${claim.spec.diskGib}G`, profile.volumeGroup]);
1959
+ if (manifest.phase === "allocating") {
1960
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, lv]);
1961
+ manifest.phase = "image-ready";
1962
+ save();
1963
+ }
1964
+ const seedBase = join2(profile.seedDir, name);
1965
+ const init = cloudInit(profile, claim, manifest);
1966
+ writeFileSync4(`${seedBase}-user-data`, init.userData, { mode: 384 });
1967
+ writeFileSync4(`${seedBase}-meta-data`, init.metaData, { mode: 384 });
1968
+ writeFileSync4(`${seedBase}-network-config`, init.networkConfig, { mode: 384 });
1969
+ const seed = `${seedBase}-seed.iso`;
1970
+ await checked(exec, [
1971
+ "cloud-localds",
1972
+ "-N",
1973
+ `${seedBase}-network-config`,
1974
+ seed,
1975
+ `${seedBase}-user-data`,
1976
+ `${seedBase}-meta-data`
1977
+ ]);
1978
+ const spec = {
1979
+ name,
1980
+ vcpu: claim.spec.vcpu,
1981
+ memoryGib: claim.spec.memoryGib,
1982
+ allowedCpus: manifest.allowedCpus,
1983
+ allowedMemoryNodes: manifest.allowedMemoryNodes,
1984
+ disk: lv,
1985
+ seed,
1986
+ bridge: profile.bridge,
1987
+ mac: manifest.mac,
1988
+ tap: tapNameFor(claim.computeKey),
1989
+ egress: {
1990
+ guaranteedMbps: claim.spec.egressGuaranteedMbps,
1991
+ burstMbps: claim.spec.egressBurstMbps
1992
+ },
1993
+ confidential: claim.spec.confidential ? profile.confidential : undefined,
1994
+ consoleLog: `/var/log/forgezero/${name}.log`
1995
+ };
1996
+ if (claim.spec.confidential && !spec.confidential) {
1997
+ throw new MetalProvisionError("confidential compute requested but host SNP profile is absent");
1998
+ }
1999
+ const service = `forgezero-guest@${name}.service`;
2000
+ const unitPath = join2(profile.unitDir, service);
2001
+ mkdirSync4(dirname4(unitPath), { recursive: true });
2002
+ writeFileSync4(unitPath, guestUnit(spec), { mode: 420 });
2003
+ await checked(exec, ["systemctl", "daemon-reload"]);
2004
+ if (prior?.phase === "running") {
2005
+ await checked(exec, ["systemctl", "restart", service]);
2006
+ } else {
2007
+ await checked(exec, ["systemctl", "enable", "--now", service]);
2008
+ }
2009
+ await checked(exec, ["systemctl", "is-active", service]);
2010
+ manifest.phase = "running";
2011
+ save();
2012
+ return { guestAddress: address };
2013
+ }
2014
+ async function removeMetalGuest(profile, claim, exec) {
2015
+ validateMetalProfile(profile);
2016
+ if (claim.action !== "delete")
2017
+ throw new MetalProvisionError("create claim cannot remove a guest");
2018
+ const name = guestNameFor(claim.computeKey);
2019
+ const manifestPath = join2(profile.stateDir, `${name}.json`);
2020
+ if (!existsSync5(manifestPath))
2021
+ return {};
2022
+ const manifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
2023
+ if (manifest.computeKey !== claim.computeKey || manifest.reference !== claim.spec.reference || manifest.name !== name) {
2024
+ throw new MetalProvisionError("compute identity conflicts with host inventory");
2025
+ }
2026
+ const service = `forgezero-guest@${name}.service`;
2027
+ const unitPath = join2(profile.unitDir, service);
2028
+ if (existsSync5(unitPath))
2029
+ await checked(exec, ["systemctl", "disable", "--now", service]);
2030
+ else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
2031
+ throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
2032
+ }
2033
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
2034
+ if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
2035
+ await checked(exec, ["lvremove", "-fy", lv]);
2036
+ for (const path of [
2037
+ unitPath,
2038
+ join2(profile.seedDir, `${name}-seed.iso`),
2039
+ join2(profile.seedDir, `${name}-user-data`),
2040
+ join2(profile.seedDir, `${name}-meta-data`),
2041
+ join2(profile.seedDir, `${name}-network-config`),
2042
+ manifestPath
2043
+ ])
2044
+ if (existsSync5(path))
2045
+ unlinkSync5(path);
2046
+ await checked(exec, ["systemctl", "daemon-reload"]);
2047
+ return {};
2048
+ }
2049
+
2050
+ // src/metal-helper-socket.ts
2051
+ var DEFAULT_METAL_HELPER_SOCKET = "/run/forgezero-metal/helper.sock";
2052
+ var MAX_REQUEST_BYTES2 = 32 * 1024;
2053
+ var REQUEST_READ_TIMEOUT_MS = 5000;
2054
+ var COMMAND_TIMEOUT_MS = 30 * 60000;
2055
+ var spawnMetalCommand = async (argv) => {
2056
+ const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" } });
2057
+ let timedOut = false;
2058
+ let forceTimer;
2059
+ const timer = setTimeout(() => {
2060
+ timedOut = true;
2061
+ child.kill("SIGTERM");
2062
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), 5000);
2063
+ }, COMMAND_TIMEOUT_MS);
2064
+ const [stdout, stderr, exitCode] = await Promise.all([
2065
+ new Response(child.stdout).text(),
2066
+ new Response(child.stderr).text(),
2067
+ child.exited
2068
+ ]);
2069
+ clearTimeout(timer);
2070
+ if (forceTimer)
2071
+ clearTimeout(forceTimer);
2072
+ return {
2073
+ exitCode: timedOut ? 124 : exitCode,
2074
+ stdout,
2075
+ stderr: timedOut ? `${stderr}
2076
+ command exceeded ${COMMAND_TIMEOUT_MS}ms`.trim() : stderr
2077
+ };
2078
+ };
2079
+ function startMetalHelper(options) {
2080
+ validateMetalProfile(options.profile);
2081
+ const socketPath = options.socketPath ?? DEFAULT_METAL_HELPER_SOCKET;
2082
+ if (existsSync6(socketPath))
2083
+ unlinkSync6(socketPath);
2084
+ let tail = Promise.resolve();
2085
+ const server = createServer3((socket) => {
2086
+ let buffer = "";
2087
+ socket.setTimeout(REQUEST_READ_TIMEOUT_MS, () => {
2088
+ socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
2089
+ `);
2090
+ });
2091
+ socket.on("data", (chunk) => {
2092
+ buffer += chunk.toString("utf8");
2093
+ if (buffer.length > MAX_REQUEST_BYTES2) {
2094
+ socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
2095
+ `);
2096
+ return;
2097
+ }
2098
+ const newline = buffer.indexOf(`
2099
+ `);
2100
+ if (newline < 0)
2101
+ return;
2102
+ socket.setTimeout(0);
2103
+ const line = buffer.slice(0, newline);
2104
+ buffer = "";
2105
+ const work = async () => {
2106
+ let request;
2107
+ try {
2108
+ request = JSON.parse(line);
2109
+ } catch {
2110
+ return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
2111
+ }
2112
+ if (request?.op !== "apply" || !request.claim) {
2113
+ return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
2114
+ }
2115
+ try {
2116
+ const exec = options.exec ?? spawnMetalCommand;
2117
+ return { ok: true, result: request.claim.action === "delete" ? await removeMetalGuest(options.profile, request.claim, exec) : await provisionMetalGuest(options.profile, request.claim, exec) };
2118
+ } catch (cause) {
2119
+ return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "provisioning failed" } };
2120
+ }
2121
+ };
2122
+ const response = tail.then(work, work);
2123
+ tail = response;
2124
+ response.then((value) => socket.end(`${JSON.stringify(value)}
2125
+ `));
2126
+ });
2127
+ socket.on("error", () => socket.destroy());
2128
+ });
2129
+ server.listen(socketPath, () => chmodSync6(socketPath, 432));
2130
+ return {
2131
+ server,
2132
+ async stop() {
2133
+ await new Promise((resolve) => server.close(() => resolve()));
2134
+ await tail;
2135
+ }
2136
+ };
2137
+ }
2138
+ function requestMetalProvision(claim, socketPath = DEFAULT_METAL_HELPER_SOCKET) {
2139
+ return new Promise((resolve, reject) => {
2140
+ const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
2141
+ `));
2142
+ socket.setTimeout(COMMAND_TIMEOUT_MS + 1e4, () => {
2143
+ socket.destroy();
2144
+ reject(new Error("metal helper response timed out"));
2145
+ });
2146
+ let buffer = "";
2147
+ socket.on("data", (chunk) => {
2148
+ buffer += chunk.toString("utf8");
2149
+ const newline = buffer.indexOf(`
2150
+ `);
2151
+ if (newline < 0)
2152
+ return;
2153
+ socket.end();
2154
+ try {
2155
+ const response = JSON.parse(buffer.slice(0, newline));
2156
+ if (response.ok)
2157
+ resolve(response.result);
2158
+ else
2159
+ reject(new Error(response.error.message));
2160
+ } catch (cause) {
2161
+ reject(cause);
2162
+ }
2163
+ });
2164
+ socket.on("error", reject);
2165
+ });
2166
+ }
2167
+
2168
+ // src/deployment-runner.ts
2169
+ import { chmodSync as chmodSync7, existsSync as existsSync7, realpathSync, unlinkSync as unlinkSync7 } from "fs";
2170
+ import { isAbsolute as isAbsolute2, resolve, sep } from "path";
2171
+ import { connect as connect3, createServer as createServer4 } from "net";
2172
+ var DEFAULT_DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
2173
+ var MAX_REQUEST_BYTES3 = 256 * 1024;
2174
+ var MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
2175
+ var MAX_RESPONSE_BYTES = 2 * MAX_OUTPUT_BYTES + MAX_REQUEST_BYTES3;
2176
+ var READ_TIMEOUT_MS = 5000;
2177
+ var MAX_COMMAND_TIMEOUT_MS = 60 * 60000;
2178
+ function within(root, candidate) {
2179
+ return candidate === root || candidate.startsWith(`${root}${sep}`);
2180
+ }
2181
+ function validate(root, input) {
2182
+ if (!input || typeof input.command !== "string" || input.command.length < 1 || input.command.length > 64 * 1024) {
2183
+ throw new Error("invalid deployment command");
2184
+ }
2185
+ if (!input.cwd || !isAbsolute2(input.cwd))
2186
+ throw new Error("deployment command needs an absolute working directory");
2187
+ const realRoot = realpathSync(root);
2188
+ const realCwd = realpathSync(input.cwd);
2189
+ if (!within(realRoot, realCwd))
2190
+ throw new Error("deployment command escaped the release root");
2191
+ const timeoutMs = Math.trunc(input.timeoutMs ?? 10 * 60000);
2192
+ if (timeoutMs < 1000 || timeoutMs > MAX_COMMAND_TIMEOUT_MS)
2193
+ throw new Error("invalid deployment command timeout");
2194
+ const env = {};
2195
+ let environmentBytes = 0;
2196
+ for (const [name, value] of Object.entries(input.env ?? {})) {
2197
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string") {
2198
+ throw new Error("invalid deployment environment");
2199
+ }
2200
+ environmentBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
2201
+ if (environmentBytes > 128 * 1024)
2202
+ throw new Error("deployment environment is too large");
2203
+ env[name] = value;
2204
+ }
2205
+ return { command: input.command, cwd: realCwd, env, timeoutMs };
2206
+ }
2207
+ async function limited(stream) {
2208
+ const reader = stream.getReader();
2209
+ const decoder = new TextDecoder;
2210
+ let output = "";
2211
+ let remaining = MAX_OUTPUT_BYTES;
2212
+ for (;; ) {
2213
+ const { done, value } = await reader.read();
2214
+ if (done)
2215
+ break;
2216
+ if (remaining > 0) {
2217
+ const chunk = value.byteLength <= remaining ? value : value.subarray(0, remaining);
2218
+ output += decoder.decode(chunk, { stream: true });
2219
+ remaining -= chunk.byteLength;
2220
+ }
2221
+ }
2222
+ output += decoder.decode();
2223
+ return remaining === 0 ? `${output}
2224
+ [output truncated at ${MAX_OUTPUT_BYTES} bytes]` : output;
2225
+ }
2226
+ async function execute(input, home) {
2227
+ const child = Bun.spawn(["bash", "-Eeuo", "pipefail", "-c", input.command], {
2228
+ cwd: input.cwd,
2229
+ detached: true,
2230
+ stdout: "pipe",
2231
+ stderr: "pipe",
2232
+ env: {
2233
+ PATH: "/usr/local/bin:/usr/bin:/bin",
2234
+ HOME: home,
2235
+ XDG_CACHE_HOME: `${home}/cache`,
2236
+ LANG: "C.UTF-8",
2237
+ ...input.env ?? {}
2238
+ }
2239
+ });
2240
+ let timedOut = false;
2241
+ let forceTimer;
2242
+ const killGroup = (signal) => {
2243
+ try {
2244
+ process.kill(-child.pid, signal);
2245
+ } catch (cause) {
2246
+ if (cause.code !== "ESRCH")
2247
+ throw cause;
2248
+ }
2249
+ };
2250
+ const timer = setTimeout(() => {
2251
+ timedOut = true;
2252
+ killGroup("SIGTERM");
2253
+ forceTimer = setTimeout(() => killGroup("SIGKILL"), 2000);
2254
+ }, input.timeoutMs);
2255
+ const [stdout, stderr, exitCode] = await Promise.all([
2256
+ limited(child.stdout),
2257
+ limited(child.stderr),
2258
+ child.exited
2259
+ ]);
2260
+ clearTimeout(timer);
2261
+ if (forceTimer)
2262
+ clearTimeout(forceTimer);
2263
+ return {
2264
+ exitCode: timedOut ? 124 : exitCode,
2265
+ output: `${stdout}${stderr}${timedOut ? `
2266
+ command exceeded ${input.timeoutMs}ms` : ""}`
2267
+ };
2268
+ }
2269
+ function startDeploymentRunner(options) {
2270
+ const root = resolve(options.root);
2271
+ const home = resolve(options.home);
2272
+ const socketPath = options.socketPath ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET;
2273
+ if (options.listenFd === undefined && existsSync7(socketPath))
2274
+ unlinkSync7(socketPath);
2275
+ const active = new Set;
2276
+ const server = createServer4((socket) => {
2277
+ let buffer = "";
2278
+ socket.setTimeout(READ_TIMEOUT_MS, () => socket.end(`${JSON.stringify({
2279
+ ok: false,
2280
+ error: { code: "REFUSED", message: "request timed out" }
2281
+ })}
2282
+ `));
2283
+ socket.on("data", (chunk) => {
2284
+ buffer += chunk.toString("utf8");
2285
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_BYTES3) {
2286
+ socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
2287
+ `);
2288
+ return;
2289
+ }
2290
+ const newline = buffer.indexOf(`
2291
+ `);
2292
+ if (newline < 0)
2293
+ return;
2294
+ socket.setTimeout(0);
2295
+ const line = buffer.slice(0, newline);
2296
+ buffer = "";
2297
+ const work = (async () => {
2298
+ try {
2299
+ const input = validate(root, JSON.parse(line));
2300
+ return { ok: true, result: await (options.exec ?? execute)(input, home) };
2301
+ } catch (cause) {
2302
+ return {
2303
+ ok: false,
2304
+ error: { code: "REFUSED", message: cause instanceof Error ? cause.message : "deployment command refused" }
2305
+ };
2306
+ }
2307
+ })();
2308
+ active.add(work);
2309
+ work.finally(() => active.delete(work));
2310
+ work.then((response) => socket.end(`${JSON.stringify(response)}
2311
+ `));
2312
+ });
2313
+ socket.on("error", () => socket.destroy());
2314
+ });
2315
+ if (options.listenFd !== undefined)
2316
+ server.listen({ fd: options.listenFd });
2317
+ else
2318
+ server.listen(socketPath, () => chmodSync7(socketPath, 432));
2319
+ return {
2320
+ server,
2321
+ async stop() {
2322
+ await new Promise((resolveStop) => server.close(() => resolveStop()));
2323
+ await Promise.all(active);
2324
+ }
2325
+ };
2326
+ }
2327
+ function requestDeploymentCommand(input, socketPath = DEFAULT_DEPLOYMENT_RUNNER_SOCKET) {
2328
+ return new Promise((resolveRequest, reject) => {
2329
+ const socket = connect3(socketPath, () => socket.write(`${JSON.stringify(input)}
2330
+ `));
2331
+ const timeout = Math.max(1000, input.timeoutMs ?? 10 * 60000) + 1e4;
2332
+ socket.setTimeout(timeout, () => {
2333
+ socket.destroy();
2334
+ reject(new Error("deployment runner response timed out"));
2335
+ });
2336
+ let buffer = "";
2337
+ socket.on("data", (chunk) => {
2338
+ buffer += chunk.toString("utf8");
2339
+ if (Buffer.byteLength(buffer) > MAX_RESPONSE_BYTES) {
2340
+ socket.destroy();
2341
+ reject(new Error("deployment runner response is too large"));
2342
+ return;
2343
+ }
2344
+ const newline = buffer.indexOf(`
2345
+ `);
2346
+ if (newline < 0)
2347
+ return;
2348
+ socket.end();
2349
+ try {
2350
+ const response = JSON.parse(buffer.slice(0, newline));
2351
+ if (response.ok)
2352
+ resolveRequest(response.result);
2353
+ else
2354
+ reject(new Error(response.error.message));
2355
+ } catch (cause) {
2356
+ reject(cause);
2357
+ }
2358
+ });
2359
+ socket.on("error", reject);
2360
+ });
2361
+ }
2362
+
2363
+ // src/snp-attestation.ts
2364
+ import { existsSync as existsSync8 } from "fs";
2365
+ import { REPORT_BYTES } from "@forgezero/runtime/snp";
2366
+ var SNP_REPORT_HELPER = String.raw`
2367
+ import base64
2368
+ import ctypes
2369
+ import errno
2370
+ import os
2371
+ import re
2372
+ import sys
2373
+
2374
+ REPORT_BYTES = 1184
2375
+ RESPONSE_BYTES = 4000
2376
+ RESPONSE_HEADER_BYTES = 32
2377
+
2378
+ if len(sys.argv) != 3:
2379
+ raise SystemExit("usage: snp-report NONCE_HEX DEVICE")
2380
+
2381
+ nonce = sys.argv[1]
2382
+ device = sys.argv[2]
2383
+ if not re.fullmatch(r"[0-9a-fA-F]{64}", nonce):
2384
+ raise SystemExit("the SNP challenge must be exactly 32 bytes of hex")
2385
+ if device != "/dev/sev-guest":
2386
+ raise SystemExit("the SNP report source only accepts /dev/sev-guest")
2387
+
2388
+ class ReportRequest(ctypes.Structure):
2389
+ _fields_ = [
2390
+ ("user_data", ctypes.c_ubyte * 64),
2391
+ ("vmpl", ctypes.c_uint32),
2392
+ ("reserved", ctypes.c_ubyte * 28),
2393
+ ]
2394
+
2395
+ class ReportResponse(ctypes.Structure):
2396
+ _fields_ = [("data", ctypes.c_ubyte * RESPONSE_BYTES)]
2397
+
2398
+ class GuestRequestIoctl(ctypes.Structure):
2399
+ _fields_ = [
2400
+ ("msg_version", ctypes.c_uint8),
2401
+ ("req_data", ctypes.c_uint64),
2402
+ ("resp_data", ctypes.c_uint64),
2403
+ ("exitinfo2", ctypes.c_uint64),
2404
+ ]
2405
+
2406
+ def ioc(direction, kind, number, size):
2407
+ return (direction << 30) | (size << 16) | (ord(kind) << 8) | number
2408
+
2409
+ request = ReportRequest()
2410
+ for index, value in enumerate(bytes.fromhex(nonce)):
2411
+ request.user_data[index] = value
2412
+
2413
+ response = ReportResponse()
2414
+ argument = GuestRequestIoctl(
2415
+ msg_version=1,
2416
+ req_data=ctypes.addressof(request),
2417
+ resp_data=ctypes.addressof(response),
2418
+ exitinfo2=0,
2419
+ )
2420
+
2421
+ # _IOWR('S', 0x0, struct snp_guest_request_ioctl)
2422
+ snp_get_report = ioc(3, "S", 0, ctypes.sizeof(GuestRequestIoctl))
2423
+ libc = ctypes.CDLL(None, use_errno=True)
2424
+ libc.ioctl.argtypes = [ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p]
2425
+ libc.ioctl.restype = ctypes.c_int
2426
+
2427
+ descriptor = os.open(device, os.O_RDWR | os.O_CLOEXEC)
2428
+ try:
2429
+ result = libc.ioctl(descriptor, snp_get_report, ctypes.byref(argument))
2430
+ finally:
2431
+ os.close(descriptor)
2432
+
2433
+ if result != 0:
2434
+ code = ctypes.get_errno()
2435
+ firmware = argument.exitinfo2 & 0xffffffff
2436
+ vmm = argument.exitinfo2 >> 32
2437
+ detail = os.strerror(code) if code else errno.errorcode.get(code, "unknown error")
2438
+ raise SystemExit(f"SNP_GET_REPORT failed: {detail}; firmware={firmware}; vmm={vmm}")
2439
+
2440
+ raw = bytes(response.data)
2441
+ status = int.from_bytes(raw[0:4], "little")
2442
+ report_size = int.from_bytes(raw[4:8], "little")
2443
+ if status != 0 or report_size != REPORT_BYTES:
2444
+ raise SystemExit(f"SNP report response invalid: status={status}; size={report_size}")
2445
+ report = raw[RESPONSE_HEADER_BYTES:RESPONSE_HEADER_BYTES + REPORT_BYTES]
2446
+ sys.stdout.write(base64.b64encode(report).decode("ascii"))
2447
+ `;
2448
+ function createSnpAttestationSource(options = {}) {
2449
+ const device = options.device ?? "/dev/sev-guest";
2450
+ const python = options.python ?? "python3";
2451
+ const timeoutMs = options.timeoutMs ?? 1e4;
2452
+ const spawn = options.spawn ?? Bun.spawn;
2453
+ const exists = options.exists ?? existsSync8;
2454
+ return {
2455
+ name: "linux-sev-guest",
2456
+ async report(nonce) {
2457
+ if (!/^[0-9a-f]{64}$/i.test(nonce)) {
2458
+ throw new Error("the SNP challenge must be exactly 32 bytes of hex");
2459
+ }
2460
+ if (device !== "/dev/sev-guest" || !exists(device)) {
2461
+ throw new Error("/dev/sev-guest is unavailable");
2462
+ }
2463
+ const child = spawn([python, "-c", SNP_REPORT_HELPER, nonce, device], {
2464
+ stdin: "ignore",
2465
+ stdout: "pipe",
2466
+ stderr: "pipe"
2467
+ });
2468
+ const timer = setTimeout(() => child.kill(), timeoutMs);
2469
+ try {
2470
+ const [exitCode, stdout, stderr] = await Promise.all([
2471
+ child.exited,
2472
+ new Response(child.stdout).text(),
2473
+ new Response(child.stderr).text()
2474
+ ]);
2475
+ if (exitCode !== 0) {
2476
+ throw new Error(stderr.trim() || `SNP report helper exited ${exitCode}`);
2477
+ }
2478
+ const report = stdout.trim();
2479
+ const bytes = Uint8Array.from(Buffer.from(report, "base64"));
2480
+ if (bytes.length !== REPORT_BYTES) {
2481
+ throw new Error(`SNP report helper returned ${bytes.length} bytes; expected ${REPORT_BYTES}`);
2482
+ }
2483
+ return report;
2484
+ } finally {
2485
+ clearTimeout(timer);
2486
+ }
2487
+ }
2488
+ };
2489
+ }
2490
+
2491
+ // src/attestation-client.ts
2492
+ async function attestNodeOnce(options) {
2493
+ const challenge = await postSignedNode(options, "v1/node/attest/challenge", {});
2494
+ if (!/^[0-9a-f]{64}$/i.test(challenge.nonce) || !Number.isSafeInteger(challenge.expiresAtSec)) {
2495
+ throw new Error("attestation challenge response is malformed");
2496
+ }
2497
+ const report = await options.source.report(challenge.nonce);
2498
+ const verdict = await postSignedNode(options, "v1/node/attest", { nonce: challenge.nonce, report });
2499
+ if (!verdict.verified || !/^[0-9a-f]{96}$/i.test(verdict.measurement)) {
2500
+ throw new Error("attestation verdict response is malformed");
2501
+ }
2502
+ return { measurement: verdict.measurement, ageMs: verdict.ageMs };
2503
+ }
2504
+ function startNodeAttestation(options) {
2505
+ const interval = Math.max(5000, options.intervalMs ?? 30000);
2506
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
2507
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
2508
+ let stopped = false;
2509
+ let timer;
2510
+ let active = null;
2511
+ const schedule = () => {
2512
+ if (!stopped)
2513
+ timer = setTimer(tick, interval);
2514
+ };
2515
+ const tick = () => {
2516
+ if (stopped || active)
2517
+ return;
2518
+ active = attestNodeOnce(options).then((result) => options.onEvent?.("verified", result)).catch((cause) => options.onEvent?.("failed", cause)).finally(() => {
2519
+ active = null;
2520
+ schedule();
2521
+ });
2522
+ };
2523
+ if (options.immediate === false)
2524
+ schedule();
2525
+ else
2526
+ tick();
2527
+ return {
2528
+ async stop() {
2529
+ stopped = true;
2530
+ clearTimer(timer);
2531
+ await active;
2532
+ }
2533
+ };
2534
+ }
2535
+
2536
+ // src/metal-isolation.ts
2537
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2538
+ import { join as join3 } from "path";
2539
+ var members = (list) => list.split(",").flatMap((part) => {
2540
+ const [first, last = first] = part.split("-").map(Number);
2541
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index);
2542
+ });
2543
+ var compact = (values) => {
2544
+ const sorted = [...new Set(values)].sort((left, right) => left - right);
2545
+ const ranges = [];
2546
+ for (let index = 0;index < sorted.length; ) {
2547
+ const first = sorted[index];
2548
+ let last = first;
2549
+ while (sorted[index + 1] === last + 1)
2550
+ last = sorted[++index];
2551
+ ranges.push(first === last ? String(first) : `${first}-${last}`);
2552
+ index += 1;
2553
+ }
2554
+ return ranges.join(",");
2555
+ };
2556
+ var memoryDirective = (nodes) => nodes ? `AllowedMemoryNodes=${nodes}
2557
+ ` : "";
2558
+ function metalGuestSliceUnit(profile) {
2559
+ validateMetalProfile(profile);
2560
+ const cpus = compact(profile.cpuPools.flatMap((pool) => members(pool.cpus)));
2561
+ const nodes = compact(profile.cpuPools.flatMap((pool) => pool.memoryNodes ? members(pool.memoryNodes) : [])) || undefined;
2562
+ return `[Unit]
2563
+ Description=ForgeZero exclusive guest CPU and memory boundary
2564
+
2565
+ [Slice]
2566
+ AllowedCPUs=${cpus}
2567
+ ${memoryDirective(nodes)}`;
2568
+ }
2569
+ function metalHousekeepingDropIn(profile, kind) {
2570
+ validateMetalProfile(profile);
2571
+ return `[${kind === "slice" ? "Slice" : "Scope"}]
2572
+ AllowedCPUs=${profile.housekeepingCpus}
2573
+ ${memoryDirective(profile.housekeepingMemoryNodes)}`;
2574
+ }
2575
+ var defaultExec = async (argv) => {
2576
+ const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe" });
2577
+ const [exitCode, stdout, stderr] = await Promise.all([
2578
+ child.exited,
2579
+ new Response(child.stdout).text(),
2580
+ new Response(child.stderr).text()
2581
+ ]);
2582
+ return { exitCode, stdout, stderr };
2583
+ };
2584
+ var checked2 = async (exec, argv) => {
2585
+ const result = await exec(argv);
2586
+ if (result.exitCode !== 0)
2587
+ throw new Error(`${argv[0]} failed: ${(result.stderr || result.stdout).trim()}`);
2588
+ return result;
2589
+ };
2590
+ var requireGuestsInSlice = async (exec) => {
2591
+ const active = await checked2(exec, [
2592
+ "systemctl",
2593
+ "list-units",
2594
+ "--type=service",
2595
+ "--state=running",
2596
+ "--plain",
2597
+ "--no-legend",
2598
+ "forgezero-guest@*.service"
2599
+ ]);
2600
+ for (const line of active.stdout.split(`
2601
+ `)) {
2602
+ const service = line.trim().split(/\s+/)[0];
2603
+ if (!service)
2604
+ continue;
2605
+ const cgroup = await checked2(exec, ["systemctl", "show", "-p", "ControlGroup", "--value", service]);
2606
+ if (!cgroup.stdout.trim().includes("/forgezero-guests.slice/")) {
2607
+ throw new Error(`${service} must be drained and restarted into forgezero-guests.slice`);
2608
+ }
2609
+ }
2610
+ };
2611
+ async function applyMetalIsolation(profile, exec = defaultExec) {
2612
+ validateMetalProfile(profile);
2613
+ await requireGuestsInSlice(exec);
2614
+ const unitDir = profile.unitDir;
2615
+ mkdirSync5(unitDir, { recursive: true });
2616
+ writeFileSync5(join3(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
2617
+ for (const unit of ["system.slice", "user.slice"]) {
2618
+ const directory = join3(unitDir, `${unit}.d`);
2619
+ mkdirSync5(directory, { recursive: true });
2620
+ writeFileSync5(join3(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
2621
+ }
2622
+ const initDirectory = join3(unitDir, "init.scope.d");
2623
+ mkdirSync5(initDirectory, { recursive: true });
2624
+ writeFileSync5(join3(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
2625
+ await checked2(exec, ["systemctl", "daemon-reload"]);
2626
+ await requireGuestsInSlice(exec);
2627
+ const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
2628
+ if (profile.housekeepingMemoryNodes)
2629
+ properties.push(`AllowedMemoryNodes=${profile.housekeepingMemoryNodes}`);
2630
+ for (const unit of ["system.slice", "user.slice", "init.scope"]) {
2631
+ await checked2(exec, ["systemctl", "set-property", "--runtime", unit, ...properties]);
2632
+ }
2633
+ }
2634
+
341
2635
  // src/index.ts
342
- var VERSION = "0.1.2";
2636
+ var VERSION = "0.1.9";
343
2637
  function loadOrCreateSeed(path) {
344
- if (existsSync2(path)) {
345
- const seed2 = new Uint8Array(Buffer.from(readFileSync(path, "utf8").trim(), "base64url"));
2638
+ if (existsSync9(path)) {
2639
+ const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
346
2640
  if (seed2.length < 32) {
347
2641
  throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
348
2642
  }
349
2643
  return seed2;
350
2644
  }
351
- mkdirSync(dirname(path), { recursive: true });
2645
+ mkdirSync6(dirname5(path), { recursive: true });
352
2646
  const seed = new Uint8Array(randomBytes(32));
353
- writeFileSync(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
354
- chmodSync2(path, 384);
2647
+ writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
2648
+ chmodSync8(path, 384);
355
2649
  return seed;
356
2650
  }
357
2651
  var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
358
2652
  var DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
2653
+ var DEFAULT_SEED_CREDENTIAL = "agent-seed";
2654
+ var DEFAULT_ENROLMENT_STATE_PATH = "/var/lib/forgezero/enrolment.json";
2655
+ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.env.CREDENTIALS_DIRECTORY) {
2656
+ if (!directory)
2657
+ throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
2658
+ const path = `${directory}/${name}`;
2659
+ if (!existsSync9(path))
2660
+ throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
2661
+ const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
2662
+ if (seed.length < 32)
2663
+ throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
2664
+ return seed;
2665
+ }
2666
+ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY) {
2667
+ if (!directory)
2668
+ throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
2669
+ if (!/^[A-Za-z0-9_.-]+$/.test(name))
2670
+ throw new Error("agent: invalid systemd credential name.");
2671
+ const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
2672
+ if (!value)
2673
+ throw new Error(`agent: systemd credential ${name} is empty.`);
2674
+ return value;
2675
+ }
2676
+ function createSystemdDeploymentSecrets(names, directory = process.env.CREDENTIALS_DIRECTORY) {
2677
+ const allowed = new Set((names ?? "").split(",").map((name) => name.trim()).filter(Boolean));
2678
+ if (allowed.size === 0)
2679
+ return;
2680
+ for (const name of allowed) {
2681
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name))
2682
+ throw new Error(`agent: invalid pipeline credential name ${name}.`);
2683
+ }
2684
+ return {
2685
+ has: (name) => allowed.has(name),
2686
+ async get(name) {
2687
+ if (!allowed.has(name))
2688
+ throw new Error(`agent: pipeline credential ${name} is not allowed.`);
2689
+ return loadTextCredential(name, directory);
2690
+ }
2691
+ };
2692
+ }
359
2693
  function runAgent(config = {}) {
360
- const seedPath = config.seedPath ?? DEFAULT_SEED_PATH;
361
- const keys = deriveKeysFromSeed(loadOrCreateSeed(seedPath));
2694
+ const seed = config.seed ?? (config.seedCredential ? loadSeedCredential(config.seedCredential) : loadOrCreateSeed(config.seedPath ?? DEFAULT_SEED_PATH));
2695
+ const keys = deriveKeysFromSeed(seed);
362
2696
  const nodeKey = config.nodeKey ?? keys.ed25519.publicKey;
363
- const server = startAgent({
2697
+ const options = {
364
2698
  socketPath: config.socketPath ?? DEFAULT_SOCKET_PATH,
365
2699
  keys,
366
2700
  nodeKey,
367
2701
  attestation: config.attestation,
368
2702
  cache: config.cache,
369
2703
  record: config.record
370
- });
371
- return { server, keys, nodeKey };
2704
+ };
2705
+ const server = startAgent(options);
2706
+ return { server, keys, nodeKey, setCache: (cache) => {
2707
+ options.cache = cache;
2708
+ } };
372
2709
  }
373
2710
  if (import.meta.main) {
374
2711
  const args = process.argv.slice(2);
@@ -376,18 +2713,27 @@ if (import.meta.main) {
376
2713
  console.log([
377
2714
  `fz-agent ${VERSION}`,
378
2715
  "",
379
- "Runs on the metal and answers secret requests over a local socket.",
2716
+ "Runs inside managed compute and answers secret requests over a local socket.",
380
2717
  "It holds no configuration of its own \u2014 everything comes from the",
381
2718
  "environment, so a systemd unit is the whole deployment.",
382
2719
  "",
383
2720
  " FZ_SOCKET_PATH where to listen (default: " + DEFAULT_SOCKET_PATH + ")",
384
- " FZ_SEED_PATH node identity seed (default: " + DEFAULT_SEED_PATH + ")",
2721
+ " FZ_SEED_CREDENTIAL systemd credential (production: agent-seed)",
2722
+ " FZ_SEED_PATH legacy/dev seed file (default: " + DEFAULT_SEED_PATH + ")",
385
2723
  " FZ_NODE_KEY override the node key (default: derived from the seed)",
386
2724
  "",
2725
+ " deploy [--revision=<full-sha>] [--coordinator]",
2726
+ " identity print this sealed seed's public identity",
2727
+ " enrol consume a systemd-loaded compute capability",
2728
+ " metal-helper --profile=/etc/forgezero/metal.json",
2729
+ " status | pause | resume",
2730
+ " pause-key|resume-key|stop-key|start-key --key=<key>",
2731
+ " cancel --id=<task-id>",
2732
+ "",
387
2733
  " --help, -h this text",
388
2734
  " --version, -v print the version and exit",
389
2735
  "",
390
- "The seed is created on first run and never leaves the machine."
2736
+ "Production reads the node seed from a private systemd credential."
391
2737
  ].join(`
392
2738
  `));
393
2739
  process.exit(0);
@@ -396,22 +2742,397 @@ if (import.meta.main) {
396
2742
  console.log(VERSION);
397
2743
  process.exit(0);
398
2744
  }
399
- const { nodeKey } = runAgent({
2745
+ const command = args.find((arg) => !arg.startsWith("-"));
2746
+ if (command === "identity") {
2747
+ const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
2748
+ const keys2 = deriveKeysFromSeed(seed);
2749
+ console.log(JSON.stringify({
2750
+ nodeKey: keys2.ed25519.publicKey,
2751
+ publicKeys: { ed25519: keys2.ed25519.publicKey, mlDsa: keys2.mlDsa.publicKey }
2752
+ }, null, 2));
2753
+ process.exit(0);
2754
+ }
2755
+ if (command === "enrol") {
2756
+ if (!process.env.FZ_API)
2757
+ throw new Error("agent enrolment requires FZ_API");
2758
+ if (!process.env.FZ_ENROL_TOKEN_CREDENTIAL) {
2759
+ throw new Error("agent enrolment requires a systemd enrolment credential");
2760
+ }
2761
+ const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
2762
+ const keys2 = deriveKeysFromSeed(seed);
2763
+ const nodeKey2 = process.env.FZ_NODE_KEY ?? keys2.ed25519.publicKey;
2764
+ const binding2 = await enrolGuestIdentity({
2765
+ apiUrl: process.env.FZ_API,
2766
+ token: loadTextCredential(process.env.FZ_ENROL_TOKEN_CREDENTIAL),
2767
+ statePath: process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH,
2768
+ nodeKey: nodeKey2,
2769
+ keys: keys2,
2770
+ label: process.env.FZ_NODE_LABEL,
2771
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2772
+ });
2773
+ console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
2774
+ process.exit(0);
2775
+ }
2776
+ if (command === "metal-helper") {
2777
+ const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2778
+ if (!profilePath)
2779
+ throw new Error("metal-helper requires --profile=/absolute/path.json");
2780
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2781
+ const helper = startMetalHelper({
2782
+ profile,
2783
+ socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
2784
+ });
2785
+ console.log(`[metal-helper] listening on ${process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET}`);
2786
+ let stopping = false;
2787
+ const stop = async () => {
2788
+ if (stopping)
2789
+ return;
2790
+ stopping = true;
2791
+ await helper.stop();
2792
+ process.exit(0);
2793
+ };
2794
+ process.on("SIGTERM", () => void stop());
2795
+ process.on("SIGINT", () => void stop());
2796
+ await new Promise(() => {});
2797
+ }
2798
+ if (command === "metal-isolation") {
2799
+ const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
2800
+ if (!profilePath)
2801
+ throw new Error("metal-isolation requires --profile=/absolute/path.json");
2802
+ const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
2803
+ await applyMetalIsolation(profile);
2804
+ console.log("[metal-isolation] host and guest cgroup boundaries active");
2805
+ process.exit(0);
2806
+ }
2807
+ if (command === "deploy-runner") {
2808
+ const root2 = args.find((arg) => arg.startsWith("--root="))?.slice("--root=".length);
2809
+ const home = args.find((arg) => arg.startsWith("--home="))?.slice("--home=".length);
2810
+ if (!root2 || !home)
2811
+ throw new Error("deploy-runner requires --root=/absolute/path --home=/absolute/path");
2812
+ const runner = startDeploymentRunner({
2813
+ root: `${root2.replace(/\/$/, "")}/releases`,
2814
+ home,
2815
+ socketPath: process.env.FZ_DEPLOY_RUNNER_SOCKET ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
2816
+ listenFd: Number(process.env.LISTEN_PID) === process.pid && Number(process.env.LISTEN_FDS) > 0 ? 3 : undefined
2817
+ });
2818
+ console.log(`[deploy-runner] listening on ${process.env.FZ_DEPLOY_RUNNER_SOCKET ?? DEFAULT_DEPLOYMENT_RUNNER_SOCKET}`);
2819
+ let stopping = false;
2820
+ const stop = async () => {
2821
+ if (stopping)
2822
+ return;
2823
+ stopping = true;
2824
+ await runner.stop();
2825
+ process.exit(0);
2826
+ };
2827
+ process.on("SIGTERM", () => void stop());
2828
+ process.on("SIGINT", () => void stop());
2829
+ await new Promise(() => {});
2830
+ }
2831
+ if (process.env.FZ_AGENT_ROLE === "metal") {
2832
+ if (!process.env.FZ_API)
2833
+ throw new Error("metal agent requires FZ_API");
2834
+ const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
2835
+ const keys2 = deriveKeysFromSeed(seed);
2836
+ const nodeKey2 = process.env.FZ_NODE_KEY ?? keys2.ed25519.publicKey;
2837
+ const pull = startProvisioningPull({
2838
+ apiUrl: process.env.FZ_API,
2839
+ nodeKey: nodeKey2,
2840
+ keys: keys2,
2841
+ run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
2842
+ metalPreflight: () => ({
2843
+ snpHost: existsSync9("/dev/sev"),
2844
+ kvm: existsSync9("/dev/kvm"),
2845
+ helper: existsSync9(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
2846
+ }),
2847
+ onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2848
+ });
2849
+ console.log(`[metal-agent] signing as ${nodeKey2}; outbound provisioning enabled`);
2850
+ let stopping = false;
2851
+ const stop = async (signal) => {
2852
+ if (stopping)
2853
+ return;
2854
+ stopping = true;
2855
+ const deadline = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 120000));
2856
+ const drained = await Promise.race([
2857
+ pull.stop().then(() => true),
2858
+ new Promise((resolve2) => setTimeout(() => resolve2(false), deadline))
2859
+ ]);
2860
+ console.log(`[metal-agent] ${signal}: ${drained ? "drained" : "deadline reached; claim left fenced for recovery"}`);
2861
+ process.exit(drained ? 0 : 1);
2862
+ };
2863
+ process.on("SIGTERM", () => void stop("SIGTERM"));
2864
+ process.on("SIGINT", () => void stop("SIGINT"));
2865
+ await new Promise(() => {});
2866
+ }
2867
+ if (command && ["deploy", "status", "pause", "resume", "pause-key", "resume-key", "stop-key", "start-key", "cancel"].includes(command)) {
2868
+ const revisionArg = args.find((arg) => arg.startsWith("--revision="));
2869
+ const socketArg = args.find((arg) => arg.startsWith("--control-socket="));
2870
+ const keyArg = args.find((arg) => arg.startsWith("--key="));
2871
+ const idArg = args.find((arg) => arg.startsWith("--id="));
2872
+ const socketPath = socketArg?.slice("--control-socket=".length) ?? process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET;
2873
+ const request = command === "deploy" ? {
2874
+ op: "deploy",
2875
+ request: {
2876
+ revision: revisionArg?.slice("--revision=".length),
2877
+ coordinator: args.includes("--coordinator")
2878
+ }
2879
+ } : command === "cancel" ? { op: "cancel", id: idArg?.slice("--id=".length) ?? "" } : ["pause-key", "resume-key", "stop-key", "start-key"].includes(command) ? {
2880
+ op: command,
2881
+ key: keyArg?.slice("--key=".length) ?? ""
2882
+ } : { op: command };
2883
+ try {
2884
+ const response = await requestControl(request, socketPath);
2885
+ console.log(JSON.stringify(response, null, 2));
2886
+ process.exit(response.ok ? 0 : 1);
2887
+ } catch (cause) {
2888
+ console.error(`agent control failed: ${cause instanceof Error ? cause.message : String(cause)}`);
2889
+ process.exit(1);
2890
+ }
2891
+ }
2892
+ const attestationSource = existsSync9("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
2893
+ const running = runAgent({
400
2894
  socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
2895
+ seedCredential: process.env.FZ_SEED_CREDENTIAL,
401
2896
  seedPath: process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH,
402
2897
  nodeKey: process.env.FZ_NODE_KEY,
2898
+ attestation: attestationSource,
403
2899
  record: (entry) => console.log(`[agent] ${entry.op} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`)
404
2900
  });
2901
+ const { nodeKey, keys, server } = running;
405
2902
  console.log(`[agent] ${VERSION} signing as ${nodeKey}`);
2903
+ const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
2904
+ let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
2905
+ const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
2906
+ const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync9(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
2907
+ const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync9(process.env.FZ_ENROL_TOKEN_FILE));
2908
+ if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
2909
+ binding = await enrolGuestIdentity({
2910
+ apiUrl: process.env.FZ_API,
2911
+ token: enrolmentCredential ? loadTextCredential(enrolmentCredential) : undefined,
2912
+ tokenPath: enrolmentCredential ? undefined : process.env.FZ_ENROL_TOKEN_FILE,
2913
+ statePath: enrolmentStatePath,
2914
+ nodeKey,
2915
+ keys,
2916
+ label: process.env.FZ_NODE_LABEL,
2917
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
2918
+ });
2919
+ console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
2920
+ }
2921
+ let nodeApiUrl = binding && process.env.FZ_API ? tenantNodeApiUrl(process.env.FZ_API, binding.tenantSlug) : process.env.FZ_API;
2922
+ let secretCache;
2923
+ let vaultSync;
2924
+ if (binding && attestationSource && nodeApiUrl) {
2925
+ const result = await attestNodeOnce({ apiUrl: nodeApiUrl, nodeKey, keys, source: attestationSource });
2926
+ console.log(`[agent] initial SEV-SNP attestation verified ${result.measurement.slice(0, 16)}\u2026`);
2927
+ }
2928
+ if (binding && nodeApiUrl) {
2929
+ secretCache = createNodeVaultCache({ apiUrl: nodeApiUrl, nodeKey, keys });
2930
+ const loaded = await secretCache.load();
2931
+ if (loaded.failed.length > 0) {
2932
+ throw new Error(`agent: failed to load ${loaded.failed.length} assigned vault entries`);
2933
+ }
2934
+ running.setCache(secretCache);
2935
+ vaultSync = startNodeVaultSync(secretCache, {
2936
+ onEvent: (event, detail) => console.log(`[agent] vault ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
2937
+ });
2938
+ console.log(`[agent] in-memory vault loaded for ${binding.projectKey}/${binding.environmentKey}`);
2939
+ }
2940
+ const attestationLoop = attestationSource && nodeApiUrl ? startNodeAttestation({
2941
+ apiUrl: nodeApiUrl,
2942
+ nodeKey,
2943
+ keys,
2944
+ source: attestationSource,
2945
+ immediate: !binding,
2946
+ onEvent: (event, detail) => console.log(`[agent] attestation ${event}${detail ? ` ${detail instanceof Error ? detail.message : JSON.stringify(detail)}` : ""}`)
2947
+ }) : undefined;
2948
+ if (attestationLoop)
2949
+ console.log("[agent] periodic SEV-SNP attestation enabled");
2950
+ const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
2951
+ const deploymentSecrets = secretCache ? {
2952
+ async get(name) {
2953
+ try {
2954
+ return await secretCache.get(name);
2955
+ } catch (cause) {
2956
+ if (systemdDeploymentSecrets?.has(name))
2957
+ return systemdDeploymentSecrets.get(name);
2958
+ throw cause;
2959
+ }
2960
+ }
2961
+ } : systemdDeploymentSecrets;
2962
+ const repository = process.env.FZ_DEPLOY_REPO;
2963
+ const branch = process.env.FZ_DEPLOY_BRANCH;
2964
+ const role = process.env.FZ_DEPLOY_ROLE;
2965
+ const root = process.env.FZ_DEPLOY_ROOT;
2966
+ const pullEnabled = process.env.FZ_DEPLOY_PULL === "true" && Boolean(process.env.FZ_API);
2967
+ if (pullEnabled && !binding) {
2968
+ throw new Error("agent: outbound guest deployment requires a persisted tenant enrolment binding");
2969
+ }
2970
+ if (root && (repository && branch && role || pullEnabled)) {
2971
+ const managers = new Map;
2972
+ const managerOptions = (source, key) => ({
2973
+ key,
2974
+ repository: source.repository,
2975
+ branch: source.branch,
2976
+ role: source.role,
2977
+ root,
2978
+ publicApiUrl: process.env.FZ_PUBLIC_API_URL,
2979
+ environment: Object.fromEntries((process.env.FZ_DEPLOY_ENV_NAMES ?? "").split(",").filter(Boolean).map((name) => {
2980
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || process.env[name] === undefined) {
2981
+ throw new Error(`agent: invalid or absent deployment environment ${name}.`);
2982
+ }
2983
+ return [name, process.env[name]];
2984
+ })),
2985
+ gitCredentialPath: process.env.CREDENTIALS_DIRECTORY ? `${process.env.CREDENTIALS_DIRECTORY}/git-deploy-key` : undefined,
2986
+ knownHostsPath: source.knownHosts ? join4(root, "cache", `known-hosts-${key}`) : undefined,
2987
+ knownHostsContent: source.knownHosts,
2988
+ cache: deploymentSecrets,
2989
+ projectExec: process.env.FZ_DEPLOY_RUNNER_SOCKET ? (input) => requestDeploymentCommand(input, process.env.FZ_DEPLOY_RUNNER_SOCKET) : undefined
2990
+ });
2991
+ const staticManager = repository && branch && role ? createDeploymentManager(managerOptions({ repository, branch, role }, process.env.FZ_DEPLOY_KEY ?? `${repository}:${branch}:${role}`)) : undefined;
2992
+ if (staticManager)
2993
+ managers.set("__static__", staticManager);
2994
+ const control = staticManager ? startControlServer(staticManager, process.env.FZ_CONTROL_SOCKET ?? DEFAULT_CONTROL_SOCKET) : undefined;
2995
+ if (control)
2996
+ console.log(`[agent] deployment control listening for ${repository}@${branch}`);
2997
+ const staticWatch = staticManager && process.env.FZ_DEPLOY_WATCH === "true" ? startStaticDeploymentWatch({
2998
+ manager: staticManager,
2999
+ statePath: process.env.FZ_DEPLOY_STATE ?? join4(root, "cache", "static-deployment.json"),
3000
+ coordinator: process.env.FZ_DEPLOY_COORDINATOR === "true",
3001
+ currentRevision: () => {
3002
+ try {
3003
+ const slot = readFileSync5(join4(root, ".forge-slot"), "utf8").trim();
3004
+ const revision = readFileSync5(join4(root, "slots", slot, ".git", "HEAD"), "utf8").trim();
3005
+ return /^[a-f0-9]{40}$/i.test(revision) ? revision.toLowerCase() : undefined;
3006
+ } catch {
3007
+ return;
3008
+ }
3009
+ },
3010
+ onEvent: (event, detail) => console.log(`[agent] static deployment ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
3011
+ }) : undefined;
3012
+ if (staticWatch)
3013
+ console.log(`[agent] watching ${repository}@${branch} for exact revisions`);
3014
+ const pull = pullEnabled ? startDeploymentPull({
3015
+ apiUrl: nodeApiUrl,
3016
+ nodeKey,
3017
+ keys,
3018
+ manager: staticManager,
3019
+ managerFor: staticManager ? undefined : (claim) => {
3020
+ const cacheKey = [
3021
+ claim.pipelineKey,
3022
+ claim.source.repository,
3023
+ claim.source.branch,
3024
+ claim.source.role
3025
+ ].join("\x00");
3026
+ const existing = managers.get(cacheKey);
3027
+ if (existing)
3028
+ return existing;
3029
+ const manager = createDeploymentManager(managerOptions(claim.source, claim.pipelineKey));
3030
+ managers.set(cacheKey, manager);
3031
+ return manager;
3032
+ },
3033
+ onEvent: (event, detail) => console.log(`[agent] deployment ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
3034
+ }) : undefined;
3035
+ if (pull)
3036
+ console.log(`[agent] outbound deployment claims enabled for ${process.env.FZ_API}`);
3037
+ let stopping = false;
3038
+ const shutdown = async (signal) => {
3039
+ if (stopping)
3040
+ return;
3041
+ stopping = true;
3042
+ console.log(`[agent] ${signal}: stopping deployment intake and draining`);
3043
+ if (control)
3044
+ await new Promise((resolve2) => control.close(() => resolve2()));
3045
+ const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
3046
+ const deadline = Date.now() + deadlineMs;
3047
+ const pullDrain = Promise.all([
3048
+ pull?.stop() ?? Promise.resolve(),
3049
+ staticWatch?.stop() ?? Promise.resolve()
3050
+ ]);
3051
+ const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3052
+ const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3053
+ const pullWithinDeadline = pull || staticWatch ? Promise.race([
3054
+ pullDrain.then(() => true),
3055
+ new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
3056
+ ]) : Promise.resolve(true);
3057
+ const pullDrained = await pullWithinDeadline;
3058
+ await Promise.all([vaultDrain, attestationDrain]);
3059
+ const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(Math.max(1, deadline - Date.now()))));
3060
+ await new Promise((resolve2) => server.close(() => resolve2()));
3061
+ const timedOut = managerReports.some((report) => report.timedOut);
3062
+ console.log(`[agent] drain ${JSON.stringify({ managers: managerReports, pullDrained })}`);
3063
+ process.exit(timedOut || !pullDrained ? 1 : 0);
3064
+ };
3065
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
3066
+ process.on("SIGINT", () => void shutdown("SIGINT"));
3067
+ } else {
3068
+ console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
3069
+ if (vaultSync || attestationLoop) {
3070
+ let stopping = false;
3071
+ const stop = async () => {
3072
+ if (stopping)
3073
+ return;
3074
+ stopping = true;
3075
+ await Promise.all([
3076
+ vaultSync?.stop() ?? Promise.resolve(),
3077
+ attestationLoop?.stop() ?? Promise.resolve()
3078
+ ]);
3079
+ await new Promise((resolve2) => server.close(() => resolve2()));
3080
+ process.exit(0);
3081
+ };
3082
+ process.on("SIGTERM", () => void stop());
3083
+ process.on("SIGINT", () => void stop());
3084
+ }
3085
+ }
406
3086
  }
407
3087
  export {
3088
+ writeStaticDeploymentState,
3089
+ validateMetalProfile,
3090
+ tenantNodeApiUrl,
3091
+ startStaticDeploymentWatch,
3092
+ startProvisioningPull,
3093
+ startNodeVaultSync,
3094
+ startNodeAttestation,
3095
+ startMetalHelper,
3096
+ startDeploymentRunner,
3097
+ startDeploymentPull,
3098
+ startControlServer,
408
3099
  startAgent,
409
3100
  runAgent,
3101
+ requestMetalProvision,
3102
+ requestDeploymentCommand,
3103
+ requestControl,
3104
+ removeMetalGuest,
3105
+ readStaticDeploymentState,
3106
+ pullProvisioningOnce,
3107
+ pullDeploymentOnce,
3108
+ provisionMetalGuest,
3109
+ metalHousekeepingDropIn,
3110
+ metalGuestSliceUnit,
3111
+ loadTextCredential,
3112
+ loadSeedCredential,
410
3113
  loadOrCreateSeed,
3114
+ loadGuestBinding,
411
3115
  handleRequest,
3116
+ guestNameFor,
3117
+ enrolGuestIdentity,
3118
+ createSystemdDeploymentSecrets,
3119
+ createSnpAttestationSource,
412
3120
  createSecretCache,
3121
+ createNodeVaultCache,
3122
+ createDeploymentManager,
3123
+ cloudInit,
3124
+ attestNodeOnce,
3125
+ applyMetalIsolation,
3126
+ allocateCpuPool,
3127
+ allocateAddress,
413
3128
  VERSION,
3129
+ DeploymentError,
414
3130
  DEFAULT_SOCKET_PATH,
415
3131
  DEFAULT_SEED_PATH,
3132
+ DEFAULT_SEED_CREDENTIAL,
3133
+ DEFAULT_METAL_HELPER_SOCKET,
3134
+ DEFAULT_ENROLMENT_STATE_PATH,
3135
+ DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
3136
+ DEFAULT_CONTROL_SOCKET,
416
3137
  CacheError
417
3138
  };