@forgezero/agent 0.1.10 → 0.1.12
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 +36 -9
- package/dist/deployment.d.ts +1 -1
- package/dist/fz-agent.js +525 -490
- package/dist/fz.js +34 -12
- package/dist/guest-enrolment.d.ts +2 -1
- package/dist/guest-enrolment.js +81 -23
- package/dist/index.d.ts +5 -29
- package/dist/metal-helper-socket.js +117 -24
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +117 -24
- package/dist/node-vault.d.ts +9 -0
- package/dist/node-vault.js +53 -17
- package/dist/provision.d.ts +1 -0
- package/dist/provision.js +14 -4
- package/dist/provisioning-pull.d.ts +24 -1
- package/dist/provisioning-pull.js +30 -11
- package/dist/signed-node-http.d.ts +1 -1
- package/dist/socket.d.ts +11 -4
- package/dist/ubuntu.d.ts +16 -0
- package/dist/ubuntu.js +18 -0
- package/dist/version.d.ts +2 -0
- package/package.json +11 -6
- package/dist/attestation-client.test.d.ts +0 -1
- package/dist/cache.test.d.ts +0 -1
- package/dist/cli/agent-install.test.d.ts +0 -1
- package/dist/cli/options.test.d.ts +0 -1
- package/dist/cli/run.test.d.ts +0 -1
- package/dist/compute.test.d.ts +0 -1
- package/dist/control.test.d.ts +0 -1
- package/dist/definition.test.d.ts +0 -1
- package/dist/deployment-pull.test.d.ts +0 -1
- package/dist/deployment-runner.test.d.ts +0 -1
- package/dist/deployment-watch.d.ts +0 -36
- package/dist/deployment-watch.test.d.ts +0 -1
- package/dist/deployment.test.d.ts +0 -1
- package/dist/guest-enrolment.test.d.ts +0 -1
- package/dist/index.test.d.ts +0 -1
- package/dist/metal-helper-socket.test.d.ts +0 -1
- package/dist/metal-isolation.test.d.ts +0 -1
- package/dist/metal-provision.test.d.ts +0 -1
- package/dist/node-vault.test.d.ts +0 -1
- package/dist/pipeline.test.d.ts +0 -1
- package/dist/provisioning-pull.test.d.ts +0 -1
- package/dist/snp-attestation.test.d.ts +0 -1
- package/dist/socket.test.d.ts +0 -1
- package/dist/ssh-listen.test.d.ts +0 -1
- package/dist/ssh-server.test.d.ts +0 -1
- package/dist/subscribe.test.d.ts +0 -1
package/dist/fz.js
CHANGED
|
@@ -101,7 +101,7 @@ async function spawnWith(command, env, report = () => {}) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
// src/cli/index.ts
|
|
104
|
-
import { readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
104
|
+
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
105
105
|
import { fileURLToPath } from "url";
|
|
106
106
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
107
107
|
|
|
@@ -148,6 +148,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
|
148
148
|
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 \u2014 weaker than attestation, stronger than an API key in the application.";
|
|
149
149
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
150
150
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
151
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
151
152
|
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
152
153
|
var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
153
154
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -317,7 +318,7 @@ function agentUnit(options) {
|
|
|
317
318
|
`);
|
|
318
319
|
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
319
320
|
DeviceAllow=/dev/sev-guest rw` : "";
|
|
320
|
-
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${
|
|
321
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
321
322
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
322
323
|
` : "";
|
|
323
324
|
return `[Unit]
|
|
@@ -328,7 +329,7 @@ ${deploymentDependency}
|
|
|
328
329
|
[Service]
|
|
329
330
|
Type=simple
|
|
330
331
|
User=${user}
|
|
331
|
-
Group=${
|
|
332
|
+
Group=${VAULT_GROUP}
|
|
332
333
|
${deploymentGroup}
|
|
333
334
|
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
334
335
|
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
@@ -347,8 +348,8 @@ LimitCORE=0
|
|
|
347
348
|
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
348
349
|
# than wherever the process happened to have write access.
|
|
349
350
|
RuntimeDirectory=forgezero
|
|
350
|
-
RuntimeDirectoryMode=
|
|
351
|
-
UMask=
|
|
351
|
+
RuntimeDirectoryMode=0750
|
|
352
|
+
UMask=0007
|
|
352
353
|
|
|
353
354
|
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
354
355
|
# credential-bearing process never needs to cross a privilege boundary.
|
|
@@ -415,6 +416,10 @@ function planProvision(options) {
|
|
|
415
416
|
socketPath: options.socketPath,
|
|
416
417
|
user,
|
|
417
418
|
steps: [
|
|
419
|
+
{
|
|
420
|
+
label: "vault socket access group",
|
|
421
|
+
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
422
|
+
},
|
|
418
423
|
...sourceBinPath && binPath ? [{
|
|
419
424
|
label: "root-owned agent runtime",
|
|
420
425
|
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
@@ -427,6 +432,10 @@ function planProvision(options) {
|
|
|
427
432
|
label: "service account",
|
|
428
433
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
429
434
|
},
|
|
435
|
+
{
|
|
436
|
+
label: "bind service account to vault group",
|
|
437
|
+
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
438
|
+
},
|
|
430
439
|
...deploymentEnabled ? [{
|
|
431
440
|
label: "credential-free deployment account",
|
|
432
441
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
@@ -771,10 +780,12 @@ async function resolveIdentity(selector, socketPath) {
|
|
|
771
780
|
return chosen;
|
|
772
781
|
}
|
|
773
782
|
|
|
783
|
+
// src/version.ts
|
|
784
|
+
var VERSION = "0.1.12";
|
|
785
|
+
|
|
774
786
|
// src/cli/index.ts
|
|
775
787
|
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
776
788
|
var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
|
|
777
|
-
var VERSION = "0.1.10";
|
|
778
789
|
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
779
790
|
function parseOptions(argv) {
|
|
780
791
|
const options = {
|
|
@@ -1033,13 +1044,24 @@ async function cmdAgent(options, args) {
|
|
|
1033
1044
|
out.ok(`Wrote ${auxiliary.path}`);
|
|
1034
1045
|
}
|
|
1035
1046
|
if (options.enrol) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1047
|
+
if (existsSync(enrolTokenSourcePath)) {
|
|
1048
|
+
const source = statSync(enrolTokenSourcePath);
|
|
1049
|
+
const token = readFileSync(enrolTokenSourcePath, "utf8").trim();
|
|
1050
|
+
if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
|
|
1051
|
+
throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
|
|
1052
|
+
}
|
|
1053
|
+
if (!/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
1054
|
+
throw new Error("The preloaded enrolment token is malformed.");
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
|
|
1058
|
+
const token = (await new Response(prompt.stdout).text()).trim();
|
|
1059
|
+
if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
1060
|
+
throw new Error("A valid fze_ enrolment token was not provided.");
|
|
1061
|
+
}
|
|
1062
|
+
writeFileSync(enrolTokenSourcePath, `${token}
|
|
1042
1063
|
`, { mode: 384, flag: "wx" });
|
|
1064
|
+
}
|
|
1043
1065
|
}
|
|
1044
1066
|
const transcript = await applyPlan(plan, localRunner);
|
|
1045
1067
|
for (const step of transcript)
|
package/dist/guest-enrolment.js
CHANGED
|
@@ -10,11 +10,72 @@ import {
|
|
|
10
10
|
writeFileSync
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/signed-node-http.ts
|
|
15
|
+
import {
|
|
16
|
+
encodeSignatureHeader,
|
|
17
|
+
generateResponseRecipient,
|
|
18
|
+
openResponse,
|
|
19
|
+
RESPONSE_KEY_HEADER,
|
|
20
|
+
signRequest
|
|
21
|
+
} from "@forgezero/runtime/identity";
|
|
22
|
+
|
|
23
|
+
class SignedNodeHttpError extends Error {
|
|
24
|
+
status;
|
|
25
|
+
constructor(status, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.name = "SignedNodeHttpError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function postSignedNode(options, path, body, sealedResponse = false) {
|
|
32
|
+
const url = new URL(options.apiUrl);
|
|
33
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
34
|
+
url.search = "";
|
|
35
|
+
url.hash = "";
|
|
36
|
+
const raw = JSON.stringify(body);
|
|
37
|
+
const recipient = sealedResponse ? generateResponseRecipient() : undefined;
|
|
38
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
path: url.pathname,
|
|
41
|
+
query: "",
|
|
42
|
+
body: raw,
|
|
43
|
+
responseKey: recipient?.publicKey
|
|
44
|
+
});
|
|
45
|
+
const signature = encodeSignatureHeader(envelope);
|
|
46
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
"x-fz-node": options.nodeKey,
|
|
51
|
+
"x-fz-signature": signature,
|
|
52
|
+
...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
|
|
53
|
+
},
|
|
54
|
+
body: raw,
|
|
55
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
56
|
+
});
|
|
57
|
+
const payload = await response.json().catch(() => null);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const failure = payload;
|
|
60
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
61
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
62
|
+
}
|
|
63
|
+
if (recipient) {
|
|
64
|
+
try {
|
|
65
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/guest-enrolment.ts
|
|
13
74
|
var validBinding = (value, expectedNodeKey) => {
|
|
14
75
|
if (!value || typeof value !== "object")
|
|
15
76
|
return false;
|
|
16
77
|
const row = value;
|
|
17
|
-
return ["nodeKey", "computeReference", "projectKey", "environmentKey"
|
|
78
|
+
return ["nodeKey", "computeReference", "projectKey", "environmentKey"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (row.realm === "platform" || row.realm === "tenant" && typeof row.tenantSlug === "string" && row.tenantSlug.length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
|
|
18
79
|
};
|
|
19
80
|
function loadGuestBinding(path, expectedNodeKey) {
|
|
20
81
|
if (!existsSync(path))
|
|
@@ -46,34 +107,31 @@ async function enrolGuestIdentity(options) {
|
|
|
46
107
|
const token = options.token?.trim() ?? (options.tokenPath ? readFileSync(options.tokenPath, "utf8").trim() : "");
|
|
47
108
|
if (!token.startsWith("fze_"))
|
|
48
109
|
throw new Error("guest enrolment credential is malformed");
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
-
const payload = await response.json().catch(() => null);
|
|
68
|
-
if (!response.ok || !payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || !payload.tenantSlug) {
|
|
69
|
-
throw new Error(payload?.error?.message || `guest enrolment returned HTTP ${response.status}`);
|
|
110
|
+
const payload = await postSignedNode({
|
|
111
|
+
apiUrl: options.apiUrl,
|
|
112
|
+
nodeKey: options.nodeKey,
|
|
113
|
+
keys: options.keys,
|
|
114
|
+
fetch: options.fetch,
|
|
115
|
+
requestTimeoutMs: options.requestTimeoutMs
|
|
116
|
+
}, "v1/compute/enrol", {
|
|
117
|
+
token,
|
|
118
|
+
label: options.label,
|
|
119
|
+
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
120
|
+
publicKeys: {
|
|
121
|
+
ed25519: options.keys.ed25519.publicKey,
|
|
122
|
+
mlDsa: options.keys.mlDsa.publicKey
|
|
123
|
+
}
|
|
124
|
+
}, true);
|
|
125
|
+
if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
|
|
126
|
+
throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
|
|
70
127
|
}
|
|
71
128
|
const binding = {
|
|
72
129
|
nodeKey: payload.nodeKey,
|
|
73
130
|
computeReference: payload.computeReference,
|
|
74
131
|
projectKey: payload.projectKey,
|
|
75
132
|
environmentKey: payload.environmentKey,
|
|
76
|
-
|
|
133
|
+
realm: payload.realm,
|
|
134
|
+
...payload.tenantSlug ? { tenantSlug: payload.tenantSlug } : {}
|
|
77
135
|
};
|
|
78
136
|
persistGuestBinding(options.statePath, binding);
|
|
79
137
|
if (options.consume)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,30 +2,7 @@
|
|
|
2
2
|
import { type NodeKeyPair } from '@forgezero/runtime/identity';
|
|
3
3
|
import { startAgent, type AgentOptions, type AttestationSource } from './socket';
|
|
4
4
|
import type { SecretCache } from './cache';
|
|
5
|
-
|
|
6
|
-
* fz-agent — runs inside managed compute, driven locally before the platform exists and
|
|
7
|
-
* over HTTPS after it does. One implementation, two front doors: bootstrap is
|
|
8
|
-
* not a special case, it is the general case run first, because a tenant's own
|
|
9
|
-
* bare metal has no ForgeZero on it either.
|
|
10
|
-
*
|
|
11
|
-
* Everything it does is recorded in a hash-chained journal from its first
|
|
12
|
-
* command — before there is a database to log into. A flat log written then is
|
|
13
|
-
* a log anyone could have edited afterwards; the chain either verifies or
|
|
14
|
-
* visibly does not, and it is replayed into `audit_log` once the platform is
|
|
15
|
-
* operational.
|
|
16
|
-
*
|
|
17
|
-
* ## What it serves
|
|
18
|
-
*
|
|
19
|
-
* A unix socket that SIGNS and never surrenders the key. `@forgezero/vault`
|
|
20
|
-
* discovers `/run/forgezero/vault.sock` and prefers it over `FORGEZERO_API_KEY`, so
|
|
21
|
-
* moving an application onto managed compute means deleting an environment
|
|
22
|
-
* variable rather than changing a line of code — and a machine that used to hold
|
|
23
|
-
* a signing seed now holds nothing an attacker can take.
|
|
24
|
-
*
|
|
25
|
-
* That preference was implemented in the client long before anything listened.
|
|
26
|
-
* See `socket.ts` for why it signs rather than handing back a token.
|
|
27
|
-
*/
|
|
28
|
-
export declare const VERSION = "0.1.10";
|
|
5
|
+
export { VERSION } from './version';
|
|
29
6
|
export { startAgent, handleRequest } from './socket';
|
|
30
7
|
export type { AgentOptions, AttestationSource, Request, Response } from './socket';
|
|
31
8
|
export { createSecretCache, CacheError } from './cache';
|
|
@@ -35,13 +12,11 @@ export type { DeploymentManager, DeploymentOptions, DeploymentRequest, Deploymen
|
|
|
35
12
|
export { DEFAULT_CONTROL_SOCKET, requestControl, startControlServer } from './control';
|
|
36
13
|
export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
|
|
37
14
|
export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
|
|
38
|
-
export { readStaticDeploymentState, writeStaticDeploymentState, startStaticDeploymentWatch } from './deployment-watch';
|
|
39
|
-
export type { StaticDeploymentState, StaticDeploymentWatchOptions } from './deployment-watch';
|
|
40
15
|
export { pullProvisioningOnce, startProvisioningPull } from './provisioning-pull';
|
|
41
|
-
export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
|
|
16
|
+
export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, GuestAccess, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
|
|
42
17
|
export { enrolGuestIdentity, loadGuestBinding } from './guest-enrolment';
|
|
43
18
|
export type { GuestBinding, GuestEnrolmentOptions } from './guest-enrolment';
|
|
44
|
-
export { createNodeVaultCache, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
|
|
19
|
+
export { createNodeVaultCache, projectVaultCacheKey, projectVaultCoordinate, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
|
|
45
20
|
export type { NodeVaultOptions, NodeVaultSyncOptions } from './node-vault';
|
|
46
21
|
export { allocateAddress, allocateCpuPool, cloudInit, guestNameFor, provisionMetalGuest, removeMetalGuest } from './metal-provision';
|
|
47
22
|
export { validateMetalProfile } from './metal-provision';
|
|
@@ -53,6 +28,7 @@ export type { SnpAttestationOptions } from './snp-attestation';
|
|
|
53
28
|
export { attestNodeOnce, startNodeAttestation } from './attestation-client';
|
|
54
29
|
export type { NodeAttestationOptions } from './attestation-client';
|
|
55
30
|
export { applyMetalIsolation, metalGuestSliceUnit, metalHousekeepingDropIn } from './metal-isolation';
|
|
31
|
+
export { assertSupportedGuestImage, SUPPORTED_GUEST_IMAGE } from './ubuntu';
|
|
56
32
|
/**
|
|
57
33
|
* The node seed, on disk, owner-only.
|
|
58
34
|
*
|
|
@@ -128,5 +104,5 @@ export declare function runAgent(config?: AgentConfig): {
|
|
|
128
104
|
server: ReturnType<typeof startAgent>;
|
|
129
105
|
keys: NodeKeyPair;
|
|
130
106
|
nodeKey: string;
|
|
131
|
-
|
|
107
|
+
setVault(cache: SecretCache, projectKey: string): void;
|
|
132
108
|
};
|
|
@@ -181,6 +181,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
|
181
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
182
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
183
183
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
184
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
184
185
|
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
185
186
|
var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
186
187
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -350,7 +351,7 @@ function agentUnit(options) {
|
|
|
350
351
|
`);
|
|
351
352
|
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
352
353
|
DeviceAllow=/dev/sev-guest rw` : "";
|
|
353
|
-
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${
|
|
354
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
354
355
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
355
356
|
` : "";
|
|
356
357
|
return `[Unit]
|
|
@@ -361,7 +362,7 @@ ${deploymentDependency}
|
|
|
361
362
|
[Service]
|
|
362
363
|
Type=simple
|
|
363
364
|
User=${user}
|
|
364
|
-
Group=${
|
|
365
|
+
Group=${VAULT_GROUP}
|
|
365
366
|
${deploymentGroup}
|
|
366
367
|
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
367
368
|
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
@@ -380,8 +381,8 @@ LimitCORE=0
|
|
|
380
381
|
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
381
382
|
# than wherever the process happened to have write access.
|
|
382
383
|
RuntimeDirectory=forgezero
|
|
383
|
-
RuntimeDirectoryMode=
|
|
384
|
-
UMask=
|
|
384
|
+
RuntimeDirectoryMode=0750
|
|
385
|
+
UMask=0007
|
|
385
386
|
|
|
386
387
|
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
387
388
|
# credential-bearing process never needs to cross a privilege boundary.
|
|
@@ -448,6 +449,10 @@ function planProvision(options) {
|
|
|
448
449
|
socketPath: options.socketPath,
|
|
449
450
|
user,
|
|
450
451
|
steps: [
|
|
452
|
+
{
|
|
453
|
+
label: "vault socket access group",
|
|
454
|
+
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
455
|
+
},
|
|
451
456
|
...sourceBinPath && binPath ? [{
|
|
452
457
|
label: "root-owned agent runtime",
|
|
453
458
|
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
@@ -460,6 +465,10 @@ function planProvision(options) {
|
|
|
460
465
|
label: "service account",
|
|
461
466
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
462
467
|
},
|
|
468
|
+
{
|
|
469
|
+
label: "bind service account to vault group",
|
|
470
|
+
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
471
|
+
},
|
|
463
472
|
...deploymentEnabled ? [{
|
|
464
473
|
label: "credential-free deployment account",
|
|
465
474
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
@@ -523,6 +532,21 @@ function planProvision(options) {
|
|
|
523
532
|
};
|
|
524
533
|
}
|
|
525
534
|
|
|
535
|
+
// src/ubuntu.ts
|
|
536
|
+
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
537
|
+
key: "ubuntu-resolute-20260731",
|
|
538
|
+
family: "ubuntu-26.04",
|
|
539
|
+
version: "2026-07-31",
|
|
540
|
+
label: "Ubuntu 26.04 LTS Resolute",
|
|
541
|
+
url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
|
|
542
|
+
sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
|
|
543
|
+
});
|
|
544
|
+
function assertSupportedGuestImage(imageKey) {
|
|
545
|
+
if (imageKey !== SUPPORTED_GUEST_IMAGE.key) {
|
|
546
|
+
throw new Error(`unsupported guest image ${imageKey}; ForgeZero currently supports only ${SUPPORTED_GUEST_IMAGE.key}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
526
550
|
// src/metal-provision.ts
|
|
527
551
|
import { createHash } from "node:crypto";
|
|
528
552
|
import {
|
|
@@ -530,6 +554,7 @@ import {
|
|
|
530
554
|
mkdirSync,
|
|
531
555
|
readFileSync,
|
|
532
556
|
readdirSync,
|
|
557
|
+
rmSync,
|
|
533
558
|
statSync,
|
|
534
559
|
unlinkSync,
|
|
535
560
|
writeFileSync
|
|
@@ -584,6 +609,10 @@ function validateMetalProfile(profile) {
|
|
|
584
609
|
throw new MetalProvisionError("metal paths must be absolute");
|
|
585
610
|
}
|
|
586
611
|
new URL(profile.apiUrl);
|
|
612
|
+
const imageKeys = Object.keys(profile.images);
|
|
613
|
+
if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
|
|
614
|
+
throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
|
|
615
|
+
}
|
|
587
616
|
if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
|
|
588
617
|
throw new MetalProvisionError("at least one exclusive CPU pool is required");
|
|
589
618
|
}
|
|
@@ -649,6 +678,21 @@ function allocateAddress(profile, computeKey, rows) {
|
|
|
649
678
|
}
|
|
650
679
|
throw new MetalProvisionError("guest address range is full");
|
|
651
680
|
}
|
|
681
|
+
function requestedAddress(profile, claim, rows) {
|
|
682
|
+
if (!claim.spec.guestAddress)
|
|
683
|
+
return allocateAddress(profile, claim.computeKey, rows);
|
|
684
|
+
if (!claim.spec.guestAddress.startsWith(`${profile.subnetPrefix}.`)) {
|
|
685
|
+
throw new MetalProvisionError("requested guest address is outside the metal profile");
|
|
686
|
+
}
|
|
687
|
+
const last = Number(claim.spec.guestAddress.slice(profile.subnetPrefix.length + 1));
|
|
688
|
+
if (!Number.isInteger(last) || last < profile.addressStart || last > profile.addressEnd) {
|
|
689
|
+
throw new MetalProvisionError("requested guest address is outside the allocatable range");
|
|
690
|
+
}
|
|
691
|
+
const occupied = rows.find((row) => row.address === claim.spec.guestAddress && row.computeKey !== claim.computeKey);
|
|
692
|
+
if (occupied)
|
|
693
|
+
throw new MetalProvisionError("requested guest address is already allocated");
|
|
694
|
+
return claim.spec.guestAddress;
|
|
695
|
+
}
|
|
652
696
|
function allocateCpuPool(profile, claim, rows) {
|
|
653
697
|
const prior = rows.find((row) => row.computeKey === claim.computeKey);
|
|
654
698
|
if (prior) {
|
|
@@ -659,6 +703,15 @@ function allocateCpuPool(profile, claim, rows) {
|
|
|
659
703
|
return retained;
|
|
660
704
|
}
|
|
661
705
|
const used = new Set(rows.map((row) => row.cpuPoolKey));
|
|
706
|
+
if (claim.spec.cpuPoolKey) {
|
|
707
|
+
const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
|
|
708
|
+
if (!requested || used.has(requested.key)) {
|
|
709
|
+
throw new MetalProvisionError("requested CPU pool is unavailable");
|
|
710
|
+
}
|
|
711
|
+
if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
|
|
712
|
+
throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
|
|
713
|
+
return requested;
|
|
714
|
+
}
|
|
662
715
|
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
716
|
const selected = candidates[0];
|
|
664
717
|
if (!selected)
|
|
@@ -671,7 +724,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
671
724
|
encoding: b64
|
|
672
725
|
content: ${base64(content)}
|
|
673
726
|
`;
|
|
674
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
|
|
727
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
675
728
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
676
729
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
677
730
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -684,7 +737,9 @@ test -c /dev/sev-guest
|
|
|
684
737
|
` : "";
|
|
685
738
|
return `#!/usr/bin/env bash
|
|
686
739
|
set -Eeuo pipefail
|
|
687
|
-
${attestationSetup}
|
|
740
|
+
${attestationSetup}groupadd --system ${VAULT_GROUP} 2>/dev/null || true
|
|
741
|
+
useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
|
|
742
|
+
usermod -g ${VAULT_GROUP} forgezero-agent
|
|
688
743
|
groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
|
|
689
744
|
useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
|
|
690
745
|
usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
|
|
@@ -695,13 +750,13 @@ install -d -o root -g root -m 0755 /opt/forgezero
|
|
|
695
750
|
install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
|
|
696
751
|
install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
|
|
697
752
|
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
|
|
753
|
+
${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
|
|
699
754
|
systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
|
|
700
755
|
rm -f /run/forgezero-enrol-token
|
|
701
756
|
fi
|
|
702
757
|
chown root:root /var/lib/forgezero/enrol-token.cred
|
|
703
758
|
chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
704
|
-
if [[ ! -x /usr/local/bin/bun ]]; then
|
|
759
|
+
` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
705
760
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
706
761
|
printf '%s %s
|
|
707
762
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -740,8 +795,8 @@ systemctl daemon-reload
|
|
|
740
795
|
systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
|
|
741
796
|
`;
|
|
742
797
|
}
|
|
743
|
-
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
|
|
744
|
-
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp
|
|
798
|
+
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
|
|
799
|
+
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
745
800
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
746
801
|
` : "";
|
|
747
802
|
const attestationDevice = attested ? `DevicePolicy=closed
|
|
@@ -756,7 +811,7 @@ Requires=forgezero-deploy-runner.service
|
|
|
756
811
|
[Service]
|
|
757
812
|
Type=simple
|
|
758
813
|
User=forgezero-agent
|
|
759
|
-
Group
|
|
814
|
+
Group=${VAULT_GROUP}
|
|
760
815
|
SupplementaryGroups=${DEPLOYMENT_GROUP}
|
|
761
816
|
LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
|
|
762
817
|
LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
|
|
@@ -767,15 +822,15 @@ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
|
|
|
767
822
|
Environment=FZ_NODE_LABEL=${name}
|
|
768
823
|
Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
|
|
769
824
|
Environment=FZ_DEPLOY_ROOT=/opt/forgezero
|
|
770
|
-
Environment=FZ_DEPLOY_PULL
|
|
825
|
+
Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
|
|
771
826
|
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
772
827
|
Environment=HOME=/opt/forgezero/home
|
|
773
828
|
${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
|
|
774
829
|
Restart=on-failure
|
|
775
830
|
RestartSec=5
|
|
776
831
|
RuntimeDirectory=forgezero
|
|
777
|
-
RuntimeDirectoryMode=
|
|
778
|
-
UMask=
|
|
832
|
+
RuntimeDirectoryMode=0750
|
|
833
|
+
UMask=0007
|
|
779
834
|
LimitCORE=0
|
|
780
835
|
NoNewPrivileges=true
|
|
781
836
|
PrivateTmp=true
|
|
@@ -825,24 +880,39 @@ WantedBy=multi-user.target
|
|
|
825
880
|
`;
|
|
826
881
|
}
|
|
827
882
|
function cloudInit(profile, claim, manifest) {
|
|
828
|
-
const
|
|
829
|
-
const
|
|
883
|
+
const enrolled = Boolean(claim.enrolment);
|
|
884
|
+
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
|
|
885
|
+
const agentUnit2 = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
|
|
830
886
|
const enrolmentDropIn = guestEnrolmentDropIn();
|
|
831
887
|
const cleanupScript = guestEnrolmentCleanupScript();
|
|
832
888
|
const cleanupUnit = guestEnrolmentCleanupUnit();
|
|
833
889
|
const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
|
|
834
890
|
const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
|
|
891
|
+
const access = claim.access;
|
|
892
|
+
const users = access ? `users:
|
|
893
|
+
- default
|
|
894
|
+
- name: ${access.sshUser}
|
|
895
|
+
groups: [sudo]
|
|
896
|
+
shell: /bin/bash
|
|
897
|
+
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
898
|
+
ssh_authorized_keys:
|
|
899
|
+
${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
|
|
900
|
+
`)}
|
|
901
|
+
` : "";
|
|
902
|
+
const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
|
|
903
|
+
`, "0600")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}` : "";
|
|
835
904
|
return {
|
|
836
905
|
userData: `#cloud-config
|
|
837
906
|
package_update: true
|
|
907
|
+
${users}disable_root: true
|
|
838
908
|
# Git is part of the deployment transport, not a tenant-selected prerequisite:
|
|
839
909
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
840
910
|
packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
|
|
841
911
|
write_files:
|
|
842
|
-
${yamlFile("/
|
|
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:
|
|
912
|
+
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "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")}runcmd:
|
|
844
913
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
845
|
-
- [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
914
|
+
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
915
|
+
` : ""}
|
|
846
916
|
`,
|
|
847
917
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
848
918
|
local-hostname: ${manifest.name}
|
|
@@ -866,7 +936,8 @@ var checked = async (exec, argv) => {
|
|
|
866
936
|
};
|
|
867
937
|
async function provisionMetalGuest(profile, claim, exec) {
|
|
868
938
|
validateMetalProfile(profile);
|
|
869
|
-
|
|
939
|
+
assertSupportedGuestImage(claim.spec.imageKey);
|
|
940
|
+
if (!claim.computeKey || !claim.spec.reference || !SAFE_NAME.test(claim.spec.imageKey) || claim.spec.guestName !== undefined && !SAFE_NAME.test(claim.spec.guestName) || claim.spec.cpuPoolKey !== undefined && !SAFE_NAME.test(claim.spec.cpuPoolKey) || !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
941
|
throw new MetalProvisionError("invalid compute claim");
|
|
871
942
|
const image = profile.images[claim.spec.imageKey];
|
|
872
943
|
if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
|
|
@@ -880,12 +951,22 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
880
951
|
for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
|
|
881
952
|
mkdirSync(path, { recursive: true, mode: 448 });
|
|
882
953
|
const manifests = readManifests(profile.stateDir);
|
|
883
|
-
|
|
954
|
+
if (claim.access) {
|
|
955
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(claim.access.sshUser)) {
|
|
956
|
+
throw new MetalProvisionError("invalid SSH user");
|
|
957
|
+
}
|
|
958
|
+
if (claim.access.sshPublicKeys.length < 1 || claim.access.sshPublicKeys.length > 16 || claim.access.sshPublicKeys.some((key) => typeof key !== "string" || key.length > 16384 || !/^ssh-(?:ed25519|rsa)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/.test(key.trim())))
|
|
959
|
+
throw new MetalProvisionError("invalid SSH public key list");
|
|
960
|
+
}
|
|
961
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
962
|
+
const conflictingName = manifests.find((row) => row.name === name && row.computeKey !== claim.computeKey);
|
|
963
|
+
if (conflictingName)
|
|
964
|
+
throw new MetalProvisionError("requested guest name is already allocated");
|
|
884
965
|
const manifestPath = join(profile.stateDir, `${name}.json`);
|
|
885
966
|
const prior = manifests.find((row) => row.computeKey === claim.computeKey);
|
|
886
967
|
if (prior && prior.reference !== claim.spec.reference)
|
|
887
968
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
888
|
-
const address =
|
|
969
|
+
const address = requestedAddress(profile, claim, manifests);
|
|
889
970
|
const cpuPool = allocateCpuPool(profile, claim, manifests);
|
|
890
971
|
const manifest = prior ?? {
|
|
891
972
|
computeKey: claim.computeKey,
|
|
@@ -964,18 +1045,22 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
964
1045
|
validateMetalProfile(profile);
|
|
965
1046
|
if (claim.action !== "delete")
|
|
966
1047
|
throw new MetalProvisionError("create claim cannot remove a guest");
|
|
967
|
-
const name = guestNameFor(claim.computeKey);
|
|
1048
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
968
1049
|
const manifestPath = join(profile.stateDir, `${name}.json`);
|
|
969
1050
|
if (!existsSync(manifestPath))
|
|
970
1051
|
return {};
|
|
971
1052
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
972
|
-
|
|
1053
|
+
const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
|
|
1054
|
+
const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
|
|
1055
|
+
if (!currentIdentity && !legacyPlatformIdentity) {
|
|
973
1056
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
974
1057
|
}
|
|
975
1058
|
const service = `forgezero-guest@${name}.service`;
|
|
976
1059
|
const unitPath = join(profile.unitDir, service);
|
|
977
1060
|
if (existsSync(unitPath))
|
|
978
1061
|
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
1062
|
+
else if (legacyPlatformIdentity)
|
|
1063
|
+
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
979
1064
|
else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
|
|
980
1065
|
throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
|
|
981
1066
|
}
|
|
@@ -992,6 +1077,14 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
992
1077
|
])
|
|
993
1078
|
if (existsSync(path))
|
|
994
1079
|
unlinkSync(path);
|
|
1080
|
+
if (legacyPlatformIdentity) {
|
|
1081
|
+
const legacyConfig = `/etc/forgezero/guests/${name}.conf`;
|
|
1082
|
+
const legacyDropIn = join(profile.unitDir, `${service}.d`);
|
|
1083
|
+
if (existsSync(legacyConfig))
|
|
1084
|
+
unlinkSync(legacyConfig);
|
|
1085
|
+
if (existsSync(legacyDropIn))
|
|
1086
|
+
rmSync(legacyDropIn, { recursive: true });
|
|
1087
|
+
}
|
|
995
1088
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
996
1089
|
return {};
|
|
997
1090
|
}
|