From 4bba2948a2cc5003eb6896b37411af3d8f616f4d Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:46:14 -0400 Subject: [PATCH 1/6] feat(deployment): surface declared confidential compute config on the deployment view For deployments declaring a TEE type (services..params.tee in the stored SDL manifest): - show a Confidential Compute badge in the deployment sub-header (presence-driven, friendly CPU / CPU + GPU labels, declared-intent framing) - render the attestation sidecar's per-service resource carve-out (requested / sidecar / available) in the lease view, with a note when the declared budget is below the footprint - defensively keep the sidecar out of the lease-status service lists via one useLeaseStatus select chokepoint The feature self-gates on a declared params.tee, so non-CC deployments are unaffected. --- .../ConfidentialComputeResources.spec.tsx | 87 +++++ .../ConfidentialComputeResources.tsx | 75 +++++ .../deployments/DeploymentDetail.tsx | 4 +- .../deployments/DeploymentSubHeader.tsx | 7 +- .../src/components/deployments/LeaseRow.tsx | 5 + .../shared/ConfidentialComputeBadge.spec.tsx | 46 +++ .../shared/ConfidentialComputeBadge.tsx | 47 +++ .../src/queries/useLeaseQuery.spec.tsx | 64 +++- apps/deploy-web/src/queries/useLeaseQuery.ts | 12 +- .../src/utils/confidentialCompute.spec.ts | 310 ++++++++++++++++++ .../src/utils/confidentialCompute.ts | 228 +++++++++++++ 11 files changed, 879 insertions(+), 6 deletions(-) create mode 100644 apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx create mode 100644 apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx create mode 100644 apps/deploy-web/src/components/shared/ConfidentialComputeBadge.spec.tsx create mode 100644 apps/deploy-web/src/components/shared/ConfidentialComputeBadge.tsx create mode 100644 apps/deploy-web/src/utils/confidentialCompute.spec.ts create mode 100644 apps/deploy-web/src/utils/confidentialCompute.ts diff --git a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx new file mode 100644 index 0000000000..839346fc89 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; + +import type { TeeServiceCarveout } from "@src/utils/confidentialCompute"; +import { ConfidentialComputeResources, DEPENDENCIES } from "./ConfidentialComputeResources"; + +import { render, screen } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +const MIB = 1024 * 1024; + +const cpuCarveout: TeeServiceCarveout = { + serviceName: "web", + teeType: "cpu", + requested: { cpu: 500, memory: 256 * MIB }, + reserved: { cpu: 100, memory: 64 * MIB }, + container: { cpu: 400, memory: 192 * MIB } +}; + +describe(ConfidentialComputeResources.name, () => { + it("renders nothing when there are no carve-outs", () => { + const { container } = setup({ carveouts: [] }); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the requested, sidecar and available rows with formatted values", () => { + setup({ carveouts: [cpuCarveout] }); + + expect(screen.getByText("Requested")).toBeInTheDocument(); + expect(screen.getByText("Attestation sidecar")).toBeInTheDocument(); + expect(screen.getByText("Available to your container")).toBeInTheDocument(); + + expect(screen.getByText("0.5 CPU")).toBeInTheDocument(); + expect(screen.getByText("256 MiB")).toBeInTheDocument(); + expect(screen.getByText("0.1 CPU")).toBeInTheDocument(); + expect(screen.getByText("64 MiB")).toBeInTheDocument(); + expect(screen.getByText("0.4 CPU")).toBeInTheDocument(); + expect(screen.getByText("192 MiB")).toBeInTheDocument(); + }); + + it("warns when the declared resources are at or below the attestation sidecar reservation", () => { + const constrained: TeeServiceCarveout = { + serviceName: "web", + teeType: "cpu", + requested: { cpu: 100, memory: 64 * MIB }, + reserved: { cpu: 100, memory: 64 * MIB }, + container: { cpu: 10, memory: 16 * MIB } + }; + setup({ carveouts: [constrained] }); + expect(screen.getByText(/minimum/i)).toBeInTheDocument(); + }); + + it("does not warn when the declared resources comfortably exceed the reservation", () => { + setup({ carveouts: [cpuCarveout] }); + expect(screen.queryByText(/minimum/i)).not.toBeInTheDocument(); + }); + + it("labels each service when more than one declares a TEE type", () => { + const second: TeeServiceCarveout = { + ...cpuCarveout, + serviceName: "api", + teeType: "cpu-gpu", + reserved: { cpu: 100, memory: 128 * MIB }, + container: { cpu: 400, memory: 128 * MIB } + }; + setup({ carveouts: [cpuCarveout, second] }); + + expect(screen.getByText("web")).toBeInTheDocument(); + expect(screen.getByText("api")).toBeInTheDocument(); + }); + + it("explains that billing is unaffected via the tooltip", () => { + const CustomTooltip = ({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) => ( + <> + {title} + {children} + + ); + setup({ carveouts: [cpuCarveout], dependencies: { CustomTooltip } }); + + expect(screen.getByText(/still pay for the full/i)).toBeInTheDocument(); + }); + + function setup(input: { carveouts: TeeServiceCarveout[]; dependencies?: Partial }) { + return render(); + } +}); diff --git a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx new file mode 100644 index 0000000000..6ec8df2232 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx @@ -0,0 +1,75 @@ +"use client"; +import * as React from "react"; +import { CustomTooltip } from "@akashnetwork/ui/components"; +import { Info } from "lucide-react"; + +import type { TeeServiceCarveout } from "@src/utils/confidentialCompute"; +import { MIN_PRIMARY_CPU_MILLICORES, MIN_PRIMARY_MEMORY_BYTES } from "@src/utils/confidentialCompute"; +import { roundDecimal } from "@src/utils/mathHelpers"; +import { bytesToShrink } from "@src/utils/unitUtils"; + +export type Props = { + carveouts: TeeServiceCarveout[]; + dependencies?: typeof DEPENDENCIES; +}; + +export const DEPENDENCIES = { CustomTooltip }; + +const formatCpu = (millicores: number) => `${roundDecimal(millicores / 1000, 2)} CPU`; + +const formatMemory = (bytes: number) => { + const { value, unit } = bytesToShrink(bytes, true); + return `${roundDecimal(value, 2)} ${unit}`; +}; + +function ResourceLine({ label, cpu, memory }: { label: string; cpu: number; memory: number }) { + return ( +
+ {label} + + {formatCpu(cpu)} + {formatMemory(memory)} + +
+ ); +} + +export function ConfidentialComputeResources({ carveouts, dependencies: d = DEPENDENCIES }: Props) { + if (carveouts.length === 0) return null; + + const multiple = carveouts.length > 1; + + return ( +
+
+ Confidential compute resources + + + +
+ + {carveouts.map(carveout => { + // When the declared budget is at/below the sidecar footprint the provider applies a minimum + // for the primary container, so the rows are no longer a clean subtraction — explain that. + const isConstrained = + carveout.requested.cpu - carveout.reserved.cpu < MIN_PRIMARY_CPU_MILLICORES || + carveout.requested.memory - carveout.reserved.memory < MIN_PRIMARY_MEMORY_BYTES; + + return ( +
+ {multiple &&
{carveout.serviceName}
} + + + + {isConstrained && ( +

+ This service's declared resources are at or below the attestation sidecar's reservation, so the provider applies a minimum for your + container — the values above are not a direct subtraction. Consider increasing the declared CPU and memory. +

+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx index 7f9de14a8f..a8523dd39c 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx @@ -24,6 +24,7 @@ import { useDeploymentLeaseList } from "@src/queries/useLeaseQuery"; import { useProviderList } from "@src/queries/useProvidersQuery"; import { extractRepositoryUrl } from "@src/services/remote-deploy/env-var-manager.service"; import { RouteStep } from "@src/types/route-steps.type"; +import { getDeclaredTeeTypesFromYaml } from "@src/utils/confidentialCompute"; import { isLeaseLive } from "@src/utils/reclamationUtils"; import { UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; @@ -103,6 +104,7 @@ export const DeploymentDetail: FC = ({ dseq }) => { }, [deployment, dseq, getLeases, getProviders, address, deploymentLocalStorage]); const isActive = deployment?.state === "active" && leases?.some(isLeaseLive); + const declaredTeeTypes = useMemo(() => getDeclaredTeeTypesFromYaml(deploymentManifest), [deploymentManifest]); const tabs = useMemo(() => { const tabs: { label: string; value: Tab; badged?: boolean }[] = [ @@ -242,7 +244,7 @@ export const DeploymentDetail: FC = ({ dseq }) => { <> - + changeTab(value as Tab)}> = ({ deployment, leases }) => { +export const DeploymentSubHeader: React.FunctionComponent = ({ deployment, leases, teeTypes = [] }) => { const { deploymentCost, realTimeLeft } = useDeploymentMetrics({ deployment, leases }); const isActive = deployment.state === "active"; const hasLeases = !!leases && leases.length > 0; @@ -100,6 +103,8 @@ export const DeploymentSubHeader: React.FunctionComponent = ({ deployment
{deployment.state}
+ + {isTrialing && } } diff --git a/apps/deploy-web/src/components/deployments/LeaseRow.tsx b/apps/deploy-web/src/components/deployments/LeaseRow.tsx index 4bff462673..d57feda71c 100644 --- a/apps/deploy-web/src/components/deployments/LeaseRow.tsx +++ b/apps/deploy-web/src/components/deployments/LeaseRow.tsx @@ -25,6 +25,7 @@ import { useLeaseStatus } from "@src/queries/useLeaseQuery"; import { useProviderStatus } from "@src/queries/useProvidersQuery"; import type { LeaseDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; +import { getTeeServiceCarveouts } from "@src/utils/confidentialCompute"; import { copyTextToClipboard } from "@src/utils/copyClipboard"; import { deploymentData } from "@src/utils/deploymentData"; import { getGpusFromAttributes } from "@src/utils/deploymentUtils"; @@ -35,6 +36,7 @@ import { CopyTextToClipboardButton } from "../shared/CopyTextToClipboardButton"; import { ManifestErrorSnackbar } from "../shared/ManifestErrorSnackbar/ManifestErrorSnackbar"; import { ProviderName } from "../shared/ProviderName"; import { ReclamationCard } from "./ReclamationCard/ReclamationCard"; +import { ConfidentialComputeResources } from "./ConfidentialComputeResources"; type Props = { index: number; @@ -101,6 +103,7 @@ export const LeaseRow = React.forwardRef( }, [isLeaseActive, provider, providerCredentials.details, getLeaseStatus, getProviderStatus]); const parsedManifest = useMemo(() => yaml.load(deploymentManifest), [deploymentManifest]); + const teeCarveouts = useMemo(() => getTeeServiceCarveouts(parsedManifest), [parsedManifest]); const checkIfServicesAreAvailable = (leaseStatus: LeaseStatusDto) => { const servicesNames = leaseStatus ? Object.keys(leaseStatus.services) : []; @@ -208,6 +211,8 @@ export const LeaseRow = React.forwardRef( size="medium" /> + + ( + <> + {title} + {children} + +); + +describe(ConfidentialComputeBadge.name, () => { + it("renders a friendly CPU label and the TEE explanation for a single cpu TEE type", () => { + setup({ teeTypes: ["cpu"], dependencies: { CustomTooltip: TitleRenderingTooltip } }); + expect(screen.getByText("Confidential Compute (CPU)")).toBeInTheDocument(); + expect(screen.getByText(/Trusted Execution Environment/)).toBeInTheDocument(); + expect(screen.getByText(/depends on the provider honoring the request and passing attestation/i)).toBeInTheDocument(); + }); + + it("renders a friendly CPU + GPU label for a single cpu-gpu TEE type", () => { + setup({ teeTypes: ["cpu-gpu"], dependencies: { CustomTooltip: TitleRenderingTooltip } }); + expect(screen.getByText("Confidential Compute (CPU + GPU)")).toBeInTheDocument(); + expect(screen.getByText(/Trusted Execution Environment/)).toBeInTheDocument(); + }); + + it("renders a typeless label and lists the distinct types in the tooltip for mixed deployments", () => { + setup({ teeTypes: ["cpu", "cpu-gpu"], dependencies: { CustomTooltip: TitleRenderingTooltip } }); + + expect(screen.getByText("Confidential Compute")).toBeInTheDocument(); + expect(screen.getByText(/CPU, CPU \+ GPU/)).toBeInTheDocument(); + }); + + it("renders nothing when no TEE types are declared", () => { + setup({ teeTypes: [] }); + expect(screen.queryByText(/Confidential Compute/)).not.toBeInTheDocument(); + }); + + function setup(input: { teeTypes: TeeType[]; dependencies?: Partial }) { + return render(); + } +}); diff --git a/apps/deploy-web/src/components/shared/ConfidentialComputeBadge.tsx b/apps/deploy-web/src/components/shared/ConfidentialComputeBadge.tsx new file mode 100644 index 0000000000..a304793ff6 --- /dev/null +++ b/apps/deploy-web/src/components/shared/ConfidentialComputeBadge.tsx @@ -0,0 +1,47 @@ +"use client"; +import * as React from "react"; +import { Badge, CustomTooltip } from "@akashnetwork/ui/components"; +import { cn } from "@akashnetwork/ui/utils"; +import { Info } from "lucide-react"; + +import type { TeeType } from "@src/utils/confidentialCompute"; +import { formatTeeTypeLabel } from "@src/utils/confidentialCompute"; + +export type Props = { + teeTypes: TeeType[]; + className?: string; + dependencies?: typeof DEPENDENCIES; +}; + +export const DEPENDENCIES = { + Badge, + CustomTooltip +}; + +export function ConfidentialComputeBadge({ teeTypes, className, dependencies: d = DEPENDENCIES }: Props) { + if (teeTypes.length === 0) return null; + + const label = teeTypes.length === 1 ? `Confidential Compute (${formatTeeTypeLabel(teeTypes[0])})` : "Confidential Compute"; + + return ( + +

+ One or more services in this deployment are configured to run in a Trusted Execution Environment (TEE), keeping their memory encrypted and isolated + from the provider. Confidentiality depends on the provider honoring the request and passing attestation. +

+

The provider automatically injects an attestation sidecar that reserves a small slice of the resources you declared.

+ {teeTypes.length > 1 &&

Declared types: {teeTypes.map(formatTeeTypeLabel).join(", ")}.

} + + } + > +
+ + {label} + + +
+
+ ); +} diff --git a/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx b/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx index 26aa97b618..fa92c4a1f8 100644 --- a/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx +++ b/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx @@ -12,7 +12,7 @@ import type { ApiProviderList } from "@src/types/provider"; import { leaseToDto } from "@src/utils/deploymentDetailUtils"; import { setupQuery } from "../../tests/unit/query-client"; import { QueryKeys } from "./queryKeys"; -import { USE_LEASE_STATUS_DEPENDENCIES, useAllLeases, useDeploymentLeaseList, useLeaseStatus } from "./useLeaseQuery"; +import { type LeaseStatusDto, USE_LEASE_STATUS_DEPENDENCIES, useAllLeases, useDeploymentLeaseList, useLeaseStatus } from "./useLeaseQuery"; import { act } from "@testing-library/react"; import { buildProvider } from "@tests/seeders/provider"; @@ -353,11 +353,71 @@ describe("useLeaseQuery", () => { expect(result.current.data).toEqual(mockLeaseStatus); }); + it("filters the attestation sidecar out of the returned services", async () => { + const provider = buildProvider(); + const statusWithSidecar = { + forwarded_ports: {}, + ips: {}, + services: { + web: { name: "web", available: 1 }, + "akash-attestation-sidecar": { name: "akash-attestation-sidecar", available: 1 } + } + }; + const providerProxy = mock({ + request: vi.fn().mockResolvedValue({ data: statusWithSidecar }) + }); + const { result } = setupLeaseStatus({ + provider, + lease: mockLease, + services: { + providerProxy: () => providerProxy + } + }); + + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.services).toHaveProperty("web"); + expect(result.current.data?.services).not.toHaveProperty("akash-attestation-sidecar"); + }); + + it("composes a caller-provided select on top of the sidecar filter", async () => { + const statusWithSidecar = { + forwarded_ports: {}, + ips: {}, + services: { + web: { name: "web", available: 1 }, + "akash-attestation-sidecar": { name: "akash-attestation-sidecar", available: 1 } + } + }; + const providerProxy = mock({ + request: vi.fn().mockResolvedValue({ data: statusWithSidecar }) + }); + const callerSelect = vi.fn((data: LeaseStatusDto | null) => data); + const { result } = setupLeaseStatus({ + lease: mockLease, + select: callerSelect, + services: { + providerProxy: () => providerProxy + } + }); + + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const receivedServices = callerSelect.mock.calls[0][0]?.services ?? {}; + expect(receivedServices).toHaveProperty("web"); + expect(receivedServices).not.toHaveProperty("akash-attestation-sidecar"); + }); + function setupLeaseStatus(input?: { provider?: ApiProviderList; lease?: LeaseDto; providerCredentials?: UseProviderCredentialsResult["details"]; services?: ServicesProviderProps["services"]; + select?: (data: LeaseStatusDto | null) => LeaseStatusDto | null; }) { const dependencies: typeof USE_LEASE_STATUS_DEPENDENCIES = { ...USE_LEASE_STATUS_DEPENDENCIES, @@ -372,7 +432,7 @@ describe("useLeaseQuery", () => { ensureToken: vi.fn().mockResolvedValue("jwt-token") }) }; - return setupQuery(() => useLeaseStatus({ provider: input?.provider || buildProvider(), lease: input?.lease, dependencies }), { + return setupQuery(() => useLeaseStatus({ provider: input?.provider || buildProvider(), lease: input?.lease, dependencies, select: input?.select }), { services: { providerProxy: () => mock(), ...input?.services diff --git a/apps/deploy-web/src/queries/useLeaseQuery.ts b/apps/deploy-web/src/queries/useLeaseQuery.ts index fdde6c3525..4eaaca4c4e 100644 --- a/apps/deploy-web/src/queries/useLeaseQuery.ts +++ b/apps/deploy-web/src/queries/useLeaseQuery.ts @@ -9,6 +9,7 @@ import { useScopedFetchProviderUrl } from "@src/hooks/useScopedFetchProviderUrl" import type { DeploymentDto, LeaseDto, RpcLease } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; import { ApiUrlService, loadWithPagination } from "@src/utils/apiUtils"; +import { omitAttestationSidecar } from "@src/utils/confidentialCompute"; import { leaseToDto } from "@src/utils/deploymentDetailUtils"; import { isLeaseLive } from "@src/utils/reclamationUtils"; import { QueryKeys } from "./queryKeys"; @@ -77,7 +78,7 @@ export function useLeaseStatus( dependencies?: typeof USE_LEASE_STATUS_DEPENDENCIES; } & Omit, "queryKey" | "queryFn"> = {} ) { - const { provider, lease, dependencies: d = USE_LEASE_STATUS_DEPENDENCIES, ...options } = params; + const { provider, lease, dependencies: d = USE_LEASE_STATUS_DEPENDENCIES, select: callerSelect, ...options } = params; const providerCredentials = d.useProviderCredentials(); const fetchProviderUrl = d.useScopedFetchProviderUrl(provider); @@ -100,7 +101,14 @@ export function useLeaseStatus( return response.data; }, ...options, - enabled: options.enabled !== false && providerCredentials.details.usable + enabled: options.enabled !== false && providerCredentials.details.usable, + // Defensive: the attestation sidecar is a pod container under the current provider design and + // never appears as a lease-status service, so this is a passthrough today. It keeps the sidecar + // out of every consumer (status list, logs/shell selectors) at one chokepoint if that changes. + select: data => { + const filtered = data ? omitAttestationSidecar(data) : data; + return callerSelect ? callerSelect(filtered) : filtered; + } }); } export const USE_LEASE_STATUS_DEPENDENCIES = { diff --git a/apps/deploy-web/src/utils/confidentialCompute.spec.ts b/apps/deploy-web/src/utils/confidentialCompute.spec.ts new file mode 100644 index 0000000000..64e883c7ec --- /dev/null +++ b/apps/deploy-web/src/utils/confidentialCompute.spec.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; + +import { + computeSidecarCarveout, + formatTeeTypeLabel, + getDeclaredTeeTypes, + getDeclaredTeeTypesFromYaml, + getServiceComputeResources, + getServiceTeeType, + getTeeServiceCarveouts, + MIN_PRIMARY_CPU_MILLICORES, + MIN_PRIMARY_MEMORY_BYTES, + omitAttestationSidecar, + SIDECAR_CPU_LIMIT_MILLICORES, + SIDECAR_MEMORY_LIMIT_BYTES +} from "./confidentialCompute"; + +const MIB = 1024 * 1024; +const GIB = 1024 * 1024 * 1024; + +describe(getDeclaredTeeTypes.name, () => { + it("returns an empty array for nullish or empty manifests", () => { + expect(getDeclaredTeeTypes(undefined)).toEqual([]); + expect(getDeclaredTeeTypes(null)).toEqual([]); + expect(getDeclaredTeeTypes({})).toEqual([]); + expect(getDeclaredTeeTypes({ services: {} })).toEqual([]); + }); + + it("returns the single declared type for a one-service deployment", () => { + expect(getDeclaredTeeTypes({ services: { web: { params: { tee: "cpu" } } } })).toEqual(["cpu"]); + expect(getDeclaredTeeTypes({ services: { web: { params: { tee: "cpu-gpu" } } } })).toEqual(["cpu-gpu"]); + }); + + it("returns the distinct set in canonical order for mixed multi-service deployments", () => { + const manifest = { + services: { + gpuApp: { params: { tee: "cpu-gpu" } }, + cpuApp: { params: { tee: "cpu" } } + } + }; + expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu", "cpu-gpu"]); + }); + + it("dedupes when several services declare the same type", () => { + const manifest = { + services: { + a: { params: { tee: "cpu" } }, + b: { params: { tee: "cpu" } } + } + }; + expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu"]); + }); + + it("ignores services without a tee param and unknown tee values", () => { + const manifest = { + services: { + plain: { image: "nginx" }, + bogus: { params: { tee: "quantum" } }, + valid: { params: { tee: "cpu" } } + } + }; + expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu"]); + }); + + it("returns an empty array for malformed manifests instead of throwing", () => { + expect(getDeclaredTeeTypes({ services: "not-an-object" })).toEqual([]); + expect(getDeclaredTeeTypes("just a string")).toEqual([]); + expect(getDeclaredTeeTypes(42)).toEqual([]); + }); +}); + +describe(getServiceTeeType.name, () => { + const manifest = { + services: { + web: { params: { tee: "cpu-gpu" } }, + api: { image: "nginx" } + } + }; + + it("returns the declared type for a TEE service", () => { + expect(getServiceTeeType(manifest, "web")).toBe("cpu-gpu"); + }); + + it("returns undefined for a service without a tee param", () => { + expect(getServiceTeeType(manifest, "api")).toBeUndefined(); + }); + + it("returns undefined for an unknown service", () => { + expect(getServiceTeeType(manifest, "missing")).toBeUndefined(); + }); + + it("returns undefined for malformed manifests", () => { + expect(getServiceTeeType(null, "web")).toBeUndefined(); + }); +}); + +describe(getServiceComputeResources.name, () => { + it("parses fractional cpu cores and mebibyte memory", () => { + const manifest = { + services: { web: { params: { tee: "cpu" } } }, + profiles: { compute: { web: { resources: { cpu: { units: 0.5 }, memory: { size: "256Mi" } } } } } + }; + expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 500, memoryBytes: 256 * MIB }); + }); + + it("parses millicore string cpu units", () => { + const manifest = { + profiles: { compute: { web: { resources: { cpu: { units: "500m" }, memory: { size: "1Gi" } } } } } + }; + expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 500, memoryBytes: GIB }); + }); + + it("parses whole-core string and number cpu units", () => { + const stringManifest = { + profiles: { compute: { web: { resources: { cpu: { units: "0.5" }, memory: { size: "256Mi" } } } } } + }; + expect(getServiceComputeResources(stringManifest, "web")?.cpuMillicores).toBe(500); + + const intManifest = { + profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "256Mi" } } } } } + }; + expect(getServiceComputeResources(intManifest, "web")?.cpuMillicores).toBe(1000); + }); + + it("parses a numeric (suffix-less) memory size as bytes", () => { + const manifest = { + profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: 268435456 } } } } } + }; + expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 1000, memoryBytes: 268435456 }); + }); + + it("returns undefined when the compute profile or resources are missing", () => { + expect(getServiceComputeResources({ profiles: { compute: {} } }, "web")).toBeUndefined(); + expect(getServiceComputeResources(null, "web")).toBeUndefined(); + }); + + it("returns undefined when the cpu units cannot be parsed", () => { + const manifest = { + profiles: { compute: { web: { resources: { cpu: { units: {} }, memory: { size: "256Mi" } } } } } + }; + expect(getServiceComputeResources(manifest, "web")).toBeUndefined(); + }); + + it("returns undefined when the memory size is unparseable or of an unexpected type", () => { + const garbage = { + profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "garbage" } } } } } + }; + expect(getServiceComputeResources(garbage, "web")).toBeUndefined(); + + const objectSize = { + profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: {} } } } } } + }; + expect(getServiceComputeResources(objectSize, "web")).toBeUndefined(); + }); +}); + +describe(computeSidecarCarveout.name, () => { + it("reserves 100m CPU / 64Mi for the cpu TEE type and leaves the remainder to the container", () => { + const result = computeSidecarCarveout({ cpuMillicores: 500, memoryBytes: 256 * MIB, teeType: "cpu" }); + expect(result).toEqual({ + reserved: { cpu: 100, memory: 64 * MIB }, + container: { cpu: 400, memory: 192 * MIB } + }); + }); + + it("reserves 128Mi memory for the cpu-gpu TEE type", () => { + const result = computeSidecarCarveout({ cpuMillicores: 500, memoryBytes: 256 * MIB, teeType: "cpu-gpu" }); + expect(result).toEqual({ + reserved: { cpu: 100, memory: 128 * MIB }, + container: { cpu: 400, memory: 128 * MIB } + }); + }); + + it("floors the container CPU at the minimum when the declared CPU is below the reservation", () => { + const result = computeSidecarCarveout({ cpuMillicores: 50, memoryBytes: 256 * MIB, teeType: "cpu" }); + expect(result.reserved.cpu).toBe(SIDECAR_CPU_LIMIT_MILLICORES); + expect(result.container.cpu).toBe(MIN_PRIMARY_CPU_MILLICORES); + }); + + it("floors the container memory at the minimum when the declared memory is below the reservation", () => { + const result = computeSidecarCarveout({ cpuMillicores: 500, memoryBytes: 32 * MIB, teeType: "cpu" }); + expect(result.reserved.memory).toBe(SIDECAR_MEMORY_LIMIT_BYTES.cpu); + expect(result.container.memory).toBe(MIN_PRIMARY_MEMORY_BYTES); + }); + + it("subtracts the reservation cleanly for large declarations", () => { + const result = computeSidecarCarveout({ cpuMillicores: 4000, memoryBytes: GIB, teeType: "cpu" }); + expect(result.container).toEqual({ cpu: 3900, memory: GIB - 64 * MIB }); + }); +}); + +describe(getTeeServiceCarveouts.name, () => { + it("returns a carve-out for each TEE service with resolvable resources", () => { + const manifest = { + services: { web: { params: { tee: "cpu" } } }, + profiles: { compute: { web: { resources: { cpu: { units: 0.5 }, memory: { size: "256Mi" } } } } } + }; + + expect(getTeeServiceCarveouts(manifest)).toEqual([ + { + serviceName: "web", + teeType: "cpu", + requested: { cpu: 500, memory: 256 * MIB }, + reserved: { cpu: 100, memory: 64 * MIB }, + container: { cpu: 400, memory: 192 * MIB } + } + ]); + }); + + it("returns an empty array when no service declares a TEE type", () => { + const manifest = { + services: { web: { image: "nginx" } }, + profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "1Gi" } } } } } + }; + expect(getTeeServiceCarveouts(manifest)).toEqual([]); + }); + + it("skips TEE services whose compute resources cannot be resolved", () => { + const manifest = { + services: { web: { params: { tee: "cpu" } } }, + profiles: { compute: {} } + }; + expect(getTeeServiceCarveouts(manifest)).toEqual([]); + }); + + it("returns carve-outs sorted by service name for mixed deployments", () => { + const manifest = { + services: { + zeta: { params: { tee: "cpu-gpu" } }, + alpha: { params: { tee: "cpu" } } + }, + profiles: { + compute: { + zeta: { resources: { cpu: { units: 1 }, memory: { size: "512Mi" } } }, + alpha: { resources: { cpu: { units: 1 }, memory: { size: "512Mi" } } } + } + } + }; + + const result = getTeeServiceCarveouts(manifest); + expect(result.map(c => c.serviceName)).toEqual(["alpha", "zeta"]); + expect(result[1].reserved.memory).toBe(128 * MIB); + }); + + it("returns an empty array for malformed manifests", () => { + expect(getTeeServiceCarveouts(null)).toEqual([]); + }); +}); + +describe(omitAttestationSidecar.name, () => { + it("returns the same reference when no attestation sidecar is present", () => { + const leaseStatus = { + services: { web: { name: "web" } }, + forwarded_ports: { web: [] }, + ips: {} + }; + expect(omitAttestationSidecar(leaseStatus)).toBe(leaseStatus); + }); + + it("strips the attestation sidecar from services, forwarded_ports and ips when present", () => { + const leaseStatus = { + services: { web: { name: "web" }, "akash-attestation-sidecar": { name: "akash-attestation-sidecar" } }, + forwarded_ports: { web: [], "akash-attestation-sidecar": [] }, + ips: { "akash-attestation-sidecar": [] } + }; + + const result = omitAttestationSidecar(leaseStatus); + + expect(result.services).toEqual({ web: { name: "web" } }); + expect(result.forwarded_ports).toEqual({ web: [] }); + expect(result.ips).toEqual({}); + expect(result).not.toBe(leaseStatus); + }); + + it("does not throw when forwarded_ports or ips are absent", () => { + const leaseStatus = { services: { "akash-attestation-sidecar": { name: "akash-attestation-sidecar" } } }; + expect(omitAttestationSidecar(leaseStatus).services).toEqual({}); + }); + + it("strips an attestation sidecar entry even when its value is falsy", () => { + const leaseStatus = { services: { web: { name: "web" }, "akash-attestation-sidecar": null } }; + const result = omitAttestationSidecar(leaseStatus); + expect(result.services).toEqual({ web: { name: "web" } }); + expect(result).not.toBe(leaseStatus); + }); +}); + +describe(getDeclaredTeeTypesFromYaml.name, () => { + it("parses a manifest YAML string and returns its declared TEE types", () => { + const manifestYaml = ["services:", " web:", " params:", " tee: cpu-gpu"].join("\n"); + expect(getDeclaredTeeTypesFromYaml(manifestYaml)).toEqual(["cpu-gpu"]); + }); + + it("returns an empty array for empty or nullish input", () => { + expect(getDeclaredTeeTypesFromYaml("")).toEqual([]); + expect(getDeclaredTeeTypesFromYaml(null)).toEqual([]); + expect(getDeclaredTeeTypesFromYaml(undefined)).toEqual([]); + }); + + it("returns an empty array for malformed YAML instead of throwing", () => { + expect(getDeclaredTeeTypesFromYaml("services: [unclosed")).toEqual([]); + }); +}); + +describe(formatTeeTypeLabel.name, () => { + it("renders friendly labels for each TEE type", () => { + expect(formatTeeTypeLabel("cpu")).toBe("CPU"); + expect(formatTeeTypeLabel("cpu-gpu")).toBe("CPU + GPU"); + }); +}); diff --git a/apps/deploy-web/src/utils/confidentialCompute.ts b/apps/deploy-web/src/utils/confidentialCompute.ts new file mode 100644 index 0000000000..c68ad1bd5b --- /dev/null +++ b/apps/deploy-web/src/utils/confidentialCompute.ts @@ -0,0 +1,228 @@ +import yaml from "js-yaml"; + +import { parseSizeStr } from "./deploymentData/helpers"; + +/** + * Confidential Compute (TEE) helpers for the deployment detail view. + * + * The declared TEE type lives only in the off-chain SDL manifest the user stored locally + * (`services..params.tee`); it is not projected on-chain. The constants below mirror the + * provider's attestation-sidecar resource footprint (akash-network/provider PR #396, + * `cluster/kube/builder`) so we can show the tenant how much of their declared budget the + * provider-injected sidecar reserves. + */ + +export type TeeType = "cpu" | "cpu-gpu"; + +/** Exact container name the provider injects (akash-network/provider, webhook/sidecar.go). */ +export const ATTESTATION_SIDECAR_SERVICE_NAME = "akash-attestation-sidecar"; + +const MEGABYTES = 1024 * 1024; + +/** Sidecar resource limits the provider subtracts from the primary container (PR #396 builder constants). */ +export const SIDECAR_CPU_LIMIT_MILLICORES = 100; +export const SIDECAR_MEMORY_LIMIT_BYTES: Record = { + cpu: 64 * MEGABYTES, + "cpu-gpu": 128 * MEGABYTES +}; + +/** Floors the provider applies to the primary container after subtraction. */ +export const MIN_PRIMARY_CPU_MILLICORES = 10; +export const MIN_PRIMARY_MEMORY_BYTES = 16 * MEGABYTES; + +const TEE_TYPES: readonly TeeType[] = ["cpu", "cpu-gpu"]; + +function isTeeType(value: unknown): value is TeeType { + return typeof value === "string" && (TEE_TYPES as readonly string[]).includes(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Returns the distinct set of TEE types declared across all services, in canonical order. + * Never throws — a malformed manifest yields an empty array so the deployment view stays intact. + */ +export function getDeclaredTeeTypes(parsedManifest: unknown): TeeType[] { + const declared = new Set(); + + if (isRecord(parsedManifest) && isRecord(parsedManifest.services)) { + for (const service of Object.values(parsedManifest.services)) { + const tee = isRecord(service) && isRecord(service.params) ? service.params.tee : undefined; + if (isTeeType(tee)) declared.add(tee); + } + } + + return TEE_TYPES.filter(type => declared.has(type)); +} + +/** + * Parses a stored SDL manifest (YAML string) and returns its declared TEE types. Never throws — + * malformed YAML yields an empty array, keeping the deployment view safe (the feature has no flag). + */ +export function getDeclaredTeeTypesFromYaml(manifestYaml: string | null | undefined): TeeType[] { + if (!manifestYaml) return []; + try { + return getDeclaredTeeTypes(yaml.load(manifestYaml)); + } catch { + return []; + } +} + +/** Returns the TEE type declared by a single service, or undefined when none/unknown. */ +export function getServiceTeeType(parsedManifest: unknown, serviceName: string): TeeType | undefined { + if (!isRecord(parsedManifest) || !isRecord(parsedManifest.services)) return undefined; + const service = parsedManifest.services[serviceName]; + const tee = isRecord(service) && isRecord(service.params) ? service.params.tee : undefined; + return isTeeType(tee) ? tee : undefined; +} + +function parseCpuToMillicores(units: unknown): number | undefined { + if (typeof units === "number" && Number.isFinite(units)) return units * 1000; + if (typeof units === "string") { + const trimmed = units.trim(); + if (trimmed.endsWith("m")) { + const millis = parseFloat(trimmed.slice(0, -1)); + return Number.isFinite(millis) ? millis : undefined; + } + const cores = parseFloat(trimmed); + return Number.isFinite(cores) ? cores * 1000 : undefined; + } + return undefined; +} + +/** + * Resolves a service's per-container compute resources from its compute profile + * (service name == compute-profile name, the console convention). Returns undefined when the + * profile or resources cannot be resolved. + */ +export function getServiceComputeResources(parsedManifest: unknown, serviceName: string): { cpuMillicores: number; memoryBytes: number } | undefined { + if (!isRecord(parsedManifest) || !isRecord(parsedManifest.profiles) || !isRecord(parsedManifest.profiles.compute)) return undefined; + + const profile = parsedManifest.profiles.compute[serviceName]; + const resources = isRecord(profile) ? profile.resources : undefined; + if (!isRecord(resources) || !isRecord(resources.cpu) || !isRecord(resources.memory)) return undefined; + + const cpuMillicores = parseCpuToMillicores(resources.cpu.units); + if (cpuMillicores === undefined) return undefined; + + // memory.size is normally a suffixed string ("256Mi"), but a hand-written SDL may use a raw byte + // count as a YAML number — accept both, mirroring how cpu.units accepts numbers and strings. + const memorySize = resources.memory.size; + let memoryBytes: number; + if (typeof memorySize === "string") { + memoryBytes = Number(parseSizeStr(memorySize)); + } else if (typeof memorySize === "number") { + memoryBytes = memorySize; + } else { + return undefined; + } + if (!Number.isFinite(memoryBytes)) return undefined; + + return { cpuMillicores, memoryBytes }; +} + +/** + * Replicates the provider's resource split: the sidecar's fixed limits are reserved, and the + * primary container keeps the remainder floored at the provider minimums. The lease's declared + * (billed) resources are unchanged — this only describes how the pod's budget is divided. + */ +export function computeSidecarCarveout(input: { cpuMillicores: number; memoryBytes: number; teeType: TeeType }): { + reserved: { cpu: number; memory: number }; + container: { cpu: number; memory: number }; +} { + const reservedCpu = SIDECAR_CPU_LIMIT_MILLICORES; + const reservedMemory = SIDECAR_MEMORY_LIMIT_BYTES[input.teeType]; + + return { + reserved: { cpu: reservedCpu, memory: reservedMemory }, + container: { + cpu: Math.max(input.cpuMillicores - reservedCpu, MIN_PRIMARY_CPU_MILLICORES), + memory: Math.max(input.memoryBytes - reservedMemory, MIN_PRIMARY_MEMORY_BYTES) + } + }; +} + +export interface TeeServiceCarveout { + serviceName: string; + teeType: TeeType; + requested: { cpu: number; memory: number }; + reserved: { cpu: number; memory: number }; + container: { cpu: number; memory: number }; +} + +/** + * Builds the per-service resource carve-out for every TEE service in the manifest whose compute + * resources can be resolved, sorted by service name. Services without a TEE type or with + * unresolvable resources are omitted. Never throws on malformed input. + * + * Note: this gates on resolvable resources, while the header badge gates only on a declared + * `params.tee` ({@link getDeclaredTeeTypes}). So a TEE service with an unresolvable compute profile + * (e.g. a non-standard manifest) shows the badge but no carve-out row — an accepted divergence, + * since console-generated SDLs always use the service-name == profile-name convention. + */ +export function getTeeServiceCarveouts(parsedManifest: unknown): TeeServiceCarveout[] { + if (!isRecord(parsedManifest) || !isRecord(parsedManifest.services)) return []; + + const carveouts: TeeServiceCarveout[] = []; + + for (const serviceName of Object.keys(parsedManifest.services).sort()) { + const teeType = getServiceTeeType(parsedManifest, serviceName); + if (!teeType) continue; + + const resources = getServiceComputeResources(parsedManifest, serviceName); + if (!resources) continue; + + const { reserved, container } = computeSidecarCarveout({ cpuMillicores: resources.cpuMillicores, memoryBytes: resources.memoryBytes, teeType }); + carveouts.push({ + serviceName, + teeType, + requested: { cpu: resources.cpuMillicores, memory: resources.memoryBytes }, + reserved, + container + }); + } + + return carveouts; +} + +interface LeaseStatusLike { + services: Record; + forwarded_ports?: Record; + ips?: Record | null; +} + +/** + * Strips the attestation sidecar from a lease status. Defensive: under the current provider design + * the sidecar is a pod container and never appears as a lease-status service, so this is a passthrough + * (returns the same reference) today. It guards against a future provider reporting it as a service. + */ +export function omitAttestationSidecar(leaseStatus: T): T { + if (!leaseStatus) return leaseStatus; + + // Detect by key presence (not truthiness) so a sidecar entry with a falsy value is still stripped, + // staying consistent with the `omit` helper below. + const hasSidecar = (record: Record | null | undefined): boolean => !!record && ATTESTATION_SIDECAR_SERVICE_NAME in record; + const present = hasSidecar(leaseStatus.services) || hasSidecar(leaseStatus.forwarded_ports) || hasSidecar(leaseStatus.ips); + + if (!present) return leaseStatus; + + const omit = (record: Record | null | undefined): Record | null | undefined => { + if (!record || !(ATTESTATION_SIDECAR_SERVICE_NAME in record)) return record; + const { [ATTESTATION_SIDECAR_SERVICE_NAME]: _omitted, ...rest } = record; + return rest; + }; + + return { + ...leaseStatus, + services: omit(leaseStatus.services) as T["services"], + forwarded_ports: omit(leaseStatus.forwarded_ports), + ips: omit(leaseStatus.ips) + }; +} + +/** Human-readable label for a TEE type (bare "cpu" reads oddly as a TEE indicator). */ +export function formatTeeTypeLabel(teeType: TeeType): string { + return teeType === "cpu-gpu" ? "CPU + GPU" : "CPU"; +} From 45d55f2c839b1b50a302b27cf6baaf708f239ed3 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:20:54 -0400 Subject: [PATCH 2/6] feat(deployment): request attestation scope in managed-wallet provider JWT --- .../deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx | 2 +- apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx index 026bc5b756..e2dc1b6db1 100644 --- a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx +++ b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx @@ -66,7 +66,7 @@ describe(useProviderJwt.name, () => { ttl: 1800, // 30 * 60 leases: { access: "scoped", - scope: ["status", "shell", "events", "logs", "send-manifest", "get-manifest"] + scope: ["status", "shell", "events", "logs", "send-manifest", "get-manifest", "attestation"] } } }); diff --git a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.ts b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.ts index f625931a74..eb5a6e5419 100644 --- a/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.ts +++ b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.ts @@ -62,7 +62,7 @@ export function useProviderJwt({ dependencies: d = DEPENDENCIES }: { dependencie ttl: 30 * 60, leases: { access: "scoped", - scope: ["status", "shell", "events", "logs", "send-manifest", "get-manifest"] + scope: ["status", "shell", "events", "logs", "send-manifest", "get-manifest", "attestation"] } } }); From 8336fbbe1e6fd01c4d018458c879ee2b280354d2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:13:19 -0400 Subject: [PATCH 3/6] fix(deployment): source confidential compute TEE type from the on-chain group attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confidential Compute is declared on-chain as a group placement requirement (group_spec.requirements.attributes "tee/type" = cpu | cpu-gpu) so only TEE-capable providers can bid and match — it is not a localStorage-only value as the initial implementation assumed. Reading the stored SDL manifest's params.tee meant the badge and carve-out would never render for a real CC deployment (e.g. one created from the CLI). Re-base on data deploy-web already loads: badge = distinct tee/type across deployment.groups; per-pod carve-out = one entry per lease.group group_spec.resources unit (the attestation sidecar is injected into every pod of a CC group), labelled by resource composition since on-chain groups carry no service names, with the replica count noted. Drop the js-yaml/params.tee manifest path (getDeclaredTeeTypesFromYaml, getServiceTeeType, getServiceComputeResources, getTeeServiceCarveouts). Keep the sidecar carve-out math and constants, omitAttestationSidecar, the badge, labels/tooltip and the no-feature-flag behaviour. --- .../ConfidentialComputeResources.spec.tsx | 40 ++- .../ConfidentialComputeResources.tsx | 24 +- .../deployments/DeploymentDetail.tsx | 4 +- .../src/components/deployments/LeaseRow.tsx | 6 +- .../src/utils/confidentialCompute.spec.ts | 301 ++++++++---------- .../src/utils/confidentialCompute.ts | 169 ++++------ 6 files changed, 244 insertions(+), 300 deletions(-) diff --git a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx index 839346fc89..2f8bb4ac64 100644 --- a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx @@ -1,17 +1,20 @@ import React from "react"; import { describe, expect, it } from "vitest"; -import type { TeeServiceCarveout } from "@src/utils/confidentialCompute"; +import type { TeeResourceCarveout } from "@src/utils/confidentialCompute"; import { ConfidentialComputeResources, DEPENDENCIES } from "./ConfidentialComputeResources"; import { render, screen } from "@testing-library/react"; import { MockComponents } from "@tests/unit/mocks"; const MIB = 1024 * 1024; +const GIB = 1024 * 1024 * 1024; -const cpuCarveout: TeeServiceCarveout = { - serviceName: "web", +const cpuCarveout: TeeResourceCarveout = { + id: "1", teeType: "cpu", + count: 1, + gpuUnits: 0, requested: { cpu: 500, memory: 256 * MIB }, reserved: { cpu: 100, memory: 64 * MIB }, container: { cpu: 400, memory: 192 * MIB } @@ -39,9 +42,8 @@ describe(ConfidentialComputeResources.name, () => { }); it("warns when the declared resources are at or below the attestation sidecar reservation", () => { - const constrained: TeeServiceCarveout = { - serviceName: "web", - teeType: "cpu", + const constrained: TeeResourceCarveout = { + ...cpuCarveout, requested: { cpu: 100, memory: 64 * MIB }, reserved: { cpu: 100, memory: 64 * MIB }, container: { cpu: 10, memory: 16 * MIB } @@ -55,18 +57,30 @@ describe(ConfidentialComputeResources.name, () => { expect(screen.queryByText(/minimum/i)).not.toBeInTheDocument(); }); - it("labels each service when more than one declares a TEE type", () => { - const second: TeeServiceCarveout = { + it("labels each resource unit by its composition when more than one is present", () => { + const second: TeeResourceCarveout = { ...cpuCarveout, - serviceName: "api", + id: "2", teeType: "cpu-gpu", + gpuUnits: 1, + requested: { cpu: 8000, memory: 32 * GIB }, reserved: { cpu: 100, memory: 128 * MIB }, - container: { cpu: 400, memory: 128 * MIB } + container: { cpu: 7900, memory: 32 * GIB - 128 * MIB } }; setup({ carveouts: [cpuCarveout, second] }); - expect(screen.getByText("web")).toBeInTheDocument(); - expect(screen.getByText("api")).toBeInTheDocument(); + expect(screen.getByText("0.5 CPU · 256 MiB")).toBeInTheDocument(); + expect(screen.getByText("8 CPU · 32 GiB · 1 GPU")).toBeInTheDocument(); + }); + + it("notes the replica count when a resource unit runs more than one pod", () => { + setup({ carveouts: [{ ...cpuCarveout, count: 3 }] }); + expect(screen.getByText(/3 replicas/i)).toBeInTheDocument(); + }); + + it("does not note replicas for a single-pod resource unit", () => { + setup({ carveouts: [cpuCarveout] }); + expect(screen.queryByText(/replicas/i)).not.toBeInTheDocument(); }); it("explains that billing is unaffected via the tooltip", () => { @@ -81,7 +95,7 @@ describe(ConfidentialComputeResources.name, () => { expect(screen.getByText(/still pay for the full/i)).toBeInTheDocument(); }); - function setup(input: { carveouts: TeeServiceCarveout[]; dependencies?: Partial }) { + function setup(input: { carveouts: TeeResourceCarveout[]; dependencies?: Partial }) { return render(); } }); diff --git a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx index 6ec8df2232..933b989184 100644 --- a/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx @@ -3,13 +3,13 @@ import * as React from "react"; import { CustomTooltip } from "@akashnetwork/ui/components"; import { Info } from "lucide-react"; -import type { TeeServiceCarveout } from "@src/utils/confidentialCompute"; +import type { TeeResourceCarveout } from "@src/utils/confidentialCompute"; import { MIN_PRIMARY_CPU_MILLICORES, MIN_PRIMARY_MEMORY_BYTES } from "@src/utils/confidentialCompute"; import { roundDecimal } from "@src/utils/mathHelpers"; import { bytesToShrink } from "@src/utils/unitUtils"; export type Props = { - carveouts: TeeServiceCarveout[]; + carveouts: TeeResourceCarveout[]; dependencies?: typeof DEPENDENCIES; }; @@ -22,6 +22,13 @@ const formatMemory = (bytes: number) => { return `${roundDecimal(value, 2)} ${unit}`; }; +/** Self-describing label for a resource unit (on-chain groups carry no service names). */ +const formatUnitLabel = (carveout: TeeResourceCarveout) => { + const parts = [formatCpu(carveout.requested.cpu), formatMemory(carveout.requested.memory)]; + if (carveout.gpuUnits > 0) parts.push(`${carveout.gpuUnits} GPU`); + return parts.join(" · "); +}; + function ResourceLine({ label, cpu, memory }: { label: string; cpu: number; memory: number }) { return (
@@ -56,15 +63,20 @@ export function ConfidentialComputeResources({ carveouts, dependencies: d = DEPE carveout.requested.memory - carveout.reserved.memory < MIN_PRIMARY_MEMORY_BYTES; return ( -
- {multiple &&
{carveout.serviceName}
} +
+ {(multiple || carveout.count > 1) && ( +
+ {multiple ? formatUnitLabel(carveout) : null} + {carveout.count > 1 && × {carveout.count} replicas} +
+ )} {isConstrained && (

- This service's declared resources are at or below the attestation sidecar's reservation, so the provider applies a minimum for your - container — the values above are not a direct subtraction. Consider increasing the declared CPU and memory. + These declared resources are at or below the attestation sidecar's reservation, so the provider applies a minimum for your container — the + values above are not a direct subtraction. Consider increasing the declared CPU and memory.

)}
diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx index a8523dd39c..6d861d1c97 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx @@ -24,7 +24,7 @@ import { useDeploymentLeaseList } from "@src/queries/useLeaseQuery"; import { useProviderList } from "@src/queries/useProvidersQuery"; import { extractRepositoryUrl } from "@src/services/remote-deploy/env-var-manager.service"; import { RouteStep } from "@src/types/route-steps.type"; -import { getDeclaredTeeTypesFromYaml } from "@src/utils/confidentialCompute"; +import { getDeclaredTeeTypes } from "@src/utils/confidentialCompute"; import { isLeaseLive } from "@src/utils/reclamationUtils"; import { UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; @@ -104,7 +104,7 @@ export const DeploymentDetail: FC = ({ dseq }) => { }, [deployment, dseq, getLeases, getProviders, address, deploymentLocalStorage]); const isActive = deployment?.state === "active" && leases?.some(isLeaseLive); - const declaredTeeTypes = useMemo(() => getDeclaredTeeTypesFromYaml(deploymentManifest), [deploymentManifest]); + const declaredTeeTypes = useMemo(() => getDeclaredTeeTypes(deployment?.groups), [deployment?.groups]); const tabs = useMemo(() => { const tabs: { label: string; value: Tab; badged?: boolean }[] = [ diff --git a/apps/deploy-web/src/components/deployments/LeaseRow.tsx b/apps/deploy-web/src/components/deployments/LeaseRow.tsx index d57feda71c..0c212c9df8 100644 --- a/apps/deploy-web/src/components/deployments/LeaseRow.tsx +++ b/apps/deploy-web/src/components/deployments/LeaseRow.tsx @@ -25,7 +25,7 @@ import { useLeaseStatus } from "@src/queries/useLeaseQuery"; import { useProviderStatus } from "@src/queries/useProvidersQuery"; import type { LeaseDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; -import { getTeeServiceCarveouts } from "@src/utils/confidentialCompute"; +import { getTeeResourceCarveouts } from "@src/utils/confidentialCompute"; import { copyTextToClipboard } from "@src/utils/copyClipboard"; import { deploymentData } from "@src/utils/deploymentData"; import { getGpusFromAttributes } from "@src/utils/deploymentUtils"; @@ -103,7 +103,9 @@ export const LeaseRow = React.forwardRef( }, [isLeaseActive, provider, providerCredentials.details, getLeaseStatus, getProviderStatus]); const parsedManifest = useMemo(() => yaml.load(deploymentManifest), [deploymentManifest]); - const teeCarveouts = useMemo(() => getTeeServiceCarveouts(parsedManifest), [parsedManifest]); + // TEE type + resources are declared on-chain (group placement requirement + group_spec.resources), + // so the carve-out is sourced from the lease's group rather than the locally stored SDL manifest. + const teeCarveouts = useMemo(() => getTeeResourceCarveouts(lease.group), [lease.group]); const checkIfServicesAreAvailable = (leaseStatus: LeaseStatusDto) => { const servicesNames = leaseStatus ? Object.keys(leaseStatus.services) : []; diff --git a/apps/deploy-web/src/utils/confidentialCompute.spec.ts b/apps/deploy-web/src/utils/confidentialCompute.spec.ts index 64e883c7ec..4c714c7ca1 100644 --- a/apps/deploy-web/src/utils/confidentialCompute.spec.ts +++ b/apps/deploy-web/src/utils/confidentialCompute.spec.ts @@ -1,156 +1,109 @@ +import type { QueryInput as DeepPartial } from "@akashnetwork/chain-sdk"; import { describe, expect, it } from "vitest"; +import type { DeploymentGroup } from "@src/types/deployment"; import { computeSidecarCarveout, formatTeeTypeLabel, getDeclaredTeeTypes, - getDeclaredTeeTypesFromYaml, - getServiceComputeResources, - getServiceTeeType, - getTeeServiceCarveouts, + getGroupTeeType, + getTeeResourceCarveouts, MIN_PRIMARY_CPU_MILLICORES, MIN_PRIMARY_MEMORY_BYTES, omitAttestationSidecar, SIDECAR_CPU_LIMIT_MILLICORES, - SIDECAR_MEMORY_LIMIT_BYTES + SIDECAR_MEMORY_LIMIT_BYTES, + type TeeType } from "./confidentialCompute"; +import { buildRpcDeployment } from "@tests/seeders"; + const MIB = 1024 * 1024; const GIB = 1024 * 1024 * 1024; -describe(getDeclaredTeeTypes.name, () => { - it("returns an empty array for nullish or empty manifests", () => { - expect(getDeclaredTeeTypes(undefined)).toEqual([]); - expect(getDeclaredTeeTypes(null)).toEqual([]); - expect(getDeclaredTeeTypes({})).toEqual([]); - expect(getDeclaredTeeTypes({ services: {} })).toEqual([]); - }); +/** Builds a single on-chain deployment group, seeded with realistic defaults and the given overrides. */ +function buildGroup(spec?: DeepPartial): DeploymentGroup { + return buildRpcDeployment(spec ? { groups: [spec] } : undefined).groups[0]; +} + +/** Builds a complete on-chain resource unit (the entries of group_spec.resources). */ +function buildResourceUnit(input: { id: number; cpuMillicores: number; memoryBytes: number; gpuUnits?: number; count?: number }) { + return { + resource: { + id: input.id, + cpu: { units: { val: String(input.cpuMillicores) }, attributes: [] }, + memory: { quantity: { val: String(input.memoryBytes) }, attributes: [] }, + storage: [{ name: "default", quantity: { val: String(512 * MIB) }, attributes: [] }], + gpu: { units: { val: String(input.gpuUnits ?? 0) }, attributes: [] }, + endpoints: [] + }, + count: input.count ?? 1, + price: { denom: "uakt", amount: "10000" } + }; +} - it("returns the single declared type for a one-service deployment", () => { - expect(getDeclaredTeeTypes({ services: { web: { params: { tee: "cpu" } } } })).toEqual(["cpu"]); - expect(getDeclaredTeeTypes({ services: { web: { params: { tee: "cpu-gpu" } } } })).toEqual(["cpu-gpu"]); - }); +const teeRequirement = (teeType: TeeType) => ({ requirements: { attributes: [{ key: "tee/type", value: teeType }] } }); - it("returns the distinct set in canonical order for mixed multi-service deployments", () => { - const manifest = { - services: { - gpuApp: { params: { tee: "cpu-gpu" } }, - cpuApp: { params: { tee: "cpu" } } - } - }; - expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu", "cpu-gpu"]); +describe(getGroupTeeType.name, () => { + it("returns the TEE type declared on the group's placement requirements", () => { + expect(getGroupTeeType(buildGroup({ group_spec: teeRequirement("cpu") }))).toBe("cpu"); + expect(getGroupTeeType(buildGroup({ group_spec: teeRequirement("cpu-gpu") }))).toBe("cpu-gpu"); }); - it("dedupes when several services declare the same type", () => { - const manifest = { - services: { - a: { params: { tee: "cpu" } }, - b: { params: { tee: "cpu" } } - } - }; - expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu"]); - }); - - it("ignores services without a tee param and unknown tee values", () => { - const manifest = { - services: { - plain: { image: "nginx" }, - bogus: { params: { tee: "quantum" } }, - valid: { params: { tee: "cpu" } } + it("ignores other placement attributes and reads only tee/type", () => { + const group = buildGroup({ + group_spec: { + requirements: { + attributes: [ + { key: "region", value: "us-west" }, + { key: "tee/type", value: "cpu-gpu" } + ] + } } - }; - expect(getDeclaredTeeTypes(manifest)).toEqual(["cpu"]); - }); - - it("returns an empty array for malformed manifests instead of throwing", () => { - expect(getDeclaredTeeTypes({ services: "not-an-object" })).toEqual([]); - expect(getDeclaredTeeTypes("just a string")).toEqual([]); - expect(getDeclaredTeeTypes(42)).toEqual([]); - }); -}); - -describe(getServiceTeeType.name, () => { - const manifest = { - services: { - web: { params: { tee: "cpu-gpu" } }, - api: { image: "nginx" } - } - }; - - it("returns the declared type for a TEE service", () => { - expect(getServiceTeeType(manifest, "web")).toBe("cpu-gpu"); + }); + expect(getGroupTeeType(group)).toBe("cpu-gpu"); }); - it("returns undefined for a service without a tee param", () => { - expect(getServiceTeeType(manifest, "api")).toBeUndefined(); + it("returns undefined when no tee/type attribute is present", () => { + expect(getGroupTeeType(buildGroup({ group_spec: { requirements: { attributes: [{ key: "region", value: "us-west" }] } } }))).toBeUndefined(); + expect(getGroupTeeType(buildGroup())).toBeUndefined(); }); - it("returns undefined for an unknown service", () => { - expect(getServiceTeeType(manifest, "missing")).toBeUndefined(); + it("returns undefined for an unrecognized tee/type value", () => { + expect(getGroupTeeType(buildGroup({ group_spec: { requirements: { attributes: [{ key: "tee/type", value: "sgx" }] } } }))).toBeUndefined(); }); - it("returns undefined for malformed manifests", () => { - expect(getServiceTeeType(null, "web")).toBeUndefined(); + it("returns undefined for a nullish or malformed group", () => { + expect(getGroupTeeType(undefined)).toBeUndefined(); + expect(getGroupTeeType(null)).toBeUndefined(); }); }); -describe(getServiceComputeResources.name, () => { - it("parses fractional cpu cores and mebibyte memory", () => { - const manifest = { - services: { web: { params: { tee: "cpu" } } }, - profiles: { compute: { web: { resources: { cpu: { units: 0.5 }, memory: { size: "256Mi" } } } } } - }; - expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 500, memoryBytes: 256 * MIB }); - }); - - it("parses millicore string cpu units", () => { - const manifest = { - profiles: { compute: { web: { resources: { cpu: { units: "500m" }, memory: { size: "1Gi" } } } } } - }; - expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 500, memoryBytes: GIB }); - }); - - it("parses whole-core string and number cpu units", () => { - const stringManifest = { - profiles: { compute: { web: { resources: { cpu: { units: "0.5" }, memory: { size: "256Mi" } } } } } - }; - expect(getServiceComputeResources(stringManifest, "web")?.cpuMillicores).toBe(500); - - const intManifest = { - profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "256Mi" } } } } } - }; - expect(getServiceComputeResources(intManifest, "web")?.cpuMillicores).toBe(1000); +describe(getDeclaredTeeTypes.name, () => { + it("returns an empty array for nullish or empty group lists", () => { + expect(getDeclaredTeeTypes(undefined)).toEqual([]); + expect(getDeclaredTeeTypes(null)).toEqual([]); + expect(getDeclaredTeeTypes([])).toEqual([]); }); - it("parses a numeric (suffix-less) memory size as bytes", () => { - const manifest = { - profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: 268435456 } } } } } - }; - expect(getServiceComputeResources(manifest, "web")).toEqual({ cpuMillicores: 1000, memoryBytes: 268435456 }); + it("returns the single declared type for a one-group deployment", () => { + expect(getDeclaredTeeTypes([buildGroup({ group_spec: teeRequirement("cpu") })])).toEqual(["cpu"]); + expect(getDeclaredTeeTypes([buildGroup({ group_spec: teeRequirement("cpu-gpu") })])).toEqual(["cpu-gpu"]); }); - it("returns undefined when the compute profile or resources are missing", () => { - expect(getServiceComputeResources({ profiles: { compute: {} } }, "web")).toBeUndefined(); - expect(getServiceComputeResources(null, "web")).toBeUndefined(); + it("returns the distinct set in canonical order across mixed groups", () => { + const groups = [buildGroup({ group_spec: teeRequirement("cpu-gpu") }), buildGroup({ group_spec: teeRequirement("cpu") })]; + expect(getDeclaredTeeTypes(groups)).toEqual(["cpu", "cpu-gpu"]); }); - it("returns undefined when the cpu units cannot be parsed", () => { - const manifest = { - profiles: { compute: { web: { resources: { cpu: { units: {} }, memory: { size: "256Mi" } } } } } - }; - expect(getServiceComputeResources(manifest, "web")).toBeUndefined(); + it("dedupes when several groups declare the same type", () => { + const groups = [buildGroup({ group_spec: teeRequirement("cpu") }), buildGroup({ group_spec: teeRequirement("cpu") })]; + expect(getDeclaredTeeTypes(groups)).toEqual(["cpu"]); }); - it("returns undefined when the memory size is unparseable or of an unexpected type", () => { - const garbage = { - profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "garbage" } } } } } - }; - expect(getServiceComputeResources(garbage, "web")).toBeUndefined(); - - const objectSize = { - profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: {} } } } } } - }; - expect(getServiceComputeResources(objectSize, "web")).toBeUndefined(); + it("ignores groups that declare no TEE type", () => { + const groups = [buildGroup({ group_spec: teeRequirement("cpu") }), buildGroup()]; + expect(getDeclaredTeeTypes(groups)).toEqual(["cpu"]); }); }); @@ -189,17 +142,21 @@ describe(computeSidecarCarveout.name, () => { }); }); -describe(getTeeServiceCarveouts.name, () => { - it("returns a carve-out for each TEE service with resolvable resources", () => { - const manifest = { - services: { web: { params: { tee: "cpu" } } }, - profiles: { compute: { web: { resources: { cpu: { units: 0.5 }, memory: { size: "256Mi" } } } } } - }; +describe(getTeeResourceCarveouts.name, () => { + it("returns a per-pod carve-out for each resource unit in a TEE group", () => { + const group = buildGroup({ + group_spec: { + requirements: { attributes: [{ key: "tee/type", value: "cpu" }] }, + resources: [buildResourceUnit({ id: 1, cpuMillicores: 500, memoryBytes: 256 * MIB })] + } + }); - expect(getTeeServiceCarveouts(manifest)).toEqual([ + expect(getTeeResourceCarveouts(group)).toEqual([ { - serviceName: "web", + id: "1", teeType: "cpu", + count: 1, + gpuUnits: 0, requested: { cpu: 500, memory: 256 * MIB }, reserved: { cpu: 100, memory: 64 * MIB }, container: { cpu: 400, memory: 192 * MIB } @@ -207,43 +164,64 @@ describe(getTeeServiceCarveouts.name, () => { ]); }); - it("returns an empty array when no service declares a TEE type", () => { - const manifest = { - services: { web: { image: "nginx" } }, - profiles: { compute: { web: { resources: { cpu: { units: 1 }, memory: { size: "1Gi" } } } } } - }; - expect(getTeeServiceCarveouts(manifest)).toEqual([]); + it("returns an empty array when the group declares no TEE type", () => { + const group = buildGroup({ group_spec: { resources: [buildResourceUnit({ id: 1, cpuMillicores: 1000, memoryBytes: GIB })] } }); + expect(getTeeResourceCarveouts(group)).toEqual([]); }); - it("skips TEE services whose compute resources cannot be resolved", () => { - const manifest = { - services: { web: { params: { tee: "cpu" } } }, - profiles: { compute: {} } - }; - expect(getTeeServiceCarveouts(manifest)).toEqual([]); - }); + it("uses the group's TEE type for every resource unit and carries replica count and gpu units", () => { + const group = buildGroup({ + group_spec: { + requirements: { attributes: [{ key: "tee/type", value: "cpu-gpu" }] }, + resources: [ + buildResourceUnit({ id: 1, cpuMillicores: 500, memoryBytes: 256 * MIB }), + buildResourceUnit({ id: 2, cpuMillicores: 8000, memoryBytes: 32 * GIB, gpuUnits: 1, count: 2 }) + ] + } + }); - it("returns carve-outs sorted by service name for mixed deployments", () => { - const manifest = { - services: { - zeta: { params: { tee: "cpu-gpu" } }, - alpha: { params: { tee: "cpu" } } + expect(getTeeResourceCarveouts(group)).toEqual([ + { + id: "1", + teeType: "cpu-gpu", + count: 1, + gpuUnits: 0, + requested: { cpu: 500, memory: 256 * MIB }, + reserved: { cpu: 100, memory: 128 * MIB }, + container: { cpu: 400, memory: 128 * MIB } }, - profiles: { - compute: { - zeta: { resources: { cpu: { units: 1 }, memory: { size: "512Mi" } } }, - alpha: { resources: { cpu: { units: 1 }, memory: { size: "512Mi" } } } - } + { + id: "2", + teeType: "cpu-gpu", + count: 2, + gpuUnits: 1, + requested: { cpu: 8000, memory: 32 * GIB }, + reserved: { cpu: 100, memory: 128 * MIB }, + container: { cpu: 7900, memory: 32 * GIB - 128 * MIB } } - }; + ]); + }); + + it("skips resource units whose on-chain cpu or memory cannot be parsed", () => { + const group = buildGroup({ + group_spec: { + requirements: { attributes: [{ key: "tee/type", value: "cpu" }] }, + resources: [ + { + ...buildResourceUnit({ id: 1, cpuMillicores: 0, memoryBytes: 0 }), + resource: { ...buildResourceUnit({ id: 1, cpuMillicores: 0, memoryBytes: 0 }).resource, cpu: { units: { val: "not-a-number" }, attributes: [] } } + }, + buildResourceUnit({ id: 2, cpuMillicores: 1000, memoryBytes: GIB }) + ] + } + }); - const result = getTeeServiceCarveouts(manifest); - expect(result.map(c => c.serviceName)).toEqual(["alpha", "zeta"]); - expect(result[1].reserved.memory).toBe(128 * MIB); + expect(getTeeResourceCarveouts(group).map(c => c.id)).toEqual(["2"]); }); - it("returns an empty array for malformed manifests", () => { - expect(getTeeServiceCarveouts(null)).toEqual([]); + it("returns an empty array for a nullish group", () => { + expect(getTeeResourceCarveouts(undefined)).toEqual([]); + expect(getTeeResourceCarveouts(null)).toEqual([]); }); }); @@ -285,23 +263,6 @@ describe(omitAttestationSidecar.name, () => { }); }); -describe(getDeclaredTeeTypesFromYaml.name, () => { - it("parses a manifest YAML string and returns its declared TEE types", () => { - const manifestYaml = ["services:", " web:", " params:", " tee: cpu-gpu"].join("\n"); - expect(getDeclaredTeeTypesFromYaml(manifestYaml)).toEqual(["cpu-gpu"]); - }); - - it("returns an empty array for empty or nullish input", () => { - expect(getDeclaredTeeTypesFromYaml("")).toEqual([]); - expect(getDeclaredTeeTypesFromYaml(null)).toEqual([]); - expect(getDeclaredTeeTypesFromYaml(undefined)).toEqual([]); - }); - - it("returns an empty array for malformed YAML instead of throwing", () => { - expect(getDeclaredTeeTypesFromYaml("services: [unclosed")).toEqual([]); - }); -}); - describe(formatTeeTypeLabel.name, () => { it("renders friendly labels for each TEE type", () => { expect(formatTeeTypeLabel("cpu")).toBe("CPU"); diff --git a/apps/deploy-web/src/utils/confidentialCompute.ts b/apps/deploy-web/src/utils/confidentialCompute.ts index c68ad1bd5b..a80b96476b 100644 --- a/apps/deploy-web/src/utils/confidentialCompute.ts +++ b/apps/deploy-web/src/utils/confidentialCompute.ts @@ -1,19 +1,21 @@ -import yaml from "js-yaml"; - -import { parseSizeStr } from "./deploymentData/helpers"; +import type { DeploymentGroup } from "@src/types/deployment"; /** * Confidential Compute (TEE) helpers for the deployment detail view. * - * The declared TEE type lives only in the off-chain SDL manifest the user stored locally - * (`services..params.tee`); it is not projected on-chain. The constants below mirror the - * provider's attestation-sidecar resource footprint (akash-network/provider PR #396, - * `cluster/kube/builder`) so we can show the tenant how much of their declared budget the - * provider-injected sidecar reserves. + * The declared TEE type is an on-chain group placement requirement: `group_spec.requirements.attributes` + * carries a `tee/type` attribute (`cpu` | `cpu-gpu`) so only TEE-capable providers can bid/match. It is + * therefore authoritative and available for every deployment (Console, CLI or otherwise) without the + * stored SDL manifest. The constants below mirror the provider's attestation-sidecar resource footprint + * (akash-network/provider PR #396, `cluster/kube/builder`) so we can show the tenant how much of their + * declared per-pod budget the provider-injected sidecar reserves. */ export type TeeType = "cpu" | "cpu-gpu"; +/** On-chain group placement attribute key carrying the declared TEE type. */ +export const TEE_TYPE_ATTRIBUTE_KEY = "tee/type"; + /** Exact container name the provider injects (akash-network/provider, webhook/sidecar.go). */ export const ATTESTATION_SIDECAR_SERVICE_NAME = "akash-attestation-sidecar"; @@ -36,97 +38,43 @@ function isTeeType(value: unknown): value is TeeType { return typeof value === "string" && (TEE_TYPES as readonly string[]).includes(value); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +/** Parses an on-chain integer-valued string (millicores, bytes, gpu units). Returns undefined on garbage. */ +function parseIntegerVal(value: unknown): number | undefined { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (typeof value !== "string") return undefined; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; } -/** - * Returns the distinct set of TEE types declared across all services, in canonical order. - * Never throws — a malformed manifest yields an empty array so the deployment view stays intact. - */ -export function getDeclaredTeeTypes(parsedManifest: unknown): TeeType[] { - const declared = new Set(); - - if (isRecord(parsedManifest) && isRecord(parsedManifest.services)) { - for (const service of Object.values(parsedManifest.services)) { - const tee = isRecord(service) && isRecord(service.params) ? service.params.tee : undefined; - if (isTeeType(tee)) declared.add(tee); - } - } - - return TEE_TYPES.filter(type => declared.has(type)); +/** Returns the TEE type declared on a group's on-chain placement requirements, or undefined when none/unknown. */ +export function getGroupTeeType(group: DeploymentGroup | undefined | null): TeeType | undefined { + const attributes = group?.group_spec?.requirements?.attributes; + if (!Array.isArray(attributes)) return undefined; + const value = attributes.find(attribute => attribute?.key === TEE_TYPE_ATTRIBUTE_KEY)?.value; + return isTeeType(value) ? value : undefined; } /** - * Parses a stored SDL manifest (YAML string) and returns its declared TEE types. Never throws — - * malformed YAML yields an empty array, keeping the deployment view safe (the feature has no flag). + * Returns the distinct set of TEE types declared across all of a deployment's groups, in canonical order. + * Never throws — a missing or malformed group list yields an empty array so the deployment view stays intact. */ -export function getDeclaredTeeTypesFromYaml(manifestYaml: string | null | undefined): TeeType[] { - if (!manifestYaml) return []; - try { - return getDeclaredTeeTypes(yaml.load(manifestYaml)); - } catch { - return []; - } -} - -/** Returns the TEE type declared by a single service, or undefined when none/unknown. */ -export function getServiceTeeType(parsedManifest: unknown, serviceName: string): TeeType | undefined { - if (!isRecord(parsedManifest) || !isRecord(parsedManifest.services)) return undefined; - const service = parsedManifest.services[serviceName]; - const tee = isRecord(service) && isRecord(service.params) ? service.params.tee : undefined; - return isTeeType(tee) ? tee : undefined; -} +export function getDeclaredTeeTypes(groups: DeploymentGroup[] | undefined | null): TeeType[] { + const declared = new Set(); -function parseCpuToMillicores(units: unknown): number | undefined { - if (typeof units === "number" && Number.isFinite(units)) return units * 1000; - if (typeof units === "string") { - const trimmed = units.trim(); - if (trimmed.endsWith("m")) { - const millis = parseFloat(trimmed.slice(0, -1)); - return Number.isFinite(millis) ? millis : undefined; + if (Array.isArray(groups)) { + for (const group of groups) { + const teeType = getGroupTeeType(group); + if (teeType) declared.add(teeType); } - const cores = parseFloat(trimmed); - return Number.isFinite(cores) ? cores * 1000 : undefined; - } - return undefined; -} - -/** - * Resolves a service's per-container compute resources from its compute profile - * (service name == compute-profile name, the console convention). Returns undefined when the - * profile or resources cannot be resolved. - */ -export function getServiceComputeResources(parsedManifest: unknown, serviceName: string): { cpuMillicores: number; memoryBytes: number } | undefined { - if (!isRecord(parsedManifest) || !isRecord(parsedManifest.profiles) || !isRecord(parsedManifest.profiles.compute)) return undefined; - - const profile = parsedManifest.profiles.compute[serviceName]; - const resources = isRecord(profile) ? profile.resources : undefined; - if (!isRecord(resources) || !isRecord(resources.cpu) || !isRecord(resources.memory)) return undefined; - - const cpuMillicores = parseCpuToMillicores(resources.cpu.units); - if (cpuMillicores === undefined) return undefined; - - // memory.size is normally a suffixed string ("256Mi"), but a hand-written SDL may use a raw byte - // count as a YAML number — accept both, mirroring how cpu.units accepts numbers and strings. - const memorySize = resources.memory.size; - let memoryBytes: number; - if (typeof memorySize === "string") { - memoryBytes = Number(parseSizeStr(memorySize)); - } else if (typeof memorySize === "number") { - memoryBytes = memorySize; - } else { - return undefined; } - if (!Number.isFinite(memoryBytes)) return undefined; - return { cpuMillicores, memoryBytes }; + return TEE_TYPES.filter(type => declared.has(type)); } /** * Replicates the provider's resource split: the sidecar's fixed limits are reserved, and the * primary container keeps the remainder floored at the provider minimums. The lease's declared - * (billed) resources are unchanged — this only describes how the pod's budget is divided. + * (billed) resources are unchanged — this only describes how a single pod's budget is divided. */ export function computeSidecarCarveout(input: { cpuMillicores: number; memoryBytes: number; teeType: TeeType }): { reserved: { cpu: number; memory: number }; @@ -144,45 +92,52 @@ export function computeSidecarCarveout(input: { cpuMillicores: number; memoryByt }; } -export interface TeeServiceCarveout { - serviceName: string; +export interface TeeResourceCarveout { + /** On-chain resource unit id, used as a stable React key. */ + id: string; teeType: TeeType; + /** Number of pods (replicas) using this resource unit; each pod gets its own attestation sidecar. */ + count: number; + /** GPU units per pod, used to disambiguate units when a lease declares more than one. */ + gpuUnits: number; + /** Per-pod declared (billed) resources. */ requested: { cpu: number; memory: number }; + /** Per-pod resources reserved by the attestation sidecar. */ reserved: { cpu: number; memory: number }; + /** Per-pod resources left to the primary container after the sidecar reservation. */ container: { cpu: number; memory: number }; } /** - * Builds the per-service resource carve-out for every TEE service in the manifest whose compute - * resources can be resolved, sorted by service name. Services without a TEE type or with - * unresolvable resources are omitted. Never throws on malformed input. - * - * Note: this gates on resolvable resources, while the header badge gates only on a declared - * `params.tee` ({@link getDeclaredTeeTypes}). So a TEE service with an unresolvable compute profile - * (e.g. a non-standard manifest) shows the badge but no carve-out row — an accepted divergence, - * since console-generated SDLs always use the service-name == profile-name convention. + * Builds the per-pod resource carve-out for every resource unit in a TEE group. The `tee/type` attribute + * is a group-level placement requirement, so the provider injects the attestation sidecar into every pod + * of the group — each resource unit (and each of its `count` replicas) reserves the same sidecar footprint. + * Returns an empty array when the group declares no TEE type. Resource units whose on-chain cpu/memory + * cannot be parsed are skipped; never throws on malformed input. */ -export function getTeeServiceCarveouts(parsedManifest: unknown): TeeServiceCarveout[] { - if (!isRecord(parsedManifest) || !isRecord(parsedManifest.services)) return []; - - const carveouts: TeeServiceCarveout[] = []; +export function getTeeResourceCarveouts(group: DeploymentGroup | undefined | null): TeeResourceCarveout[] { + const teeType = getGroupTeeType(group); + const resources = group?.group_spec?.resources; + if (!teeType || !Array.isArray(resources)) return []; - for (const serviceName of Object.keys(parsedManifest.services).sort()) { - const teeType = getServiceTeeType(parsedManifest, serviceName); - if (!teeType) continue; + const carveouts: TeeResourceCarveout[] = []; - const resources = getServiceComputeResources(parsedManifest, serviceName); - if (!resources) continue; + resources.forEach((entry, index) => { + const cpuMillicores = parseIntegerVal(entry?.resource?.cpu?.units?.val); + const memoryBytes = parseIntegerVal(entry?.resource?.memory?.quantity?.val); + if (cpuMillicores === undefined || memoryBytes === undefined) return; - const { reserved, container } = computeSidecarCarveout({ cpuMillicores: resources.cpuMillicores, memoryBytes: resources.memoryBytes, teeType }); + const { reserved, container } = computeSidecarCarveout({ cpuMillicores, memoryBytes, teeType }); carveouts.push({ - serviceName, + id: String(entry?.resource?.id ?? index), teeType, - requested: { cpu: resources.cpuMillicores, memory: resources.memoryBytes }, + count: entry?.count ?? 1, + gpuUnits: parseIntegerVal(entry?.resource?.gpu?.units?.val) ?? 0, + requested: { cpu: cpuMillicores, memory: memoryBytes }, reserved, container }); - } + }); return carveouts; } From e124913544e65ba16560e588327f50d9be018700 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:45:10 -0400 Subject: [PATCH 4/6] refactor(deployment): consume confidential-compute derivations via hooks Wrap the root-level getDeclaredTeeTypes / getTeeResourceCarveouts calls in useDeclaredTeeTypes / useTeeResourceCarveouts hooks so DeploymentDetail and LeaseRow call a hook at the render root rather than a bare memoized util, following the established pattern (cf. useTrialDeploymentTimeRemaining). The pure functions stay in confidentialCompute.ts with their unit tests; the hooks own the useMemo. No behaviour change. --- .../deployments/DeploymentDetail.tsx | 4 +-- .../src/components/deployments/LeaseRow.tsx | 6 ++-- .../src/hooks/useDeclaredTeeTypes.spec.ts | 27 ++++++++++++++++++ .../src/hooks/useDeclaredTeeTypes.ts | 10 +++++++ .../src/hooks/useTeeResourceCarveouts.spec.ts | 28 +++++++++++++++++++ .../src/hooks/useTeeResourceCarveouts.ts | 14 ++++++++++ 6 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 apps/deploy-web/src/hooks/useDeclaredTeeTypes.spec.ts create mode 100644 apps/deploy-web/src/hooks/useDeclaredTeeTypes.ts create mode 100644 apps/deploy-web/src/hooks/useTeeResourceCarveouts.spec.ts create mode 100644 apps/deploy-web/src/hooks/useTeeResourceCarveouts.ts diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx index 6d861d1c97..45076839a3 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail.tsx @@ -15,6 +15,7 @@ import { DeploymentAlerts } from "@src/components/deployments/DeploymentAlerts/D import { useServices } from "@src/context/ServicesProvider"; import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; +import { useDeclaredTeeTypes } from "@src/hooks/useDeclaredTeeTypes"; import { useFlag } from "@src/hooks/useFlag"; import { useNavigationGuard } from "@src/hooks/useNavigationGuard/useNavigationGuard"; import { useUser } from "@src/hooks/useUser"; @@ -24,7 +25,6 @@ import { useDeploymentLeaseList } from "@src/queries/useLeaseQuery"; import { useProviderList } from "@src/queries/useProvidersQuery"; import { extractRepositoryUrl } from "@src/services/remote-deploy/env-var-manager.service"; import { RouteStep } from "@src/types/route-steps.type"; -import { getDeclaredTeeTypes } from "@src/utils/confidentialCompute"; import { isLeaseLive } from "@src/utils/reclamationUtils"; import { UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; @@ -104,7 +104,7 @@ export const DeploymentDetail: FC = ({ dseq }) => { }, [deployment, dseq, getLeases, getProviders, address, deploymentLocalStorage]); const isActive = deployment?.state === "active" && leases?.some(isLeaseLive); - const declaredTeeTypes = useMemo(() => getDeclaredTeeTypes(deployment?.groups), [deployment?.groups]); + const declaredTeeTypes = useDeclaredTeeTypes(deployment); const tabs = useMemo(() => { const tabs: { label: string; value: Tab; badged?: boolean }[] = [ diff --git a/apps/deploy-web/src/components/deployments/LeaseRow.tsx b/apps/deploy-web/src/components/deployments/LeaseRow.tsx index 0c212c9df8..5b963b8dbb 100644 --- a/apps/deploy-web/src/components/deployments/LeaseRow.tsx +++ b/apps/deploy-web/src/components/deployments/LeaseRow.tsx @@ -19,13 +19,13 @@ import { SpecDetail } from "@src/components/shared/SpecDetail"; import { StatusPill } from "@src/components/shared/StatusPill"; import { useServices } from "@src/context/ServicesProvider"; import { useProviderCredentials } from "@src/hooks/useProviderCredentials/useProviderCredentials"; +import { useTeeResourceCarveouts } from "@src/hooks/useTeeResourceCarveouts"; import { useBidInfo } from "@src/queries/useBidQuery"; import type { LeaseStatusDto } from "@src/queries/useLeaseQuery"; import { useLeaseStatus } from "@src/queries/useLeaseQuery"; import { useProviderStatus } from "@src/queries/useProvidersQuery"; import type { LeaseDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; -import { getTeeResourceCarveouts } from "@src/utils/confidentialCompute"; import { copyTextToClipboard } from "@src/utils/copyClipboard"; import { deploymentData } from "@src/utils/deploymentData"; import { getGpusFromAttributes } from "@src/utils/deploymentUtils"; @@ -103,9 +103,7 @@ export const LeaseRow = React.forwardRef( }, [isLeaseActive, provider, providerCredentials.details, getLeaseStatus, getProviderStatus]); const parsedManifest = useMemo(() => yaml.load(deploymentManifest), [deploymentManifest]); - // TEE type + resources are declared on-chain (group placement requirement + group_spec.resources), - // so the carve-out is sourced from the lease's group rather than the locally stored SDL manifest. - const teeCarveouts = useMemo(() => getTeeResourceCarveouts(lease.group), [lease.group]); + const teeCarveouts = useTeeResourceCarveouts(lease); const checkIfServicesAreAvailable = (leaseStatus: LeaseStatusDto) => { const servicesNames = leaseStatus ? Object.keys(leaseStatus.services) : []; diff --git a/apps/deploy-web/src/hooks/useDeclaredTeeTypes.spec.ts b/apps/deploy-web/src/hooks/useDeclaredTeeTypes.spec.ts new file mode 100644 index 0000000000..bc9d1e77fa --- /dev/null +++ b/apps/deploy-web/src/hooks/useDeclaredTeeTypes.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { DeploymentDto } from "@src/types/deployment"; +import { useDeclaredTeeTypes } from "./useDeclaredTeeTypes"; + +import { renderHook } from "@testing-library/react"; +import { buildRpcDeployment } from "@tests/seeders"; + +describe(useDeclaredTeeTypes.name, () => { + it("returns the distinct declared TEE types across the deployment's on-chain groups", () => { + const groups = buildRpcDeployment({ + groups: [{ group_spec: { requirements: { attributes: [{ key: "tee/type", value: "cpu-gpu" }] } } }] + }).groups; + const { result } = setup({ deployment: mock({ groups }) }); + expect(result.current).toEqual(["cpu-gpu"]); + }); + + it("returns an empty array when the deployment is not loaded", () => { + const { result } = setup({ deployment: undefined }); + expect(result.current).toEqual([]); + }); + + function setup(input: { deployment: DeploymentDto | undefined }) { + return renderHook(() => useDeclaredTeeTypes(input.deployment)); + } +}); diff --git a/apps/deploy-web/src/hooks/useDeclaredTeeTypes.ts b/apps/deploy-web/src/hooks/useDeclaredTeeTypes.ts new file mode 100644 index 0000000000..658a2b28b9 --- /dev/null +++ b/apps/deploy-web/src/hooks/useDeclaredTeeTypes.ts @@ -0,0 +1,10 @@ +import { useMemo } from "react"; + +import type { DeploymentDto } from "@src/types/deployment"; +import type { TeeType } from "@src/utils/confidentialCompute"; +import { getDeclaredTeeTypes } from "@src/utils/confidentialCompute"; + +/** Distinct Confidential Compute TEE types declared on the deployment's on-chain groups. */ +export function useDeclaredTeeTypes(deployment: DeploymentDto | undefined | null): TeeType[] { + return useMemo(() => getDeclaredTeeTypes(deployment?.groups), [deployment?.groups]); +} diff --git a/apps/deploy-web/src/hooks/useTeeResourceCarveouts.spec.ts b/apps/deploy-web/src/hooks/useTeeResourceCarveouts.spec.ts new file mode 100644 index 0000000000..b08edb8280 --- /dev/null +++ b/apps/deploy-web/src/hooks/useTeeResourceCarveouts.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { LeaseDto } from "@src/types/deployment"; +import { useTeeResourceCarveouts } from "./useTeeResourceCarveouts"; + +import { renderHook } from "@testing-library/react"; +import { buildRpcDeployment } from "@tests/seeders"; + +describe(useTeeResourceCarveouts.name, () => { + it("returns a per-pod carve-out for each resource unit in the lease's on-chain group", () => { + const group = buildRpcDeployment({ + groups: [{ group_spec: { requirements: { attributes: [{ key: "tee/type", value: "cpu" }] } } }] + }).groups[0]; + const { result } = setup({ lease: mock({ group }) }); + expect(result.current).toHaveLength(1); + expect(result.current[0].teeType).toBe("cpu"); + }); + + it("returns an empty array when the lease has no matched group", () => { + const { result } = setup({ lease: mock({ group: undefined }) }); + expect(result.current).toEqual([]); + }); + + function setup(input: { lease: LeaseDto }) { + return renderHook(() => useTeeResourceCarveouts(input.lease)); + } +}); diff --git a/apps/deploy-web/src/hooks/useTeeResourceCarveouts.ts b/apps/deploy-web/src/hooks/useTeeResourceCarveouts.ts new file mode 100644 index 0000000000..c4df8fa5bc --- /dev/null +++ b/apps/deploy-web/src/hooks/useTeeResourceCarveouts.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; + +import type { LeaseDto } from "@src/types/deployment"; +import type { TeeResourceCarveout } from "@src/utils/confidentialCompute"; +import { getTeeResourceCarveouts } from "@src/utils/confidentialCompute"; + +/** + * Per-pod attestation-sidecar carve-outs for each resource unit in the lease's on-chain group. + * TEE type and resources are declared on-chain (group placement requirement + group_spec.resources), + * so this is sourced from the lease's group rather than the locally stored SDL manifest. + */ +export function useTeeResourceCarveouts(lease: LeaseDto | undefined | null): TeeResourceCarveout[] { + return useMemo(() => getTeeResourceCarveouts(lease?.group), [lease?.group]); +} From 5f6a333d94ec813f254c6fa860bf851f06df5294 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:05:57 -0400 Subject: [PATCH 5/6] refactor(deployment): dedupe sidecar key-presence check in omitAttestationSidecar --- .../src/utils/confidentialCompute.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/deploy-web/src/utils/confidentialCompute.ts b/apps/deploy-web/src/utils/confidentialCompute.ts index a80b96476b..78ec06e7c1 100644 --- a/apps/deploy-web/src/utils/confidentialCompute.ts +++ b/apps/deploy-web/src/utils/confidentialCompute.ts @@ -156,24 +156,28 @@ interface LeaseStatusLike { export function omitAttestationSidecar(leaseStatus: T): T { if (!leaseStatus) return leaseStatus; - // Detect by key presence (not truthiness) so a sidecar entry with a falsy value is still stripped, - // staying consistent with the `omit` helper below. - const hasSidecar = (record: Record | null | undefined): boolean => !!record && ATTESTATION_SIDECAR_SERVICE_NAME in record; - const present = hasSidecar(leaseStatus.services) || hasSidecar(leaseStatus.forwarded_ports) || hasSidecar(leaseStatus.ips); - - if (!present) return leaseStatus; - + // Strip by key presence (not truthiness) so a sidecar entry with a falsy value is still removed. + // Returns the same record reference when the key is absent, which lets an untouched status pass + // through unchanged below (referentially stable for React Query consumers). const omit = (record: Record | null | undefined): Record | null | undefined => { if (!record || !(ATTESTATION_SIDECAR_SERVICE_NAME in record)) return record; const { [ATTESTATION_SIDECAR_SERVICE_NAME]: _omitted, ...rest } = record; return rest; }; + const services = omit(leaseStatus.services); + const forwardedPorts = omit(leaseStatus.forwarded_ports); + const ips = omit(leaseStatus.ips); + + if (services === leaseStatus.services && forwardedPorts === leaseStatus.forwarded_ports && ips === leaseStatus.ips) { + return leaseStatus; + } + return { ...leaseStatus, - services: omit(leaseStatus.services) as T["services"], - forwarded_ports: omit(leaseStatus.forwarded_ports), - ips: omit(leaseStatus.ips) + services: services as T["services"], + forwarded_ports: forwardedPorts, + ips }; } From cf024512f045a1217382f4baf9c8aec440b5c008 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:21:15 -0400 Subject: [PATCH 6/6] fix(deployment): reject non-integer on-chain resource values in parseIntegerVal --- .../src/utils/confidentialCompute.spec.ts | 17 +++++++++++++++++ .../deploy-web/src/utils/confidentialCompute.ts | 7 ++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/deploy-web/src/utils/confidentialCompute.spec.ts b/apps/deploy-web/src/utils/confidentialCompute.spec.ts index 4c714c7ca1..22dcd86cd6 100644 --- a/apps/deploy-web/src/utils/confidentialCompute.spec.ts +++ b/apps/deploy-web/src/utils/confidentialCompute.spec.ts @@ -219,6 +219,23 @@ describe(getTeeResourceCarveouts.name, () => { expect(getTeeResourceCarveouts(group).map(c => c.id)).toEqual(["2"]); }); + it("skips resource units whose on-chain cpu is a suffixed (non-integer) string", () => { + const group = buildGroup({ + group_spec: { + requirements: { attributes: [{ key: "tee/type", value: "cpu" }] }, + resources: [ + { + ...buildResourceUnit({ id: 1, cpuMillicores: 500, memoryBytes: 256 * MIB }), + resource: { ...buildResourceUnit({ id: 1, cpuMillicores: 500, memoryBytes: 256 * MIB }).resource, cpu: { units: { val: "500Mi" }, attributes: [] } } + }, + buildResourceUnit({ id: 2, cpuMillicores: 1000, memoryBytes: GIB }) + ] + } + }); + + expect(getTeeResourceCarveouts(group).map(c => c.id)).toEqual(["2"]); + }); + it("returns an empty array for a nullish group", () => { expect(getTeeResourceCarveouts(undefined)).toEqual([]); expect(getTeeResourceCarveouts(null)).toEqual([]); diff --git a/apps/deploy-web/src/utils/confidentialCompute.ts b/apps/deploy-web/src/utils/confidentialCompute.ts index 78ec06e7c1..4c6e40db50 100644 --- a/apps/deploy-web/src/utils/confidentialCompute.ts +++ b/apps/deploy-web/src/utils/confidentialCompute.ts @@ -41,9 +41,10 @@ function isTeeType(value: unknown): value is TeeType { /** Parses an on-chain integer-valued string (millicores, bytes, gpu units). Returns undefined on garbage. */ function parseIntegerVal(value: unknown): number | undefined { if (typeof value === "number") return Number.isFinite(value) ? value : undefined; - if (typeof value !== "string") return undefined; - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; + // Require an exact integer string: parseInt accepts prefixes ("500Mi" -> 500), which would treat + // a malformed on-chain value as a valid count instead of skipping it. + if (typeof value !== "string" || !/^-?\d+$/.test(value)) return undefined; + return parseInt(value, 10); } /** Returns the TEE type declared on a group's on-chain placement requirements, or undefined when none/unknown. */