@forgezero/agent 0.1.11 → 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 +514 -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 +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
|
@@ -68,8 +68,8 @@ export declare function validateMetalProfile(profile: MetalProvisionProfile): vo
|
|
|
68
68
|
export declare function allocateAddress(profile: MetalProvisionProfile, computeKey: string, rows: GuestManifest[]): string;
|
|
69
69
|
/** Stable tight-fit allocation of one exclusive CPU/NUMA pool. */
|
|
70
70
|
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;
|
|
71
|
+
export declare function guestBootstrapScript(profile: MetalProvisionProfile, attested?: boolean, hasEnrolment?: boolean): string;
|
|
72
|
+
export declare function guestAgentUnit(profile: MetalProvisionProfile, name: string, attested?: boolean, pull?: boolean): string;
|
|
73
73
|
/** Removed by the root cleanup service once the non-secret binding is durable. */
|
|
74
74
|
export declare function guestEnrolmentDropIn(): string;
|
|
75
75
|
export declare function guestEnrolmentCleanupScript(): string;
|
package/dist/metal-provision.js
CHANGED
|
@@ -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
|
}
|
package/dist/node-vault.d.ts
CHANGED
|
@@ -5,9 +5,18 @@ export interface NodeVaultOptions extends Omit<SignedNodeHttpOptions, 'apiUrl'>
|
|
|
5
5
|
/** Tenant node base, for example https://api.example/api/t/acme. */
|
|
6
6
|
apiUrl: string;
|
|
7
7
|
keys: NodeKeyPair;
|
|
8
|
+
/** The sole tenant project this enrolled compute may replicate. */
|
|
9
|
+
projectKey: string;
|
|
8
10
|
ttlMs?: number;
|
|
9
11
|
maxStaleMs?: number;
|
|
10
12
|
}
|
|
13
|
+
export interface ProjectVaultCoordinate {
|
|
14
|
+
environment: string;
|
|
15
|
+
name: string;
|
|
16
|
+
}
|
|
17
|
+
/** Internal cache key. JSON avoids separator ambiguity in user-selected names. */
|
|
18
|
+
export declare function projectVaultCacheKey(coordinate: ProjectVaultCoordinate): string;
|
|
19
|
+
export declare function projectVaultCoordinate(key: string): ProjectVaultCoordinate;
|
|
11
20
|
/** Turn the public API origin into the realm-selected node endpoint. */
|
|
12
21
|
export declare function tenantNodeApiUrl(apiUrl: string, tenantSlug: string): string;
|
|
13
22
|
/** Project/environment selection is server-owned through the compute binding. */
|
package/dist/node-vault.js
CHANGED
|
@@ -92,7 +92,13 @@ function createSecretCache(options) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// src/signed-node-http.ts
|
|
95
|
-
import {
|
|
95
|
+
import {
|
|
96
|
+
encodeSignatureHeader,
|
|
97
|
+
generateResponseRecipient,
|
|
98
|
+
openResponse,
|
|
99
|
+
RESPONSE_KEY_HEADER,
|
|
100
|
+
signRequest
|
|
101
|
+
} from "@forgezero/runtime/identity";
|
|
96
102
|
|
|
97
103
|
class SignedNodeHttpError extends Error {
|
|
98
104
|
status;
|
|
@@ -102,30 +108,28 @@ class SignedNodeHttpError extends Error {
|
|
|
102
108
|
this.name = "SignedNodeHttpError";
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
|
-
|
|
106
|
-
timestamp: envelope.timestamp,
|
|
107
|
-
nonce: envelope.nonce,
|
|
108
|
-
edSignature: envelope.edSignature,
|
|
109
|
-
mlDsaSignature: envelope.mlDsaSignature
|
|
110
|
-
})).toString("base64url");
|
|
111
|
-
async function postSignedNode(options, path, body) {
|
|
111
|
+
async function postSignedNode(options, path, body, sealedResponse = false) {
|
|
112
112
|
const url = new URL(options.apiUrl);
|
|
113
113
|
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
114
114
|
url.search = "";
|
|
115
115
|
url.hash = "";
|
|
116
116
|
const raw = JSON.stringify(body);
|
|
117
|
+
const recipient = sealedResponse ? generateResponseRecipient() : undefined;
|
|
117
118
|
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
118
119
|
method: "POST",
|
|
119
120
|
path: url.pathname,
|
|
120
121
|
query: "",
|
|
121
|
-
body: raw
|
|
122
|
+
body: raw,
|
|
123
|
+
responseKey: recipient?.publicKey
|
|
122
124
|
});
|
|
125
|
+
const signature = encodeSignatureHeader(envelope);
|
|
123
126
|
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
124
127
|
method: "POST",
|
|
125
128
|
headers: {
|
|
126
129
|
"content-type": "application/json",
|
|
127
130
|
"x-fz-node": options.nodeKey,
|
|
128
|
-
"x-fz-signature":
|
|
131
|
+
"x-fz-signature": signature,
|
|
132
|
+
...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
|
|
129
133
|
},
|
|
130
134
|
body: raw,
|
|
131
135
|
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
@@ -136,10 +140,35 @@ async function postSignedNode(options, path, body) {
|
|
|
136
140
|
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
137
141
|
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
138
142
|
}
|
|
143
|
+
if (recipient) {
|
|
144
|
+
try {
|
|
145
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
139
150
|
return payload;
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
// src/node-vault.ts
|
|
154
|
+
var SCOPE_PART = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
155
|
+
function projectVaultCacheKey(coordinate) {
|
|
156
|
+
if (!SCOPE_PART.test(coordinate.environment) || !SCOPE_PART.test(coordinate.name)) {
|
|
157
|
+
throw new Error("node vault coordinate is malformed");
|
|
158
|
+
}
|
|
159
|
+
return JSON.stringify([coordinate.environment, coordinate.name]);
|
|
160
|
+
}
|
|
161
|
+
function projectVaultCoordinate(key) {
|
|
162
|
+
let value;
|
|
163
|
+
try {
|
|
164
|
+
value = JSON.parse(key);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error("node vault cache key is malformed");
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !SCOPE_PART.test(value[0]) || !SCOPE_PART.test(value[1]))
|
|
169
|
+
throw new Error("node vault cache key is malformed");
|
|
170
|
+
return { environment: value[0], name: value[1] };
|
|
171
|
+
}
|
|
143
172
|
function tenantNodeApiUrl(apiUrl, tenantSlug) {
|
|
144
173
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
|
|
145
174
|
throw new Error("tenant slug is malformed");
|
|
@@ -150,19 +179,20 @@ function tenantNodeApiUrl(apiUrl, tenantSlug) {
|
|
|
150
179
|
return url.toString().replace(/\/$/, "");
|
|
151
180
|
}
|
|
152
181
|
function createNodeVaultCache(options) {
|
|
153
|
-
const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
|
|
182
|
+
const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body, true);
|
|
154
183
|
return createSecretCache({
|
|
155
184
|
ttlMs: options.ttlMs,
|
|
156
185
|
maxStaleMs: options.maxStaleMs,
|
|
157
186
|
list: async () => {
|
|
158
187
|
const payload = await post("list", {});
|
|
159
|
-
if (!Array.isArray(payload.
|
|
188
|
+
if (!Array.isArray(payload.entries) || payload.entries.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string")) {
|
|
160
189
|
throw new Error("node vault list response is malformed");
|
|
161
190
|
}
|
|
162
|
-
return payload.
|
|
191
|
+
return payload.entries.map(projectVaultCacheKey);
|
|
163
192
|
},
|
|
164
|
-
fetch: async (
|
|
165
|
-
const
|
|
193
|
+
fetch: async (key) => {
|
|
194
|
+
const coordinate = projectVaultCoordinate(key);
|
|
195
|
+
const payload = await post("read", coordinate);
|
|
166
196
|
if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
|
|
167
197
|
throw new Error("node vault read response is malformed");
|
|
168
198
|
}
|
|
@@ -170,9 +200,13 @@ function createNodeVaultCache(options) {
|
|
|
170
200
|
},
|
|
171
201
|
changes: async (since) => {
|
|
172
202
|
const payload = await post("changes", { since });
|
|
173
|
-
if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((
|
|
203
|
+
if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string"))
|
|
174
204
|
throw new Error("node vault changes response is malformed");
|
|
175
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
version: payload.version,
|
|
207
|
+
changed: payload.changed.map(projectVaultCacheKey),
|
|
208
|
+
resync: payload.resync
|
|
209
|
+
};
|
|
176
210
|
}
|
|
177
211
|
});
|
|
178
212
|
}
|
|
@@ -207,5 +241,7 @@ function startNodeVaultSync(cache, options = {}) {
|
|
|
207
241
|
export {
|
|
208
242
|
tenantNodeApiUrl,
|
|
209
243
|
startNodeVaultSync,
|
|
244
|
+
projectVaultCoordinate,
|
|
245
|
+
projectVaultCacheKey,
|
|
210
246
|
createNodeVaultCache
|
|
211
247
|
};
|
package/dist/provision.d.ts
CHANGED
|
@@ -108,6 +108,7 @@ export interface UnitOptions {
|
|
|
108
108
|
}
|
|
109
109
|
export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
110
110
|
export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
111
|
+
export declare const VAULT_GROUP = "forgezero-vault";
|
|
111
112
|
export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
112
113
|
export declare const DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
113
114
|
export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
package/dist/provision.js
CHANGED
|
@@ -41,6 +41,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
|
41
41
|
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
|
|
42
42
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
43
43
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
44
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
44
45
|
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
45
46
|
var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
46
47
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -210,7 +211,7 @@ function agentUnit(options) {
|
|
|
210
211
|
`);
|
|
211
212
|
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
212
213
|
DeviceAllow=/dev/sev-guest rw` : "";
|
|
213
|
-
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${
|
|
214
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
214
215
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
215
216
|
` : "";
|
|
216
217
|
return `[Unit]
|
|
@@ -221,7 +222,7 @@ ${deploymentDependency}
|
|
|
221
222
|
[Service]
|
|
222
223
|
Type=simple
|
|
223
224
|
User=${user}
|
|
224
|
-
Group=${
|
|
225
|
+
Group=${VAULT_GROUP}
|
|
225
226
|
${deploymentGroup}
|
|
226
227
|
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
227
228
|
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
@@ -240,8 +241,8 @@ LimitCORE=0
|
|
|
240
241
|
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
241
242
|
# than wherever the process happened to have write access.
|
|
242
243
|
RuntimeDirectory=forgezero
|
|
243
|
-
RuntimeDirectoryMode=
|
|
244
|
-
UMask=
|
|
244
|
+
RuntimeDirectoryMode=0750
|
|
245
|
+
UMask=0007
|
|
245
246
|
|
|
246
247
|
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
247
248
|
# credential-bearing process never needs to cross a privilege boundary.
|
|
@@ -308,6 +309,10 @@ function planProvision(options) {
|
|
|
308
309
|
socketPath: options.socketPath,
|
|
309
310
|
user,
|
|
310
311
|
steps: [
|
|
312
|
+
{
|
|
313
|
+
label: "vault socket access group",
|
|
314
|
+
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
315
|
+
},
|
|
311
316
|
...sourceBinPath && binPath ? [{
|
|
312
317
|
label: "root-owned agent runtime",
|
|
313
318
|
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
@@ -320,6 +325,10 @@ function planProvision(options) {
|
|
|
320
325
|
label: "service account",
|
|
321
326
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
322
327
|
},
|
|
328
|
+
{
|
|
329
|
+
label: "bind service account to vault group",
|
|
330
|
+
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
331
|
+
},
|
|
323
332
|
...deploymentEnabled ? [{
|
|
324
333
|
label: "credential-free deployment account",
|
|
325
334
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
@@ -391,6 +400,7 @@ export {
|
|
|
391
400
|
atLeast,
|
|
392
401
|
agentUnit,
|
|
393
402
|
agentEnrolmentUnit,
|
|
403
|
+
VAULT_GROUP,
|
|
394
404
|
UNIT_PATH,
|
|
395
405
|
ENROLMENT_UNIT_PATH,
|
|
396
406
|
DEPLOYMENT_RUNNER_USER,
|
|
@@ -8,6 +8,10 @@ interface RemoteProvisionClaimBase {
|
|
|
8
8
|
spec: {
|
|
9
9
|
reference: string;
|
|
10
10
|
imageKey: string;
|
|
11
|
+
/** Stable bootstrap placement. Online tenant placement normally omits these. */
|
|
12
|
+
guestName?: string;
|
|
13
|
+
guestAddress?: string;
|
|
14
|
+
cpuPoolKey?: string;
|
|
11
15
|
physicalCores: number;
|
|
12
16
|
vcpu: number;
|
|
13
17
|
memoryGib: number;
|
|
@@ -17,17 +21,34 @@ interface RemoteProvisionClaimBase {
|
|
|
17
21
|
confidential: boolean;
|
|
18
22
|
};
|
|
19
23
|
}
|
|
24
|
+
export interface GuestAccess {
|
|
25
|
+
sshUser: string;
|
|
26
|
+
/** Public keys only. Private SSH material is never part of a claim. */
|
|
27
|
+
sshPublicKeys: string[];
|
|
28
|
+
}
|
|
20
29
|
export type CreateRemoteProvisionClaim = RemoteProvisionClaimBase & {
|
|
21
30
|
action?: 'create';
|
|
31
|
+
access?: GuestAccess;
|
|
32
|
+
} & ({
|
|
22
33
|
enrolment: {
|
|
23
34
|
token: string;
|
|
24
35
|
tenantKey: string;
|
|
25
36
|
projectKey: string;
|
|
26
37
|
environmentKey: string;
|
|
27
38
|
};
|
|
28
|
-
|
|
39
|
+
bootstrap?: never;
|
|
40
|
+
} | {
|
|
41
|
+
bootstrap: {
|
|
42
|
+
kind: 'platform-genesis';
|
|
43
|
+
};
|
|
44
|
+
enrolment?: never;
|
|
45
|
+
});
|
|
29
46
|
export type RemoteProvisionClaim = CreateRemoteProvisionClaim | (RemoteProvisionClaimBase & {
|
|
30
47
|
action: 'delete';
|
|
48
|
+
/** Present only for the operator-laptop authority before platform genesis. */
|
|
49
|
+
bootstrap?: {
|
|
50
|
+
kind: 'platform-genesis';
|
|
51
|
+
};
|
|
31
52
|
});
|
|
32
53
|
export interface ProvisionResult {
|
|
33
54
|
guestAddress?: string;
|