@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
@@ -0,0 +1,1014 @@
1
+ // src/compute.ts
2
+ class ComputeError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "ComputeError";
8
+ }
9
+ }
10
+ var NAME = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/;
11
+ var unitName = (name) => {
12
+ if (!NAME.test(name)) {
13
+ throw new ComputeError("BAD_NAME", `"${name}" is not a usable guest name — lower case, digits and hyphens, 3-32 characters.`);
14
+ }
15
+ return `forgezero-guest@${name}.service`;
16
+ };
17
+ function qemuArgv(spec) {
18
+ if (spec.vcpu < 1 || spec.memoryGib < 1) {
19
+ throw new ComputeError("BAD_SPEC", "a guest needs at least 1 vCPU and 1 GiB");
20
+ }
21
+ const argv = [
22
+ "/usr/bin/qemu-system-x86_64",
23
+ "-name",
24
+ spec.name,
25
+ "-accel",
26
+ "kvm",
27
+ "-cpu",
28
+ "host",
29
+ "-m",
30
+ `${spec.memoryGib}G`,
31
+ "-smp",
32
+ String(spec.vcpu)
33
+ ];
34
+ if (spec.confidential) {
35
+ const { cbitpos, reducedPhysBits, policy } = spec.confidential;
36
+ 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");
37
+ } else {
38
+ argv.push("-machine", "q35");
39
+ }
40
+ 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");
41
+ if (spec.consoleLog)
42
+ argv.push("-serial", `file:${spec.consoleLog}`);
43
+ return argv;
44
+ }
45
+ function guestUnit(spec) {
46
+ const argv = qemuArgv(spec).map((part) => /[\s"']/.test(part) ? JSON.stringify(part) : part).join(" ");
47
+ if (spec.egress && !spec.tap) {
48
+ throw new ComputeError("BAD_SPEC", "shaped egress needs a stable tap device");
49
+ }
50
+ const tapSetup = spec.tap ? [
51
+ `ExecStartPre=-/usr/sbin/ip link del ${spec.tap}`,
52
+ `ExecStartPre=/usr/sbin/ip tuntap add dev ${spec.tap} mode tap`,
53
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} master ${spec.bridge}`,
54
+ `ExecStartPre=/usr/sbin/ip link set ${spec.tap} up`,
55
+ ...spec.egress ? shapeEgressUnitDirectives(spec.tap, spec.egress.guaranteedMbps, spec.egress.burstMbps) : [],
56
+ `ExecStopPost=-/usr/sbin/ip link del ${spec.tap}`
57
+ ].join(`
58
+ `) : "";
59
+ return `[Unit]
60
+ Description=ForgeZero guest ${spec.name}
61
+ Documentation=https://www.forgezero.net
62
+ After=network-online.target
63
+ Wants=network-online.target
64
+
65
+ [Service]
66
+ Type=simple
67
+ Slice=forgezero-guests.slice
68
+ ${tapSetup}
69
+ ExecStart=${argv}
70
+ Restart=always
71
+ RestartSec=5
72
+ ${spec.allowedCpus ? `AllowedCPUs=${spec.allowedCpus}
73
+ ` : ""}${spec.allowedMemoryNodes ? `AllowedMemoryNodes=${spec.allowedMemoryNodes}
74
+ ` : ""}# The affinity belongs to the unit rather than only QEMU's vCPU threads. Its
75
+ # emulator and IO threads can otherwise run on a different tenant's cores.
76
+ # The agent is NOT the parent. Restarting or upgrading fz-agent must never stop
77
+ # a tenant's compute, which is the whole reason this is a unit rather than a
78
+ # child process.
79
+ KillMode=mixed
80
+ TimeoutStopSec=120
81
+
82
+ [Install]
83
+ WantedBy=multi-user.target
84
+ `;
85
+ }
86
+ function parseCensus(psOutput) {
87
+ const guests = [];
88
+ for (const line of psOutput.split(`
89
+ `)) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed || !trimmed.includes("qemu-system"))
92
+ continue;
93
+ const pid = Number(trimmed.split(/\s+/)[0]);
94
+ const name = /-name\s+([^\s]+)/.exec(trimmed)?.[1];
95
+ if (!Number.isFinite(pid) || !name)
96
+ continue;
97
+ const disks = [...trimmed.matchAll(/file=([^,\s]+)/g)].map((match) => match[1]);
98
+ guests.push({ name, pid, disks });
99
+ }
100
+ return guests;
101
+ }
102
+ var deviceInUse = (census, device) => census.some((guest) => guest.disks.includes(device));
103
+ function shapeEgressCommands(tap, mbps, burstMbps = mbps * 2) {
104
+ if (!/^[a-z][a-z0-9]{0,14}$/.test(tap)) {
105
+ throw new ComputeError("BAD_SPEC", `"${tap}" is not a device name`);
106
+ }
107
+ if (mbps <= 0)
108
+ return [
109
+ `tc qdisc del dev ${tap} root 2>/dev/null || true`,
110
+ `tc qdisc del dev ${tap} ingress 2>/dev/null || true`
111
+ ];
112
+ const ceiling = `${Math.floor(Math.max(burstMbps, mbps))}mbit`;
113
+ return [
114
+ `tc qdisc del dev ${tap} root 2>/dev/null || true`,
115
+ `tc qdisc del dev ${tap} ingress 2>/dev/null || true`,
116
+ `tc qdisc add dev ${tap} handle ffff: ingress`,
117
+ `tc filter add dev ${tap} parent ffff: protocol all u32 match u32 0 0 action police rate ${ceiling} burst 16mb conform-exceed drop`
118
+ ];
119
+ }
120
+ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
121
+ if (!/^[a-z][a-z0-9]{0,14}$/.test(tap)) {
122
+ throw new ComputeError("BAD_SPEC", `"${tap}" is not a device name`);
123
+ }
124
+ if (!Number.isInteger(guaranteedMbps) || guaranteedMbps < 0 || !Number.isInteger(burstMbps) || burstMbps < guaranteedMbps) {
125
+ throw new ComputeError("BAD_SPEC", "egress rates must be whole numbers and burst must cover the guarantee");
126
+ }
127
+ const clear = [
128
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} root`,
129
+ `ExecStartPre=-/usr/sbin/tc qdisc del dev ${tap} ingress`
130
+ ];
131
+ if (guaranteedMbps === 0)
132
+ return clear;
133
+ return [
134
+ ...clear,
135
+ `ExecStartPre=/usr/sbin/tc qdisc add dev ${tap} handle ffff: ingress`,
136
+ `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`
137
+ ];
138
+ }
139
+ var tapFor = (guestIndex) => `tap${guestIndex}`;
140
+
141
+ // src/provision.ts
142
+ function atLeast(version, floor) {
143
+ const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
144
+ const got = parse(version);
145
+ const want = parse(floor);
146
+ if (got.length === 0)
147
+ return false;
148
+ for (let index = 0;index < want.length; index += 1) {
149
+ const a = got[index] ?? 0;
150
+ const b = want[index] ?? 0;
151
+ if (a > b)
152
+ return true;
153
+ if (a < b)
154
+ return false;
155
+ }
156
+ return true;
157
+ }
158
+ var CAPABILITY_CHECKS = {
159
+ snpGuest: {
160
+ command: "test -e /dev/sev-guest && echo yes || echo no",
161
+ satisfied: (stdout) => stdout.trim() === "yes",
162
+ remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
163
+ },
164
+ systemd: {
165
+ command: "test -d /run/systemd/system && echo yes || echo no",
166
+ satisfied: (stdout) => stdout.trim() === "yes",
167
+ remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
168
+ },
169
+ bun: {
170
+ command: "bun --version 2>/dev/null || echo missing",
171
+ satisfied: (stdout) => atLeast(stdout, "1.1.0"),
172
+ remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
173
+ },
174
+ python: {
175
+ command: "python3 --version 2>/dev/null || echo missing",
176
+ satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
177
+ remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
178
+ }
179
+ };
180
+ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
181
+ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
182
+ var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
183
+ var DEPLOYMENT_GROUP = "forgezero-deploy";
184
+ var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
185
+ var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
186
+ var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
187
+ var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
188
+ function agentEnrolmentUnit(options) {
189
+ if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
190
+ throw new Error("direct enrolment needs API, credential and state paths");
191
+ }
192
+ const bin = options.binPath ?? "fz-agent";
193
+ const user = options.user ?? "forgezero";
194
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
195
+ const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
196
+ ` : "";
197
+ const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
198
+ ` : "";
199
+ const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
200
+ return `[Unit]
201
+ Description=Bind this machine to its ForgeZero compute
202
+ After=network-online.target
203
+ Wants=network-online.target
204
+ Before=forgezero-agent.service
205
+ ConditionPathExists=!${options.enrolStatePath}
206
+
207
+ [Service]
208
+ Type=oneshot
209
+ User=${user}
210
+ Group=${user}
211
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
212
+ LoadCredentialEncrypted=enrol-token:${options.enrolTokenCredentialPath}
213
+ Environment=FZ_SEED_CREDENTIAL=agent-seed
214
+ Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
215
+ Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
216
+ Environment=FZ_API=${options.apiUrl}
217
+ ${label}${gitPublicKey}ExecStart=${bin} enrol
218
+ # A '+' fixed command runs as root solely to remove the host-bound one-time
219
+ # ciphertext. Tenant code and the agent never receive a privilege boundary.
220
+ ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
221
+ NoNewPrivileges=true
222
+ PrivateTmp=true
223
+ ProtectSystem=strict
224
+ ProtectHome=true
225
+ ReadWritePaths=${stateDir}
226
+ LimitCORE=0
227
+
228
+ [Install]
229
+ WantedBy=multi-user.target
230
+ `;
231
+ }
232
+ function deploymentRunnerSocketUnit(agentUser) {
233
+ return `[Unit]
234
+ Description=ForgeZero private project-command socket
235
+
236
+ [Socket]
237
+ ListenStream=${DEPLOYMENT_RUNNER_SOCKET}
238
+ SocketUser=${agentUser}
239
+ SocketGroup=${agentUser}
240
+ SocketMode=0600
241
+ DirectoryMode=0710
242
+ RemoveOnStop=true
243
+
244
+ [Install]
245
+ WantedBy=sockets.target
246
+ `;
247
+ }
248
+ function deploymentRunnerUnit(options) {
249
+ const bin = options.binPath ?? "fz-agent";
250
+ const root = options.deployRoot ?? "/opt/forgezero";
251
+ return `[Unit]
252
+ Description=ForgeZero credential-free project command runner
253
+ Documentation=https://www.forgezero.net/docs/agent
254
+ After=forgezero-deploy-runner.socket
255
+ Requires=forgezero-deploy-runner.socket
256
+
257
+ [Service]
258
+ Type=simple
259
+ User=${DEPLOYMENT_RUNNER_USER}
260
+ Group=${DEPLOYMENT_GROUP}
261
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
262
+ Sockets=forgezero-deploy-runner.socket
263
+ ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
264
+ Restart=always
265
+ RestartSec=2
266
+ UMask=0007
267
+ LimitCORE=0
268
+ NoNewPrivileges=false
269
+ PrivateTmp=true
270
+ ProtectSystem=strict
271
+ ProtectHome=true
272
+ ProtectKernelTunables=true
273
+ ProtectKernelModules=true
274
+ ProtectControlGroups=true
275
+ RestrictRealtime=true
276
+ MemoryDenyWriteExecute=true
277
+ LockPersonality=true
278
+ ReadWritePaths=${root}/releases ${root}/runner-home
279
+
280
+ [Install]
281
+ WantedBy=multi-user.target
282
+ `;
283
+ }
284
+ function agentUnit(options) {
285
+ const bin = options.binPath ?? "fz-agent";
286
+ const user = options.user ?? "forgezero";
287
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
288
+ const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
289
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
290
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
291
+ const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
292
+ const deploymentEnvironment = options.deploymentEnvironment ?? {};
293
+ const deploymentCredentials = options.deploymentCredentials ?? {};
294
+ for (const [name, value] of Object.entries(deploymentEnvironment)) {
295
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !/^[A-Za-z0-9._:\/@+-]+$/.test(value)) {
296
+ throw new Error(`invalid deployment environment entry: ${name}`);
297
+ }
298
+ }
299
+ for (const [name, path] of Object.entries(deploymentCredentials)) {
300
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path.startsWith("/") || /[\r\n:]/.test(path)) {
301
+ throw new Error(`invalid deployment credential entry: ${name}`);
302
+ }
303
+ }
304
+ const environment = [
305
+ `FZ_SOCKET_PATH=${options.socketPath}`,
306
+ `FZ_CONTROL_SOCKET=${controlSocketPath}`,
307
+ `FZ_SEED_CREDENTIAL=agent-seed`,
308
+ `FZ_AGENT_MODE=${options.mode}`,
309
+ options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
310
+ options.project ? `FZ_PROJECT=${options.project}` : null,
311
+ options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
312
+ options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
313
+ options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
314
+ options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
315
+ options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
316
+ options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
317
+ options.role ? `FZ_DEPLOY_ROLE=${options.role}` : null,
318
+ options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
319
+ deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
320
+ deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
321
+ Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
322
+ Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
323
+ ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
324
+ options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
325
+ options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
326
+ ].filter((line) => line !== null);
327
+ if (deploymentEnabled) {
328
+ environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
329
+ }
330
+ const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
331
+ ` : "";
332
+ const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
333
+ `);
334
+ const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
335
+ const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
336
+ const after = [
337
+ "network-online.target",
338
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
339
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
340
+ ].filter((value) => value !== null);
341
+ const requires = [
342
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
343
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
344
+ ].filter((value) => value !== null);
345
+ const deploymentDependency = [
346
+ `After=${after.join(" ")}`,
347
+ "Wants=network-online.target",
348
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null
349
+ ].filter((value) => value !== null).join(`
350
+ `);
351
+ const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
352
+ DeviceAllow=/dev/sev-guest rw` : "";
353
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
354
+ ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
355
+ ` : "";
356
+ return `[Unit]
357
+ Description=ForgeZero node agent (${options.mode})
358
+ Documentation=https://www.forgezero.net/docs/agent
359
+ ${deploymentDependency}
360
+
361
+ [Service]
362
+ Type=simple
363
+ User=${user}
364
+ Group=${user}
365
+ ${deploymentGroup}
366
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
367
+ ${gitCredential}${projectCredentials}${projectCredentials ? `
368
+ ` : ""}${snpPrepare}ExecStart=${bin}
369
+ Restart=always
370
+ RestartSec=2
371
+
372
+ ${environment.map((line) => `Environment=${line}`).join(`
373
+ `)}
374
+
375
+ # The node seed and the vault replica live in this process's memory. A core dump
376
+ # writes both to disk, which is the one artefact this design exists to remove.
377
+ LimitCORE=0
378
+
379
+ # The socket is the entire interface: anything that can read it can read the
380
+ # scope. So it lives in a directory systemd creates with a known owner rather
381
+ # than wherever the process happened to have write access.
382
+ RuntimeDirectory=forgezero
383
+ RuntimeDirectoryMode=0710
384
+ UMask=0077
385
+
386
+ # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
387
+ # credential-bearing process never needs to cross a privilege boundary.
388
+ NoNewPrivileges=true
389
+ PrivateTmp=true
390
+ ProtectSystem=strict
391
+ ProtectHome=true
392
+ ProtectKernelTunables=true
393
+ ProtectKernelModules=true
394
+ ProtectControlGroups=true
395
+ RestrictSUIDSGID=true
396
+ RestrictRealtime=true
397
+ MemoryDenyWriteExecute=true
398
+ LockPersonality=true
399
+ ${snpDevice}
400
+ ${deploymentWrites}
401
+
402
+ [Install]
403
+ WantedBy=multi-user.target
404
+ `;
405
+ }
406
+ var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
407
+ function planProvision(options) {
408
+ const mode = options.mode;
409
+ const user = options.user ?? "forgezero";
410
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
411
+ const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
412
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
413
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
414
+ const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
415
+ if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
416
+ throw new Error("direct enrolment paths must be supplied together");
417
+ const safePath = (value, label) => {
418
+ if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
419
+ throw new Error(`invalid ${label} path`);
420
+ return value;
421
+ };
422
+ const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
423
+ const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
424
+ const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
425
+ const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
426
+ const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
427
+ const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
428
+ const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
429
+ const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
430
+ if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
431
+ throw new Error("generated Git identity needs credential and public-key paths");
432
+ }
433
+ const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
434
+ return {
435
+ mode,
436
+ reason: reasonFor(mode),
437
+ unitPath: UNIT_PATH,
438
+ unit: agentUnit({ ...options, mode }),
439
+ auxiliaryUnits: [
440
+ ...deploymentEnabled ? [
441
+ { path: DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH, unit: deploymentRunnerSocketUnit(user) },
442
+ { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
443
+ ] : [],
444
+ ...enrolmentEnabled ? [
445
+ { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
446
+ ] : []
447
+ ],
448
+ socketPath: options.socketPath,
449
+ user,
450
+ steps: [
451
+ ...sourceBinPath && binPath ? [{
452
+ label: "root-owned agent runtime",
453
+ command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
454
+ }] : [],
455
+ ...deploymentEnabled ? [{
456
+ label: "deployment isolation group",
457
+ command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
458
+ }] : [],
459
+ {
460
+ label: "service account",
461
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
462
+ },
463
+ ...deploymentEnabled ? [{
464
+ label: "credential-free deployment account",
465
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
466
+ }] : [],
467
+ {
468
+ label: "credential directory",
469
+ command: `install -d -o root -g root -m 0700 ${credentialDir}`
470
+ },
471
+ {
472
+ label: "encrypted node identity",
473
+ command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
474
+ },
475
+ ...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
476
+ {
477
+ label: "Git deploy identity directory",
478
+ command: `install -d -o root -g root -m 0755 ${gitPublicKeyDir}`
479
+ },
480
+ {
481
+ label: "unique encrypted Git deploy identity",
482
+ command: `test -s ${gitCredentialPath} || { ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/forgezero-git-deploy-key; ` + `systemd-creds encrypt --name=git-deploy-key /run/forgezero-git-deploy-key ${gitCredentialPath}; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `chmod 0400 ${gitCredentialPath}; }; ` + `test -s ${gitPublicKeyPath} || { ` + `systemd-creds decrypt --name=git-deploy-key ${gitCredentialPath} /run/forgezero-git-deploy-key; ` + `ssh-keygen -y -f /run/forgezero-git-deploy-key | ` + `sed 's/$/ forgezero-compute/' > /run/forgezero-git-deploy-key.pub; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; }; ` + `test -s ${gitCredentialPath} && test -s ${gitPublicKeyPath}`
483
+ }
484
+ ] : [],
485
+ ...enrolmentEnabled ? [
486
+ {
487
+ label: "enrolment state directory",
488
+ command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
489
+ },
490
+ {
491
+ label: "encrypted one-time enrolment capability",
492
+ command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
493
+ }
494
+ ] : [],
495
+ ...deploymentEnabled ? [{
496
+ label: "deployment directories",
497
+ command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
498
+ }] : [],
499
+ { label: "reload units", command: "systemctl daemon-reload" },
500
+ {
501
+ label: "enable and start",
502
+ command: `systemctl enable --now ${[
503
+ ...deploymentEnabled ? ["forgezero-deploy-runner.socket", "forgezero-deploy-runner.service"] : [],
504
+ ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
505
+ "forgezero-agent.service"
506
+ ].join(" ")}`
507
+ },
508
+ ...enrolmentEnabled ? [{
509
+ label: "prove the compute binding is durable",
510
+ command: `test -s ${enrolStatePath}`
511
+ }] : [],
512
+ { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
513
+ { label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
514
+ ...deploymentEnabled ? [{
515
+ label: "prove the deployment runner socket exists",
516
+ command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
517
+ }] : [],
518
+ ...options.repository ? [{
519
+ label: "prove the deployment control socket exists",
520
+ command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
521
+ }] : []
522
+ ]
523
+ };
524
+ }
525
+
526
+ // src/metal-provision.ts
527
+ import { createHash } from "node:crypto";
528
+ import {
529
+ existsSync,
530
+ mkdirSync,
531
+ readFileSync,
532
+ readdirSync,
533
+ statSync,
534
+ unlinkSync,
535
+ writeFileSync
536
+ } from "node:fs";
537
+ import { dirname, isAbsolute, join } from "node:path";
538
+ var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
539
+ var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
540
+ var SHA256 = /^[a-f0-9]{64}$/;
541
+ 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)$/;
542
+ var LINUX_LIST = /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/;
543
+
544
+ class MetalProvisionError extends Error {
545
+ }
546
+ function membersOfLinuxList(value, label) {
547
+ if (!LINUX_LIST.test(value))
548
+ throw new MetalProvisionError(`invalid ${label} list`);
549
+ const members = [];
550
+ for (const part of value.split(",")) {
551
+ const [startText, endText = startText] = part.split("-");
552
+ const start = Number(startText);
553
+ const end = Number(endText);
554
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > 65535) {
555
+ throw new MetalProvisionError(`invalid ${label} list`);
556
+ }
557
+ for (let value2 = start;value2 <= end; value2 += 1)
558
+ members.push(value2);
559
+ }
560
+ if (new Set(members).size !== members.length)
561
+ throw new MetalProvisionError(`${label} list overlaps itself`);
562
+ return members;
563
+ }
564
+ var guestNameFor = (computeKey) => `fzg-${createHash("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
565
+ var tapNameFor = (computeKey) => `fzt${createHash("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
566
+ var macForAddress = (address) => {
567
+ const octets = address.split(".").map(Number);
568
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
569
+ throw new MetalProvisionError("invalid guest address");
570
+ }
571
+ return `52:54:00:f0:${octets[2].toString(16).padStart(2, "0")}:${octets[3].toString(16).padStart(2, "0")}`;
572
+ };
573
+ function validateMetalProfile(profile) {
574
+ if (!SAFE_NAME.test(profile.volumeGroup))
575
+ throw new MetalProvisionError("invalid volume group");
576
+ if (!DEVICE.test(profile.bridge))
577
+ throw new MetalProvisionError("invalid bridge");
578
+ if (!IPV4_PREFIX.test(profile.subnetPrefix))
579
+ throw new MetalProvisionError("invalid subnet prefix");
580
+ if (!Number.isInteger(profile.addressStart) || !Number.isInteger(profile.addressEnd) || profile.addressStart < 2 || profile.addressEnd > 254 || profile.addressStart > profile.addressEnd)
581
+ throw new MetalProvisionError("invalid guest address range");
582
+ for (const path of [profile.stateDir, profile.seedDir, profile.unitDir]) {
583
+ if (!isAbsolute(path))
584
+ throw new MetalProvisionError("metal paths must be absolute");
585
+ }
586
+ new URL(profile.apiUrl);
587
+ if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
588
+ throw new MetalProvisionError("at least one exclusive CPU pool is required");
589
+ }
590
+ const keys = new Set;
591
+ const assigned = new Set;
592
+ const assignedMemory = new Set;
593
+ let poolsWithMemory = 0;
594
+ for (const pool of profile.cpuPools) {
595
+ if (!SAFE_NAME.test(pool.key) || keys.has(pool.key))
596
+ throw new MetalProvisionError("invalid or duplicate CPU pool key");
597
+ keys.add(pool.key);
598
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
599
+ if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
600
+ throw new MetalProvisionError("invalid CPU pool physical-core count");
601
+ }
602
+ for (const cpu of cpus) {
603
+ if (assigned.has(cpu))
604
+ throw new MetalProvisionError("CPU pools overlap");
605
+ assigned.add(cpu);
606
+ }
607
+ if (pool.memoryNodes) {
608
+ poolsWithMemory += 1;
609
+ for (const node of membersOfLinuxList(pool.memoryNodes, "memory-node")) {
610
+ if (assignedMemory.has(node))
611
+ throw new MetalProvisionError("guest memory-node pools overlap");
612
+ assignedMemory.add(node);
613
+ }
614
+ }
615
+ }
616
+ if (poolsWithMemory !== 0 && poolsWithMemory !== profile.cpuPools.length) {
617
+ throw new MetalProvisionError("every CPU pool must name memory nodes when NUMA isolation is enabled");
618
+ }
619
+ const housekeeping = membersOfLinuxList(profile.housekeepingCpus, "housekeeping CPU");
620
+ if (housekeeping.some((cpu) => assigned.has(cpu))) {
621
+ throw new MetalProvisionError("housekeeping CPUs overlap guest CPU pools");
622
+ }
623
+ if (profile.housekeepingMemoryNodes) {
624
+ const housekeepingMemory = membersOfLinuxList(profile.housekeepingMemoryNodes, "housekeeping memory-node");
625
+ if (housekeepingMemory.some((node) => assignedMemory.has(node))) {
626
+ throw new MetalProvisionError("housekeeping memory nodes overlap guest memory-node pools");
627
+ }
628
+ } else if (assignedMemory.size > 0) {
629
+ throw new MetalProvisionError("NUMA-isolated guest pools require housekeeping memory nodes");
630
+ }
631
+ }
632
+ var readManifests = (stateDir) => {
633
+ if (!existsSync(stateDir))
634
+ return [];
635
+ return readdirSync(stateDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync(join(stateDir, name), "utf8")));
636
+ };
637
+ function allocateAddress(profile, computeKey, rows) {
638
+ const existing = rows.find((row) => row.computeKey === computeKey);
639
+ if (existing)
640
+ return existing.address;
641
+ const used = new Set(rows.map((row) => row.address));
642
+ const width = profile.addressEnd - profile.addressStart + 1;
643
+ const start = createHash("sha256").update(computeKey).digest().readUInt16BE(0) % width;
644
+ for (let offset = 0;offset < width; offset += 1) {
645
+ const last = profile.addressStart + (start + offset) % width;
646
+ const address = `${profile.subnetPrefix}.${last}`;
647
+ if (!used.has(address))
648
+ return address;
649
+ }
650
+ throw new MetalProvisionError("guest address range is full");
651
+ }
652
+ function allocateCpuPool(profile, claim, rows) {
653
+ const prior = rows.find((row) => row.computeKey === claim.computeKey);
654
+ if (prior) {
655
+ const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
656
+ if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
657
+ throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
658
+ }
659
+ return retained;
660
+ }
661
+ const used = new Set(rows.map((row) => row.cpuPoolKey));
662
+ 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));
663
+ const selected = candidates[0];
664
+ if (!selected)
665
+ throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
666
+ return selected;
667
+ }
668
+ var base64 = (value) => Buffer.from(value).toString("base64");
669
+ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)}
670
+ permissions: '${permissions}'
671
+ encoding: b64
672
+ content: ${base64(content)}
673
+ `;
674
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
675
+ const agentBun = "/usr/local/lib/forgezero/bun";
676
+ const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
677
+ # healthy and encrypted while attestation is silently impossible. Install and
678
+ # load the exact running-kernel module before the agent is allowed to start.
679
+ DEBIAN_FRONTEND=noninteractive apt-get install -y linux-image-generic "linux-modules-extra-$(uname -r)"
680
+ printf 'sev-guest
681
+ ' >/etc/modules-load.d/sev-guest.conf
682
+ modprobe sev-guest
683
+ test -c /dev/sev-guest
684
+ ` : "";
685
+ return `#!/usr/bin/env bash
686
+ set -Eeuo pipefail
687
+ ${attestationSetup}useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
688
+ groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
689
+ useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
690
+ usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
691
+ install -d -o root -g root -m 0700 /etc/forgezero/creds
692
+ install -d -o root -g root -m 0755 /etc/forgezero/git
693
+ install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
694
+ install -d -o root -g root -m 0755 /opt/forgezero
695
+ install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
696
+ install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
697
+ install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
698
+ if [[ -s /run/forgezero-enrol-token ]]; then
699
+ systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
700
+ rm -f /run/forgezero-enrol-token
701
+ fi
702
+ chown root:root /var/lib/forgezero/enrol-token.cred
703
+ chmod 0400 /var/lib/forgezero/enrol-token.cred
704
+ if [[ ! -x /usr/local/bin/bun ]]; then
705
+ curl -fsSL https://bun.sh/install -o /run/fz-bun-install
706
+ printf '%s %s
707
+ ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
708
+ BUN_INSTALL=${agentBun} BUN_VERSION=${profile.bunVersion} bash /run/fz-bun-install
709
+ install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
710
+ rm -f /run/fz-bun-install
711
+ fi
712
+ if [[ ! -x /usr/local/bin/fz-agent ]]; then
713
+ env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g @forgezero/agent@${profile.agentVersion}
714
+ ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
715
+ fi
716
+ if [[ ! -s /etc/forgezero/creds/agent-seed.cred ]]; then
717
+ umask 077
718
+ openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
719
+ ' >/run/fz-agent-seed
720
+ systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
721
+ rm -f /run/fz-agent-seed
722
+ fi
723
+ chmod 0400 /etc/forgezero/creds/agent-seed.cred
724
+ if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
725
+ umask 077
726
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
727
+ ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
728
+ systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
729
+ install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
730
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
731
+ fi
732
+ if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
733
+ systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
734
+ ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
735
+ install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
736
+ rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
737
+ fi
738
+ chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
739
+ systemctl daemon-reload
740
+ systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
741
+ `;
742
+ }
743
+ function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
744
+ const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp forgezero-agent /dev/sev-guest
745
+ ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
746
+ ` : "";
747
+ const attestationDevice = attested ? `DevicePolicy=closed
748
+ DeviceAllow=/dev/sev-guest rw
749
+ ` : "";
750
+ return `[Unit]
751
+ Description=ForgeZero compute agent (${name})
752
+ After=network-online.target forgezero-deploy-runner.service
753
+ Wants=network-online.target
754
+ Requires=forgezero-deploy-runner.service
755
+
756
+ [Service]
757
+ Type=simple
758
+ User=forgezero-agent
759
+ Group=forgezero-agent
760
+ SupplementaryGroups=${DEPLOYMENT_GROUP}
761
+ LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
762
+ LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
763
+ Environment=FZ_SEED_CREDENTIAL=agent-seed
764
+ Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
765
+ Environment=FZ_API=${profile.apiUrl}
766
+ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
767
+ Environment=FZ_NODE_LABEL=${name}
768
+ Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
769
+ Environment=FZ_DEPLOY_ROOT=/opt/forgezero
770
+ Environment=FZ_DEPLOY_PULL=true
771
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
772
+ Environment=HOME=/opt/forgezero/home
773
+ ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
774
+ Restart=on-failure
775
+ RestartSec=5
776
+ RuntimeDirectory=forgezero
777
+ RuntimeDirectoryMode=0710
778
+ UMask=0077
779
+ LimitCORE=0
780
+ NoNewPrivileges=true
781
+ PrivateTmp=true
782
+ ProtectSystem=strict
783
+ ProtectHome=true
784
+ ReadWritePaths=/var/lib/forgezero /opt/forgezero
785
+ ${attestationDevice}
786
+
787
+ [Install]
788
+ WantedBy=multi-user.target
789
+ `;
790
+ }
791
+ function guestEnrolmentDropIn() {
792
+ return `[Service]
793
+ LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
794
+ Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
795
+ `;
796
+ }
797
+ function guestEnrolmentCleanupScript() {
798
+ return `#!/usr/bin/env bash
799
+ set -Eeuo pipefail
800
+ for _ in $(seq 1 180); do
801
+ [[ -s /var/lib/forgezero/enrolment.json ]] && break
802
+ sleep 1
803
+ done
804
+ [[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
805
+ rm -f /var/lib/forgezero/enrol-token.cred
806
+ rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
807
+ systemctl daemon-reload
808
+ systemctl disable forgezero-enrolment-cleanup.service
809
+ `;
810
+ }
811
+ function guestEnrolmentCleanupUnit() {
812
+ return `[Unit]
813
+ Description=Remove the consumed ForgeZero guest enrolment credential
814
+ After=forgezero-agent.service
815
+ Requires=forgezero-agent.service
816
+ ConditionPathExists=/var/lib/forgezero/enrol-token.cred
817
+
818
+ [Service]
819
+ Type=oneshot
820
+ ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
821
+ TimeoutStartSec=4min
822
+
823
+ [Install]
824
+ WantedBy=multi-user.target
825
+ `;
826
+ }
827
+ function cloudInit(profile, claim, manifest) {
828
+ const bootstrap = guestBootstrapScript(profile, claim.spec.confidential);
829
+ const agentUnit2 = guestAgentUnit(profile, manifest.name, claim.spec.confidential);
830
+ const enrolmentDropIn = guestEnrolmentDropIn();
831
+ const cleanupScript = guestEnrolmentCleanupScript();
832
+ const cleanupUnit = guestEnrolmentCleanupUnit();
833
+ const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
834
+ const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
835
+ return {
836
+ userData: `#cloud-config
837
+ package_update: true
838
+ # Git is part of the deployment transport, not a tenant-selected prerequisite:
839
+ # every dynamically claimed repository must be cloneable on a clean image.
840
+ packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
841
+ write_files:
842
+ ${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
843
+ `, "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", agentUnit2, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}runcmd:
844
+ - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
845
+ - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
846
+ `,
847
+ metaData: `instance-id: ${manifest.name}-${claim.attempt}
848
+ local-hostname: ${manifest.name}
849
+ `,
850
+ networkConfig: `version: 2
851
+ ethernets:
852
+ primary:
853
+ match: { name: "en*" }
854
+ addresses: [ ${manifest.address}/24 ]
855
+ gateway4: ${profile.gateway}
856
+ nameservers: { addresses: [${(profile.nameservers ?? ["1.1.1.1", "9.9.9.9"]).join(", ")}] }
857
+ `
858
+ };
859
+ }
860
+ var checked = async (exec, argv) => {
861
+ const result = await exec(argv);
862
+ if (result.exitCode !== 0) {
863
+ throw new MetalProvisionError(`${argv[0]} failed: ${(result.stderr || result.stdout).trim()}`);
864
+ }
865
+ return result;
866
+ };
867
+ async function provisionMetalGuest(profile, claim, exec) {
868
+ validateMetalProfile(profile);
869
+ 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)
870
+ throw new MetalProvisionError("invalid compute claim");
871
+ const image = profile.images[claim.spec.imageKey];
872
+ if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
873
+ throw new MetalProvisionError(`image ${claim.spec.imageKey} is not configured locally`);
874
+ }
875
+ if (!statSync(image.path).isFile())
876
+ throw new MetalProvisionError("configured image is not a file");
877
+ const digest = (await checked(exec, ["sha256sum", image.path])).stdout.trim().split(/\s+/)[0];
878
+ if (digest !== image.sha256)
879
+ throw new MetalProvisionError("configured image checksum mismatch");
880
+ for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
881
+ mkdirSync(path, { recursive: true, mode: 448 });
882
+ const manifests = readManifests(profile.stateDir);
883
+ const name = guestNameFor(claim.computeKey);
884
+ const manifestPath = join(profile.stateDir, `${name}.json`);
885
+ const prior = manifests.find((row) => row.computeKey === claim.computeKey);
886
+ if (prior && prior.reference !== claim.spec.reference)
887
+ throw new MetalProvisionError("compute identity conflicts with host inventory");
888
+ const address = allocateAddress(profile, claim.computeKey, manifests);
889
+ const cpuPool = allocateCpuPool(profile, claim, manifests);
890
+ const manifest = prior ?? {
891
+ computeKey: claim.computeKey,
892
+ reference: claim.spec.reference,
893
+ name,
894
+ address,
895
+ mac: macForAddress(address),
896
+ cpuPoolKey: cpuPool.key,
897
+ allowedCpus: cpuPool.cpus,
898
+ allowedMemoryNodes: cpuPool.memoryNodes,
899
+ phase: "allocating"
900
+ };
901
+ const save = () => writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
902
+ `, { mode: 384 });
903
+ save();
904
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
905
+ const exists = (await exec(["lvs", "--noheadings", lv])).exitCode === 0;
906
+ if (!exists)
907
+ await checked(exec, ["lvcreate", "-y", "-n", name, "-L", `${claim.spec.diskGib}G`, profile.volumeGroup]);
908
+ if (manifest.phase === "allocating") {
909
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, lv]);
910
+ manifest.phase = "image-ready";
911
+ save();
912
+ }
913
+ const seedBase = join(profile.seedDir, name);
914
+ const init = cloudInit(profile, claim, manifest);
915
+ writeFileSync(`${seedBase}-user-data`, init.userData, { mode: 384 });
916
+ writeFileSync(`${seedBase}-meta-data`, init.metaData, { mode: 384 });
917
+ writeFileSync(`${seedBase}-network-config`, init.networkConfig, { mode: 384 });
918
+ const seed = `${seedBase}-seed.iso`;
919
+ await checked(exec, [
920
+ "cloud-localds",
921
+ "-N",
922
+ `${seedBase}-network-config`,
923
+ seed,
924
+ `${seedBase}-user-data`,
925
+ `${seedBase}-meta-data`
926
+ ]);
927
+ const spec = {
928
+ name,
929
+ vcpu: claim.spec.vcpu,
930
+ memoryGib: claim.spec.memoryGib,
931
+ allowedCpus: manifest.allowedCpus,
932
+ allowedMemoryNodes: manifest.allowedMemoryNodes,
933
+ disk: lv,
934
+ seed,
935
+ bridge: profile.bridge,
936
+ mac: manifest.mac,
937
+ tap: tapNameFor(claim.computeKey),
938
+ egress: {
939
+ guaranteedMbps: claim.spec.egressGuaranteedMbps,
940
+ burstMbps: claim.spec.egressBurstMbps
941
+ },
942
+ confidential: claim.spec.confidential ? profile.confidential : undefined,
943
+ consoleLog: `/var/log/forgezero/${name}.log`
944
+ };
945
+ if (claim.spec.confidential && !spec.confidential) {
946
+ throw new MetalProvisionError("confidential compute requested but host SNP profile is absent");
947
+ }
948
+ const service = `forgezero-guest@${name}.service`;
949
+ const unitPath = join(profile.unitDir, service);
950
+ mkdirSync(dirname(unitPath), { recursive: true });
951
+ writeFileSync(unitPath, guestUnit(spec), { mode: 420 });
952
+ await checked(exec, ["systemctl", "daemon-reload"]);
953
+ if (prior?.phase === "running") {
954
+ await checked(exec, ["systemctl", "restart", service]);
955
+ } else {
956
+ await checked(exec, ["systemctl", "enable", "--now", service]);
957
+ }
958
+ await checked(exec, ["systemctl", "is-active", service]);
959
+ manifest.phase = "running";
960
+ save();
961
+ return { guestAddress: address };
962
+ }
963
+ async function removeMetalGuest(profile, claim, exec) {
964
+ validateMetalProfile(profile);
965
+ if (claim.action !== "delete")
966
+ throw new MetalProvisionError("create claim cannot remove a guest");
967
+ const name = guestNameFor(claim.computeKey);
968
+ const manifestPath = join(profile.stateDir, `${name}.json`);
969
+ if (!existsSync(manifestPath))
970
+ return {};
971
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
972
+ if (manifest.computeKey !== claim.computeKey || manifest.reference !== claim.spec.reference || manifest.name !== name) {
973
+ throw new MetalProvisionError("compute identity conflicts with host inventory");
974
+ }
975
+ const service = `forgezero-guest@${name}.service`;
976
+ const unitPath = join(profile.unitDir, service);
977
+ if (existsSync(unitPath))
978
+ await checked(exec, ["systemctl", "disable", "--now", service]);
979
+ else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
980
+ throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
981
+ }
982
+ const lv = `/dev/${profile.volumeGroup}/${name}`;
983
+ if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
984
+ await checked(exec, ["lvremove", "-fy", lv]);
985
+ for (const path of [
986
+ unitPath,
987
+ join(profile.seedDir, `${name}-seed.iso`),
988
+ join(profile.seedDir, `${name}-user-data`),
989
+ join(profile.seedDir, `${name}-meta-data`),
990
+ join(profile.seedDir, `${name}-network-config`),
991
+ manifestPath
992
+ ])
993
+ if (existsSync(path))
994
+ unlinkSync(path);
995
+ await checked(exec, ["systemctl", "daemon-reload"]);
996
+ return {};
997
+ }
998
+ export {
999
+ validateMetalProfile,
1000
+ tapNameFor,
1001
+ removeMetalGuest,
1002
+ provisionMetalGuest,
1003
+ macForAddress,
1004
+ guestNameFor,
1005
+ guestEnrolmentDropIn,
1006
+ guestEnrolmentCleanupUnit,
1007
+ guestEnrolmentCleanupScript,
1008
+ guestBootstrapScript,
1009
+ guestAgentUnit,
1010
+ cloudInit,
1011
+ allocateCpuPool,
1012
+ allocateAddress,
1013
+ MetalProvisionError
1014
+ };