@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.
- package/README.md +45 -2
- package/dist/attestation-client.d.ts +22 -0
- package/dist/attestation-client.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 +60 -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 +86 -0
- package/dist/deployment.test.d.ts +1 -0
- package/dist/fz-agent.js +2901 -155
- 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 +91 -71
|
@@ -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
|
+
};
|