@forgezero/agent 0.1.2 → 0.1.10
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.
- package/README.md +70 -4
- package/dist/attestation-client.d.ts +22 -0
- package/dist/attestation-client.test.d.ts +1 -0
- package/dist/cli/agent-install.d.ts +82 -0
- package/dist/cli/agent-install.test.d.ts +1 -0
- package/dist/cli/custody.d.ts +35 -0
- package/dist/cli/genesis.d.ts +79 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/options.test.d.ts +1 -0
- package/dist/cli/run.d.ts +92 -0
- package/dist/cli/run.test.d.ts +1 -0
- package/dist/compute.d.ts +122 -0
- package/dist/compute.js +150 -0
- package/dist/compute.test.d.ts +1 -0
- package/dist/control.d.ts +57 -0
- package/dist/control.test.d.ts +1 -0
- package/dist/definition.d.ts +34 -0
- package/dist/definition.js +159 -0
- package/dist/definition.test.d.ts +1 -0
- package/dist/deployment-pull.d.ts +61 -0
- package/dist/deployment-pull.test.d.ts +1 -0
- package/dist/deployment-runner.d.ts +23 -0
- package/dist/deployment-runner.js +199 -0
- package/dist/deployment-runner.test.d.ts +1 -0
- package/dist/deployment-watch.d.ts +36 -0
- package/dist/deployment-watch.test.d.ts +1 -0
- package/dist/deployment.d.ts +100 -0
- package/dist/deployment.test.d.ts +1 -0
- package/dist/fz-agent.js +2934 -182
- package/dist/fz.js +1270 -0
- package/dist/guest-enrolment.d.ts +29 -0
- package/dist/guest-enrolment.js +88 -0
- package/dist/guest-enrolment.test.d.ts +1 -0
- package/dist/index.d.ts +50 -4
- package/dist/metal-helper-socket.d.ts +15 -0
- package/dist/metal-helper-socket.js +1123 -0
- package/dist/metal-helper-socket.test.d.ts +1 -0
- package/dist/metal-isolation.d.ts +14 -0
- package/dist/metal-isolation.test.d.ts +1 -0
- package/dist/metal-provision.d.ts +85 -0
- package/dist/metal-provision.js +1014 -0
- package/dist/metal-provision.test.d.ts +1 -0
- package/dist/node-vault.d.ts +24 -0
- package/dist/node-vault.js +211 -0
- package/dist/node-vault.test.d.ts +1 -0
- package/dist/provision.d.ts +50 -2
- package/dist/provision.js +286 -12
- package/dist/provisioning-pull.d.ts +75 -0
- package/dist/provisioning-pull.js +188 -0
- package/dist/provisioning-pull.test.d.ts +1 -0
- package/dist/signed-node-http.d.ts +14 -0
- package/dist/snp-attestation.d.ts +18 -0
- package/dist/snp-attestation.test.d.ts +1 -0
- package/dist/socket.d.ts +4 -23
- package/package.json +27 -9
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
111
|
-
command: `install -d -o
|
|
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
|
-
{
|
|
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 {};
|