@forgezero/agent 0.1.0 → 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 +2901 -155
  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 +91 -71
package/dist/provision.js CHANGED
@@ -30,32 +30,202 @@ var CAPABILITY_CHECKS = {
30
30
  command: "bun --version 2>/dev/null || echo missing",
31
31
  satisfied: (stdout) => atLeast(stdout, "1.1.0"),
32
32
  remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
33
+ },
34
+ python: {
35
+ command: "python3 --version 2>/dev/null || echo missing",
36
+ satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
37
+ remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
33
38
  }
34
39
  };
35
40
  var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
36
41
  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.";
42
+ var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
43
+ var DEPLOYMENT_GROUP = "forgezero-deploy";
44
+ var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
45
+ var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
46
+ var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
47
+ var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
48
+ function agentEnrolmentUnit(options) {
49
+ if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
50
+ throw new Error("direct enrolment needs API, credential and state paths");
51
+ }
52
+ const bin = options.binPath ?? "fz-agent";
53
+ const user = options.user ?? "forgezero";
54
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
55
+ const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
56
+ ` : "";
57
+ const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
58
+ ` : "";
59
+ const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
60
+ return `[Unit]
61
+ Description=Bind this machine to its ForgeZero compute
62
+ After=network-online.target
63
+ Wants=network-online.target
64
+ Before=forgezero-agent.service
65
+ ConditionPathExists=!${options.enrolStatePath}
66
+
67
+ [Service]
68
+ Type=oneshot
69
+ User=${user}
70
+ Group=${user}
71
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
72
+ LoadCredentialEncrypted=enrol-token:${options.enrolTokenCredentialPath}
73
+ Environment=FZ_SEED_CREDENTIAL=agent-seed
74
+ Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
75
+ Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
76
+ Environment=FZ_API=${options.apiUrl}
77
+ ${label}${gitPublicKey}ExecStart=${bin} enrol
78
+ # A '+' fixed command runs as root solely to remove the host-bound one-time
79
+ # ciphertext. Tenant code and the agent never receive a privilege boundary.
80
+ ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
81
+ NoNewPrivileges=true
82
+ PrivateTmp=true
83
+ ProtectSystem=strict
84
+ ProtectHome=true
85
+ ReadWritePaths=${stateDir}
86
+ LimitCORE=0
87
+
88
+ [Install]
89
+ WantedBy=multi-user.target
90
+ `;
91
+ }
92
+ function deploymentRunnerSocketUnit(agentUser) {
93
+ return `[Unit]
94
+ Description=ForgeZero private project-command socket
95
+
96
+ [Socket]
97
+ ListenStream=${DEPLOYMENT_RUNNER_SOCKET}
98
+ SocketUser=${agentUser}
99
+ SocketGroup=${agentUser}
100
+ SocketMode=0600
101
+ DirectoryMode=0710
102
+ RemoveOnStop=true
103
+
104
+ [Install]
105
+ WantedBy=sockets.target
106
+ `;
107
+ }
108
+ function deploymentRunnerUnit(options) {
109
+ const bin = options.binPath ?? "fz-agent";
110
+ const root = options.deployRoot ?? "/opt/forgezero";
111
+ return `[Unit]
112
+ Description=ForgeZero credential-free project command runner
113
+ Documentation=https://www.forgezero.net/docs/agent
114
+ After=forgezero-deploy-runner.socket
115
+ Requires=forgezero-deploy-runner.socket
116
+
117
+ [Service]
118
+ Type=simple
119
+ User=${DEPLOYMENT_RUNNER_USER}
120
+ Group=${DEPLOYMENT_GROUP}
121
+ Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
122
+ Sockets=forgezero-deploy-runner.socket
123
+ ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
124
+ Restart=always
125
+ RestartSec=2
126
+ UMask=0007
127
+ LimitCORE=0
128
+ NoNewPrivileges=false
129
+ PrivateTmp=true
130
+ ProtectSystem=strict
131
+ ProtectHome=true
132
+ ProtectKernelTunables=true
133
+ ProtectKernelModules=true
134
+ ProtectControlGroups=true
135
+ RestrictRealtime=true
136
+ MemoryDenyWriteExecute=true
137
+ LockPersonality=true
138
+ ReadWritePaths=${root}/releases ${root}/runner-home
139
+
140
+ [Install]
141
+ WantedBy=multi-user.target
142
+ `;
143
+ }
37
144
  function agentUnit(options) {
38
145
  const bin = options.binPath ?? "fz-agent";
39
146
  const user = options.user ?? "forgezero";
147
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
148
+ const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
149
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
150
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
151
+ const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
152
+ const deploymentEnvironment = options.deploymentEnvironment ?? {};
153
+ const deploymentCredentials = options.deploymentCredentials ?? {};
154
+ for (const [name, value] of Object.entries(deploymentEnvironment)) {
155
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !/^[A-Za-z0-9._:\/@+-]+$/.test(value)) {
156
+ throw new Error(`invalid deployment environment entry: ${name}`);
157
+ }
158
+ }
159
+ for (const [name, path] of Object.entries(deploymentCredentials)) {
160
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path.startsWith("/") || /[\r\n:]/.test(path)) {
161
+ throw new Error(`invalid deployment credential entry: ${name}`);
162
+ }
163
+ }
40
164
  const environment = [
41
165
  `FZ_SOCKET_PATH=${options.socketPath}`,
42
- `FZ_SEED_PATH=${options.seedPath}`,
166
+ `FZ_CONTROL_SOCKET=${controlSocketPath}`,
167
+ `FZ_SEED_CREDENTIAL=agent-seed`,
43
168
  `FZ_AGENT_MODE=${options.mode}`,
44
169
  options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
45
170
  options.project ? `FZ_PROJECT=${options.project}` : null,
46
- options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null
171
+ options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
172
+ options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
173
+ options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
174
+ options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
175
+ options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
176
+ options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
177
+ options.role ? `FZ_DEPLOY_ROLE=${options.role}` : null,
178
+ options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
179
+ deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
180
+ deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
181
+ Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
182
+ Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
183
+ ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
184
+ options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
185
+ options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
47
186
  ].filter((line) => line !== null);
187
+ if (deploymentEnabled) {
188
+ environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
189
+ }
190
+ const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
191
+ ` : "";
192
+ const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
193
+ `);
194
+ const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
195
+ const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
196
+ const after = [
197
+ "network-online.target",
198
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
199
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
200
+ ].filter((value) => value !== null);
201
+ const requires = [
202
+ deploymentEnabled ? "forgezero-deploy-runner.service" : null,
203
+ enrolmentEnabled ? "forgezero-agent-enrol.service" : null
204
+ ].filter((value) => value !== null);
205
+ const deploymentDependency = [
206
+ `After=${after.join(" ")}`,
207
+ "Wants=network-online.target",
208
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null
209
+ ].filter((value) => value !== null).join(`
210
+ `);
211
+ const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
212
+ DeviceAllow=/dev/sev-guest rw` : "";
213
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
214
+ ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
215
+ ` : "";
48
216
  return `[Unit]
49
217
  Description=ForgeZero node agent (${options.mode})
50
- Documentation=https://forgezero.net/docs/agent
51
- After=network-online.target
52
- Wants=network-online.target
218
+ Documentation=https://www.forgezero.net/docs/agent
219
+ ${deploymentDependency}
53
220
 
54
221
  [Service]
55
222
  Type=simple
56
223
  User=${user}
57
224
  Group=${user}
58
- ExecStart=${bin}
225
+ ${deploymentGroup}
226
+ LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
227
+ ${gitCredential}${projectCredentials}${projectCredentials ? `
228
+ ` : ""}${snpPrepare}ExecStart=${bin}
59
229
  Restart=always
60
230
  RestartSec=2
61
231
 
@@ -71,7 +241,10 @@ LimitCORE=0
71
241
  # than wherever the process happened to have write access.
72
242
  RuntimeDirectory=forgezero
73
243
  RuntimeDirectoryMode=0710
244
+ UMask=0077
74
245
 
246
+ # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
247
+ # credential-bearing process never needs to cross a privilege boundary.
75
248
  NoNewPrivileges=true
76
249
  PrivateTmp=true
77
250
  ProtectSystem=strict
@@ -83,7 +256,8 @@ RestrictSUIDSGID=true
83
256
  RestrictRealtime=true
84
257
  MemoryDenyWriteExecute=true
85
258
  LockPersonality=true
86
- ReadWritePaths=${options.seedPath.replace(/\/[^/]+$/, "")}
259
+ ${snpDevice}
260
+ ${deploymentWrites}
87
261
 
88
262
  [Install]
89
263
  WantedBy=multi-user.target
@@ -93,27 +267,118 @@ var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
93
267
  function planProvision(options) {
94
268
  const mode = options.mode;
95
269
  const user = options.user ?? "forgezero";
96
- const seedDir = options.seedPath.replace(/\/[^/]+$/, "");
270
+ const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
271
+ const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
272
+ const deployRoot = options.deployRoot ?? "/opt/forgezero";
273
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
274
+ const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
275
+ if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
276
+ throw new Error("direct enrolment paths must be supplied together");
277
+ const safePath = (value, label) => {
278
+ if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
279
+ throw new Error(`invalid ${label} path`);
280
+ return value;
281
+ };
282
+ const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
283
+ const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
284
+ const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
285
+ const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
286
+ const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
287
+ const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
288
+ const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
289
+ const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
290
+ if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
291
+ throw new Error("generated Git identity needs credential and public-key paths");
292
+ }
293
+ const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
97
294
  return {
98
295
  mode,
99
296
  reason: reasonFor(mode),
100
297
  unitPath: UNIT_PATH,
101
298
  unit: agentUnit({ ...options, mode }),
299
+ auxiliaryUnits: [
300
+ ...deploymentEnabled ? [
301
+ { path: DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH, unit: deploymentRunnerSocketUnit(user) },
302
+ { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
303
+ ] : [],
304
+ ...enrolmentEnabled ? [
305
+ { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
306
+ ] : []
307
+ ],
102
308
  socketPath: options.socketPath,
103
309
  user,
104
310
  steps: [
311
+ ...sourceBinPath && binPath ? [{
312
+ label: "root-owned agent runtime",
313
+ command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
314
+ }] : [],
315
+ ...deploymentEnabled ? [{
316
+ label: "deployment isolation group",
317
+ command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
318
+ }] : [],
105
319
  {
106
320
  label: "service account",
107
321
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
108
322
  },
323
+ ...deploymentEnabled ? [{
324
+ label: "credential-free deployment account",
325
+ command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
326
+ }] : [],
109
327
  {
110
- label: "seed directory",
111
- command: `install -d -o ${user} -g ${user} -m 0700 ${seedDir}`
328
+ label: "credential directory",
329
+ command: `install -d -o root -g root -m 0700 ${credentialDir}`
112
330
  },
331
+ {
332
+ label: "encrypted node identity",
333
+ command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
334
+ },
335
+ ...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
336
+ {
337
+ label: "Git deploy identity directory",
338
+ command: `install -d -o root -g root -m 0755 ${gitPublicKeyDir}`
339
+ },
340
+ {
341
+ label: "unique encrypted Git deploy identity",
342
+ 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}`
343
+ }
344
+ ] : [],
345
+ ...enrolmentEnabled ? [
346
+ {
347
+ label: "enrolment state directory",
348
+ command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
349
+ },
350
+ {
351
+ label: "encrypted one-time enrolment capability",
352
+ command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
353
+ }
354
+ ] : [],
355
+ ...deploymentEnabled ? [{
356
+ label: "deployment directories",
357
+ 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`
358
+ }] : [],
113
359
  { label: "reload units", command: "systemctl daemon-reload" },
114
- { label: "enable and start", command: "systemctl enable --now forgezero-agent.service" },
360
+ {
361
+ label: "enable and start",
362
+ command: `systemctl enable --now ${[
363
+ ...deploymentEnabled ? ["forgezero-deploy-runner.socket", "forgezero-deploy-runner.service"] : [],
364
+ ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
365
+ "forgezero-agent.service"
366
+ ].join(" ")}`
367
+ },
368
+ ...enrolmentEnabled ? [{
369
+ label: "prove the compute binding is durable",
370
+ command: `test -s ${enrolStatePath}`
371
+ }] : [],
115
372
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
116
- { label: "prove the socket exists", command: `test -S ${options.socketPath}` }
373
+ { label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
374
+ ...deploymentEnabled ? [{
375
+ label: "prove the deployment runner socket exists",
376
+ command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
377
+ }] : [],
378
+ ...options.repository ? [{
379
+ label: "prove the deployment control socket exists",
380
+ command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
381
+ }] : []
117
382
  ]
118
383
  };
119
384
  }
@@ -121,8 +386,17 @@ export {
121
386
  reasonFor,
122
387
  planProvision,
123
388
  modeFor,
389
+ deploymentRunnerUnit,
390
+ deploymentRunnerSocketUnit,
124
391
  atLeast,
125
392
  agentUnit,
393
+ agentEnrolmentUnit,
126
394
  UNIT_PATH,
395
+ ENROLMENT_UNIT_PATH,
396
+ DEPLOYMENT_RUNNER_USER,
397
+ DEPLOYMENT_RUNNER_UNIT_PATH,
398
+ DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH,
399
+ DEPLOYMENT_RUNNER_SOCKET,
400
+ DEPLOYMENT_GROUP,
127
401
  CAPABILITY_CHECKS
128
402
  };
@@ -0,0 +1,75 @@
1
+ import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
+ import { type SignedNodeHttpOptions } from './signed-node-http';
3
+ interface RemoteProvisionClaimBase {
4
+ computeKey: string;
5
+ claimToken: string;
6
+ claimExpiresAtTs: number;
7
+ attempt: number;
8
+ spec: {
9
+ reference: string;
10
+ imageKey: string;
11
+ physicalCores: number;
12
+ vcpu: number;
13
+ memoryGib: number;
14
+ diskGib: number;
15
+ egressGuaranteedMbps: number;
16
+ egressBurstMbps: number;
17
+ confidential: boolean;
18
+ };
19
+ }
20
+ export type CreateRemoteProvisionClaim = RemoteProvisionClaimBase & {
21
+ action?: 'create';
22
+ enrolment: {
23
+ token: string;
24
+ tenantKey: string;
25
+ projectKey: string;
26
+ environmentKey: string;
27
+ };
28
+ };
29
+ export type RemoteProvisionClaim = CreateRemoteProvisionClaim | (RemoteProvisionClaimBase & {
30
+ action: 'delete';
31
+ });
32
+ export interface ProvisionResult {
33
+ guestAddress?: string;
34
+ }
35
+ export type ProvisionRunner = (claim: RemoteProvisionClaim) => Promise<ProvisionResult>;
36
+ export interface ProvisioningPullOptions extends SignedNodeHttpOptions {
37
+ keys: NodeKeyPair;
38
+ run: ProvisionRunner;
39
+ intervalMs?: number;
40
+ completionAttempts?: number;
41
+ sleep?: (ms: number) => Promise<void>;
42
+ now?: () => number;
43
+ renewRetryMs?: number;
44
+ setTimer?: (callback: () => void, ms: number) => unknown;
45
+ clearTimer?: (handle: unknown) => void;
46
+ onEvent?: (event: string, detail?: unknown) => void;
47
+ /** Fresh local facts, sent signed before this host may claim work. */
48
+ metalPreflight?: () => {
49
+ snpHost: boolean;
50
+ kvm: boolean;
51
+ helper: boolean;
52
+ };
53
+ }
54
+ export type ProvisionPullResult = {
55
+ status: 'idle';
56
+ } | {
57
+ status: 'running';
58
+ claim: RemoteProvisionClaim;
59
+ result: ProvisionResult;
60
+ } | {
61
+ status: 'terminated';
62
+ claim: RemoteProvisionClaim;
63
+ result: ProvisionResult;
64
+ } | {
65
+ status: 'failed';
66
+ claim: RemoteProvisionClaim;
67
+ reason: string;
68
+ };
69
+ /** Claim one compute, await the local helper, then durably acknowledge it. */
70
+ export declare function pullProvisioningOnce(options: ProvisioningPullOptions): Promise<ProvisionPullResult>;
71
+ export declare function startProvisioningPull(options: ProvisioningPullOptions): {
72
+ stop(): Promise<void>;
73
+ readonly active: boolean;
74
+ };
75
+ export {};
@@ -0,0 +1,188 @@
1
+ // src/signed-node-http.ts
2
+ import { signRequest } from "@forgezero/runtime/identity";
3
+
4
+ class SignedNodeHttpError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = "SignedNodeHttpError";
10
+ }
11
+ }
12
+ var signatureHeader = (envelope) => Buffer.from(JSON.stringify({
13
+ timestamp: envelope.timestamp,
14
+ nonce: envelope.nonce,
15
+ edSignature: envelope.edSignature,
16
+ mlDsaSignature: envelope.mlDsaSignature
17
+ })).toString("base64url");
18
+ async function postSignedNode(options, path, body) {
19
+ const url = new URL(options.apiUrl);
20
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
21
+ url.search = "";
22
+ url.hash = "";
23
+ const raw = JSON.stringify(body);
24
+ const envelope = signRequest(options.keys, options.nodeKey, {
25
+ method: "POST",
26
+ path: url.pathname,
27
+ query: "",
28
+ body: raw
29
+ });
30
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
31
+ method: "POST",
32
+ headers: {
33
+ "content-type": "application/json",
34
+ "x-fz-node": options.nodeKey,
35
+ "x-fz-signature": signatureHeader(envelope)
36
+ },
37
+ body: raw,
38
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
39
+ });
40
+ const payload = await response.json().catch(() => null);
41
+ if (!response.ok) {
42
+ const failure = payload;
43
+ const reason = failure ? failure.error?.message ?? failure.message : undefined;
44
+ throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
45
+ }
46
+ return payload;
47
+ }
48
+
49
+ // src/provisioning-pull.ts
50
+ class ProvisionClaimLostError extends Error {
51
+ }
52
+ var post = (options, operation, body) => postSignedNode(options, `v1/metal/computes/${operation}`, body);
53
+ async function runClaim(options, claim) {
54
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
55
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
56
+ const now = options.now ?? Date.now;
57
+ let expires = claim.claimExpiresAtTs;
58
+ let stopped = false;
59
+ let timer;
60
+ let renewal = null;
61
+ let lost = null;
62
+ const schedule = (override) => {
63
+ if (stopped)
64
+ return;
65
+ const delay = override ?? Math.max(1000, Math.floor(Math.max(0, expires - now()) / 3));
66
+ timer = setTimer(() => {
67
+ if (stopped || renewal)
68
+ return;
69
+ let retry;
70
+ renewal = post(options, "renew", {
71
+ computeKey: claim.computeKey,
72
+ claimToken: claim.claimToken
73
+ }).then((response) => {
74
+ expires = response.claimExpiresAtTs;
75
+ options.onEvent?.("lease-renewed", { computeKey: claim.computeKey, claimExpiresAtTs: expires });
76
+ }).catch((cause) => {
77
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
78
+ lost = new ProvisionClaimLostError(cause.message);
79
+ stopped = true;
80
+ } else {
81
+ retry = Math.max(1000, options.renewRetryMs ?? 5000);
82
+ options.onEvent?.("lease-renew-failed", cause);
83
+ }
84
+ }).finally(() => {
85
+ renewal = null;
86
+ if (!stopped)
87
+ schedule(retry);
88
+ });
89
+ }, delay);
90
+ };
91
+ schedule();
92
+ let result;
93
+ try {
94
+ result = await options.run(claim);
95
+ } finally {
96
+ stopped = true;
97
+ clearTimer(timer);
98
+ await renewal;
99
+ }
100
+ if (lost)
101
+ throw lost;
102
+ return result;
103
+ }
104
+ async function complete(options, body) {
105
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
106
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
107
+ let last;
108
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
109
+ try {
110
+ await post(options, "complete", body);
111
+ return;
112
+ } catch (cause) {
113
+ last = cause;
114
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
115
+ throw cause;
116
+ if (attempt < attempts)
117
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
118
+ }
119
+ }
120
+ throw last;
121
+ }
122
+ async function pullProvisioningOnce(options) {
123
+ if (options.metalPreflight) {
124
+ const report = options.metalPreflight();
125
+ const accepted = await postSignedNode(options, "v1/metal/preflight", report);
126
+ options.onEvent?.("preflight", { ...report, ready: accepted.ready, state: accepted.state });
127
+ if (!accepted.ready)
128
+ return { status: "idle" };
129
+ }
130
+ const response = await post(options, "claim", {});
131
+ if (!response.claim)
132
+ return { status: "idle" };
133
+ const claim = response.claim;
134
+ let result;
135
+ try {
136
+ result = await runClaim(options, claim);
137
+ } catch (cause) {
138
+ if (cause instanceof ProvisionClaimLostError)
139
+ throw cause;
140
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
141
+ await complete(options, {
142
+ computeKey: claim.computeKey,
143
+ claimToken: claim.claimToken,
144
+ ok: false,
145
+ detail: reason
146
+ });
147
+ return { status: "failed", claim, reason };
148
+ }
149
+ await complete(options, {
150
+ computeKey: claim.computeKey,
151
+ claimToken: claim.claimToken,
152
+ ok: true,
153
+ ...result.guestAddress ? { guestAddress: result.guestAddress } : {}
154
+ });
155
+ return { status: claim.action === "delete" ? "terminated" : "running", claim, result };
156
+ }
157
+ function startProvisioningPull(options) {
158
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
159
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
160
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
161
+ let stopped = false;
162
+ let timer;
163
+ let active = null;
164
+ const tick = () => {
165
+ if (stopped || active)
166
+ return;
167
+ active = pullProvisioningOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
168
+ active = null;
169
+ if (!stopped)
170
+ timer = setTimer(tick, interval);
171
+ });
172
+ };
173
+ tick();
174
+ return {
175
+ async stop() {
176
+ stopped = true;
177
+ clearTimer(timer);
178
+ await active;
179
+ },
180
+ get active() {
181
+ return !stopped;
182
+ }
183
+ };
184
+ }
185
+ export {
186
+ startProvisioningPull,
187
+ pullProvisioningOnce
188
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { type NodeKeyPair } from '@forgezero/runtime/identity';
2
+ export interface SignedNodeHttpOptions {
3
+ apiUrl: string;
4
+ nodeKey: string;
5
+ keys: NodeKeyPair;
6
+ fetch?: (input: URL, init: RequestInit) => Promise<Response>;
7
+ requestTimeoutMs?: number;
8
+ }
9
+ export declare class SignedNodeHttpError extends Error {
10
+ readonly status: number;
11
+ constructor(status: number, message: string);
12
+ }
13
+ /** One implementation of the hybrid-signed machine HTTP contract. */
14
+ export declare function postSignedNode<T>(options: SignedNodeHttpOptions, path: string, body: object): Promise<T>;
@@ -0,0 +1,18 @@
1
+ import type { AttestationSource } from './socket';
2
+ /**
3
+ * Minimal Linux SNP guest ioctl helper.
4
+ *
5
+ * Python is the native boundary because it is already required by the Ubuntu
6
+ * cloud image, `ctypes` is standard-library code, and Bun documents its own FFI
7
+ * as experimental. Arguments are passed directly, never through a shell.
8
+ */
9
+ export declare const SNP_REPORT_HELPER: string;
10
+ export interface SnpAttestationOptions {
11
+ device?: string;
12
+ python?: string;
13
+ timeoutMs?: number;
14
+ spawn?: typeof Bun.spawn;
15
+ exists?: (path: string) => boolean;
16
+ }
17
+ /** A real PSP report source. Device presence alone is never called attestation. */
18
+ export declare function createSnpAttestationSource(options?: SnpAttestationOptions): AttestationSource;
@@ -0,0 +1 @@
1
+ export {};