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 000000000..2f8bb4ac6 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.spec.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; + +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: 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 } +}; + +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: TeeResourceCarveout = { + ...cpuCarveout, + 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 resource unit by its composition when more than one is present", () => { + const second: TeeResourceCarveout = { + ...cpuCarveout, + id: "2", + teeType: "cpu-gpu", + gpuUnits: 1, + requested: { cpu: 8000, memory: 32 * GIB }, + reserved: { cpu: 100, memory: 128 * MIB }, + container: { cpu: 7900, memory: 32 * GIB - 128 * MIB } + }; + setup({ carveouts: [cpuCarveout, second] }); + + 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", () => { + 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: 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 new file mode 100644 index 000000000..933b98918 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/ConfidentialComputeResources.tsx @@ -0,0 +1,87 @@ +"use client"; +import * as React from "react"; +import { CustomTooltip } from "@akashnetwork/ui/components"; +import { Info } from "lucide-react"; + +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: TeeResourceCarveout[]; + 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}`; +}; + +/** 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 ( +
+ {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.count > 1) && ( +
+ {multiple ? formatUnitLabel(carveout) : null} + {carveout.count > 1 && × {carveout.count} replicas} +
+ )} + + + + {isConstrained && ( +

+ 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 7f9de14a8..45076839a 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"; @@ -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 = useDeclaredTeeTypes(deployment); 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 4bff46267..5b963b8db 100644 --- a/apps/deploy-web/src/components/deployments/LeaseRow.tsx +++ b/apps/deploy-web/src/components/deployments/LeaseRow.tsx @@ -19,6 +19,7 @@ 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"; @@ -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 = useTeeResourceCarveouts(lease); 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 000000000..a304793ff --- /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/hooks/useDeclaredTeeTypes.spec.ts b/apps/deploy-web/src/hooks/useDeclaredTeeTypes.spec.ts new file mode 100644 index 000000000..bc9d1e77f --- /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 000000000..658a2b28b --- /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/useProviderJwt/useProviderJwt.spec.tsx b/apps/deploy-web/src/hooks/useProviderJwt/useProviderJwt.spec.tsx index 026bc5b75..e2dc1b6db 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 f625931a7..eb5a6e541 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"] } } }); 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 000000000..b08edb828 --- /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 000000000..c4df8fa5b --- /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]); +} diff --git a/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx b/apps/deploy-web/src/queries/useLeaseQuery.spec.tsx index 26aa97b61..fa92c4a1f 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 fdde6c352..4eaaca4c4 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 000000000..22dcd86cd --- /dev/null +++ b/apps/deploy-web/src/utils/confidentialCompute.spec.ts @@ -0,0 +1,288 @@ +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, + getGroupTeeType, + getTeeResourceCarveouts, + MIN_PRIMARY_CPU_MILLICORES, + MIN_PRIMARY_MEMORY_BYTES, + omitAttestationSidecar, + SIDECAR_CPU_LIMIT_MILLICORES, + SIDECAR_MEMORY_LIMIT_BYTES, + type TeeType +} from "./confidentialCompute"; + +import { buildRpcDeployment } from "@tests/seeders"; + +const MIB = 1024 * 1024; +const GIB = 1024 * 1024 * 1024; + +/** 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" } + }; +} + +const teeRequirement = (teeType: TeeType) => ({ requirements: { attributes: [{ key: "tee/type", value: teeType }] } }); + +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("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(getGroupTeeType(group)).toBe("cpu-gpu"); + }); + + 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 unrecognized tee/type value", () => { + expect(getGroupTeeType(buildGroup({ group_spec: { requirements: { attributes: [{ key: "tee/type", value: "sgx" }] } } }))).toBeUndefined(); + }); + + it("returns undefined for a nullish or malformed group", () => { + expect(getGroupTeeType(undefined)).toBeUndefined(); + expect(getGroupTeeType(null)).toBeUndefined(); + }); +}); + +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("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 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("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("ignores groups that declare no TEE type", () => { + const groups = [buildGroup({ group_spec: teeRequirement("cpu") }), buildGroup()]; + expect(getDeclaredTeeTypes(groups)).toEqual(["cpu"]); + }); +}); + +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(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(getTeeResourceCarveouts(group)).toEqual([ + { + 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 } + } + ]); + }); + + 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("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 }) + ] + } + }); + + 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 } + }, + { + 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 }) + ] + } + }); + + 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([]); + }); +}); + +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(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 000000000..4c6e40db5 --- /dev/null +++ b/apps/deploy-web/src/utils/confidentialCompute.ts @@ -0,0 +1,188 @@ +import type { DeploymentGroup } from "@src/types/deployment"; + +/** + * Confidential Compute (TEE) helpers for the deployment detail view. + * + * 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"; + +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); +} + +/** 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; + // 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. */ +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; +} + +/** + * 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 getDeclaredTeeTypes(groups: DeploymentGroup[] | undefined | null): TeeType[] { + const declared = new Set(); + + if (Array.isArray(groups)) { + for (const group of groups) { + const teeType = getGroupTeeType(group); + if (teeType) declared.add(teeType); + } + } + + 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 a single 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 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-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 getTeeResourceCarveouts(group: DeploymentGroup | undefined | null): TeeResourceCarveout[] { + const teeType = getGroupTeeType(group); + const resources = group?.group_spec?.resources; + if (!teeType || !Array.isArray(resources)) return []; + + const carveouts: TeeResourceCarveout[] = []; + + 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, memoryBytes, teeType }); + carveouts.push({ + id: String(entry?.resource?.id ?? index), + teeType, + count: entry?.count ?? 1, + gpuUnits: parseIntegerVal(entry?.resource?.gpu?.units?.val) ?? 0, + requested: { cpu: cpuMillicores, memory: 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; + + // 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: services as T["services"], + forwarded_ports: forwardedPorts, + 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"; +}