@forgezero/agent 0.1.11 → 0.1.13
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 +545 -488
- 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 +152 -26
- package/dist/metal-provision.d.ts +4 -3
- package/dist/metal-provision.js +152 -26
- 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 +22 -1
- package/dist/provisioning-pull.js +21 -10
- 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
|
@@ -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,
|
|
@@ -960,22 +1041,59 @@ async function provisionMetalGuest(profile, claim, exec) {
|
|
|
960
1041
|
save();
|
|
961
1042
|
return { guestAddress: address };
|
|
962
1043
|
}
|
|
1044
|
+
function legacyPlatformManifest(profile, claim, name) {
|
|
1045
|
+
if (claim.bootstrap?.kind !== "platform-genesis" || claim.computeKey !== `platform:${name}` || claim.spec.reference !== `platform:${name}` || claim.spec.guestAddress === undefined)
|
|
1046
|
+
return;
|
|
1047
|
+
const legacyDir = profile.legacyStateDir ?? "/etc/forgezero/guests";
|
|
1048
|
+
const legacyPath = join(legacyDir, `${name}.conf`);
|
|
1049
|
+
if (!existsSync(legacyPath))
|
|
1050
|
+
return;
|
|
1051
|
+
const values = new Map;
|
|
1052
|
+
for (const line of readFileSync(legacyPath, "utf8").split(/\r?\n/)) {
|
|
1053
|
+
if (!line || line.startsWith("#"))
|
|
1054
|
+
continue;
|
|
1055
|
+
const match = /^([A-Z][A-Z0-9_]*)=([^\s'"`$;|&<>]+)$/.exec(line);
|
|
1056
|
+
if (!match)
|
|
1057
|
+
throw new MetalProvisionError("legacy guest inventory contains unsafe syntax");
|
|
1058
|
+
values.set(match[1], match[2]);
|
|
1059
|
+
}
|
|
1060
|
+
const disk = `/dev/${profile.volumeGroup}/${name}`;
|
|
1061
|
+
if (values.get("NAME") !== name || values.get("DISK") !== disk || values.get("IP") !== claim.spec.guestAddress)
|
|
1062
|
+
throw new MetalProvisionError("legacy guest inventory does not match the platform claim");
|
|
1063
|
+
return {
|
|
1064
|
+
computeKey: claim.computeKey,
|
|
1065
|
+
reference: claim.spec.reference,
|
|
1066
|
+
name,
|
|
1067
|
+
address: claim.spec.guestAddress,
|
|
1068
|
+
mac: values.get("MAC") ?? "",
|
|
1069
|
+
cpuPoolKey: claim.spec.cpuPoolKey ?? "",
|
|
1070
|
+
allowedCpus: values.get("CPUSET") ?? "",
|
|
1071
|
+
allowedMemoryNodes: values.get("NUMA"),
|
|
1072
|
+
phase: "running",
|
|
1073
|
+
disk,
|
|
1074
|
+
unit: `forgezero-guest@${name}.service`
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
963
1077
|
async function removeMetalGuest(profile, claim, exec) {
|
|
964
1078
|
validateMetalProfile(profile);
|
|
965
1079
|
if (claim.action !== "delete")
|
|
966
1080
|
throw new MetalProvisionError("create claim cannot remove a guest");
|
|
967
|
-
const name = guestNameFor(claim.computeKey);
|
|
1081
|
+
const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
|
|
968
1082
|
const manifestPath = join(profile.stateDir, `${name}.json`);
|
|
969
|
-
|
|
1083
|
+
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
|
|
1084
|
+
if (!manifest)
|
|
970
1085
|
return {};
|
|
971
|
-
const
|
|
972
|
-
|
|
1086
|
+
const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
|
|
1087
|
+
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`);
|
|
1088
|
+
if (!currentIdentity && !legacyPlatformIdentity) {
|
|
973
1089
|
throw new MetalProvisionError("compute identity conflicts with host inventory");
|
|
974
1090
|
}
|
|
975
1091
|
const service = `forgezero-guest@${name}.service`;
|
|
976
1092
|
const unitPath = join(profile.unitDir, service);
|
|
977
1093
|
if (existsSync(unitPath))
|
|
978
1094
|
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
1095
|
+
else if (legacyPlatformIdentity)
|
|
1096
|
+
await checked(exec, ["systemctl", "disable", "--now", service]);
|
|
979
1097
|
else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
|
|
980
1098
|
throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
|
|
981
1099
|
}
|
|
@@ -992,6 +1110,14 @@ async function removeMetalGuest(profile, claim, exec) {
|
|
|
992
1110
|
])
|
|
993
1111
|
if (existsSync(path))
|
|
994
1112
|
unlinkSync(path);
|
|
1113
|
+
if (legacyPlatformIdentity) {
|
|
1114
|
+
const legacyConfig = join(profile.legacyStateDir ?? "/etc/forgezero/guests", `${name}.conf`);
|
|
1115
|
+
const legacyDropIn = join(profile.unitDir, `${service}.d`);
|
|
1116
|
+
if (existsSync(legacyConfig))
|
|
1117
|
+
unlinkSync(legacyConfig);
|
|
1118
|
+
if (existsSync(legacyDropIn))
|
|
1119
|
+
rmSync(legacyDropIn, { recursive: true });
|
|
1120
|
+
}
|
|
995
1121
|
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
996
1122
|
return {};
|
|
997
1123
|
}
|
|
@@ -24,6 +24,8 @@ export interface MetalProvisionProfile {
|
|
|
24
24
|
stateDir: string;
|
|
25
25
|
seedDir: string;
|
|
26
26
|
unitDir: string;
|
|
27
|
+
/** One-time compatibility source for pre-Agent platform guests. */
|
|
28
|
+
legacyStateDir?: string;
|
|
27
29
|
apiUrl: string;
|
|
28
30
|
images: Record<string, MetalImage>;
|
|
29
31
|
/** Explicit host-observed pools. No pool means no dynamic guest authority. */
|
|
@@ -68,8 +70,8 @@ export declare function validateMetalProfile(profile: MetalProvisionProfile): vo
|
|
|
68
70
|
export declare function allocateAddress(profile: MetalProvisionProfile, computeKey: string, rows: GuestManifest[]): string;
|
|
69
71
|
/** Stable tight-fit allocation of one exclusive CPU/NUMA pool. */
|
|
70
72
|
export declare function allocateCpuPool(profile: MetalProvisionProfile, claim: Pick<RemoteProvisionClaim, 'computeKey' | 'spec'>, rows: GuestManifest[]): MetalCpuPool;
|
|
71
|
-
export declare function guestBootstrapScript(profile: MetalProvisionProfile, attested?: boolean): string;
|
|
72
|
-
export declare function guestAgentUnit(profile: MetalProvisionProfile, name: string, attested?: boolean): string;
|
|
73
|
+
export declare function guestBootstrapScript(profile: MetalProvisionProfile, attested?: boolean, hasEnrolment?: boolean): string;
|
|
74
|
+
export declare function guestAgentUnit(profile: MetalProvisionProfile, name: string, attested?: boolean, pull?: boolean): string;
|
|
73
75
|
/** Removed by the root cleanup service once the non-secret binding is durable. */
|
|
74
76
|
export declare function guestEnrolmentDropIn(): string;
|
|
75
77
|
export declare function guestEnrolmentCleanupScript(): string;
|
|
@@ -81,5 +83,4 @@ export declare function cloudInit(profile: MetalProvisionProfile, claim: CreateR
|
|
|
81
83
|
};
|
|
82
84
|
/** The only root operation the metal agent may request. */
|
|
83
85
|
export declare function provisionMetalGuest(profile: MetalProvisionProfile, claim: CreateRemoteProvisionClaim, exec: MetalExec): Promise<ProvisionResult>;
|
|
84
|
-
/** Idempotently remove every host-owned artifact before capacity is returned. */
|
|
85
86
|
export declare function removeMetalGuest(profile: MetalProvisionProfile, claim: RemoteProvisionClaim, exec: MetalExec): Promise<ProvisionResult>;
|