From c2d8d4788f5302ad05708db442629886b4f91a62 Mon Sep 17 00:00:00 2001 From: zhanna Date: Mon, 17 Aug 2026 13:59:23 -0700 Subject: [PATCH 01/15] feat(2951): add admin GPU quotas for Emerald and KLAB2 --- .../products/_operations/create.ts | 12 +++ .../requests/[id]/decision/route.test.ts | 87 ++++++++++++++++++- .../(product)/[licencePlate]/edit/page.tsx | 18 +++- .../products/(product)/create/page.tsx | 1 + .../private-cloud/sections/Quotas.tsx | 29 +++++-- app/constants/private-cloud.ts | 3 +- .../mock-resources/private-cloud-product.ts | 58 ++++++++++++- app/prisma/schema.prisma | 2 + app/services/nats/private-cloud/index.ts | 3 +- app/validation-schemas/private-cloud.ts | 6 ++ 10 files changed, 199 insertions(+), 20 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/create.ts b/app/app/api/private-cloud/products/_operations/create.ts index b37314852d..97c205f205 100644 --- a/app/app/api/private-cloud/products/_operations/create.ts +++ b/app/app/api/private-cloud/products/_operations/create.ts @@ -45,6 +45,18 @@ export default async function createOp({ session, body }: { session: Session; bo if (rest.cluster === Cluster.GOLDDR) rest.cluster = Cluster.GOLD; + const gpuEnabledCluster = rest.cluster === Cluster.EMERALD || rest.cluster === Cluster.KLAB2; + + rest.resourceRequests = Object.fromEntries( + Object.entries(rest.resourceRequests).map(([namespace, requests]) => [ + namespace, + { + ...requests, + gpu: gpuEnabledCluster && session.isAdmin ? requests.gpu ?? 0 : 0, + }, + ]), + ) as typeof rest.resourceRequests; + const productData = { ...rest, licencePlate, diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts index 96787b1300..3c9f8a2f9e 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts @@ -1,7 +1,11 @@ import { expect } from '@jest/globals'; import { GlobalRole } from '@/constants'; import { createSamplePrivateCloudProductData } from '@/helpers/mock-resources'; -import { resourceRequests1, resourceRequests2 } from '@/helpers/mock-resources/private-cloud-product'; +import { + normalizeResourceRequests, + resourceRequests1, + resourceRequests2, +} from '@/helpers/mock-resources/private-cloud-product'; import { pickProductData } from '@/helpers/product'; import { DecisionStatus, Cluster, RequestType } from '@/prisma/client'; import { mockSessionByIdirGuid, mockSessionByRole } from '@/services/api-test/core'; @@ -42,6 +46,7 @@ async function makeBasicProductReview(decision: DecisionStatus, extra = {}) { const response = await makePrivateCloudRequestDecision(requests.main.id, { type: RequestType.CREATE, ...decisionData, + resourceRequests: normalizeResourceRequests(decisionData.resourceRequests), ...extra, decision: decision as 'APPROVED' | 'REJECTED', }); @@ -53,7 +58,10 @@ describe('Review Private Cloud Create Request - Permissions', () => { it('should successfully submit a create request for PO', async () => { await mockSessionByIdirGuid(productData.main.projectOwner.idirGuid); - const response = await createPrivateCloudProduct(productData.main); + const response = await createPrivateCloudProduct({ + ...productData.main, + resourceRequests: normalizeResourceRequests(productData.main.resourceRequests), + }); expect(response.status).toBe(200); requests.main = await response.json(); @@ -248,7 +256,10 @@ describe('Review Private Cloud Request - Validations', () => { it('should successfully submit a create request for TL1', async () => { await mockSessionByIdirGuid(productData.main.primaryTechnicalLead.idirGuid); - const response = await createPrivateCloudProduct(productData.main); + const response = await createPrivateCloudProduct({ + ...productData.main, + resourceRequests: normalizeResourceRequests(productData.main.resourceRequests), + }); expect(response.status).toBe(200); requests.main = await response.json(); @@ -293,7 +304,11 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { it('should successfully submit a create request with GOLD cluster and golddrEnabled', async () => { await mockSessionByIdirGuid(goldProductData.main.projectOwner.idirGuid); - const response = await createPrivateCloudProduct({ ...goldProductData.main, golddrEnabled: true }); + const response = await createPrivateCloudProduct({ + ...goldProductData.main, + resourceRequests: normalizeResourceRequests(goldProductData.main.resourceRequests), + golddrEnabled: true, + }); expect(response.status).toBe(200); goldRequests.main = await response.json(); @@ -308,6 +323,7 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { const response = await makePrivateCloudRequestDecision(requestData.id, { type: RequestType.CREATE, ...requestData.decisionData, + resourceRequests: normalizeResourceRequests(requestData.decisionData.resourceRequests), cluster: Cluster.SILVER, golddrEnabled: true, decision: DecisionStatus.APPROVED as 'APPROVED' | 'REJECTED', @@ -321,4 +337,67 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { expect(decisionData.cluster).toBe(Cluster.SILVER); expect(decisionData.golddrEnabled).toBe(false); }); + + it('should reset GPU quota to 0 for Silver', async () => { + const silverProductData = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.SILVER, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 2, + }, + test: { + ...resourceRequests1.test, + gpu: 1, + }, + }, + }, + }); + + await mockSessionByRole(GlobalRole.Admin); + + const response = await createPrivateCloudProduct({ + ...silverProductData, + resourceRequests: normalizeResourceRequests(silverProductData.resourceRequests), + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + expect(responseData.decisionData.resourceRequests.test.gpu).toBe(0); + expect(responseData.decisionData.resourceRequests.production.gpu).toBe(0); + expect(responseData.decisionData.resourceRequests.tools.gpu).toBe(0); + }); + + it('should reset GPU quota to 0 for non-admin users', async () => { + const emeraldProductData = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 8, + }, + }, + }, + }); + + await mockSessionByIdirGuid(emeraldProductData.projectOwner.idirGuid); + + const response = await createPrivateCloudProduct({ + ...emeraldProductData, + resourceRequests: normalizeResourceRequests(emeraldProductData.resourceRequests), + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); }); diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx index 6b61939d87..0b37bd6b0d 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx @@ -99,14 +99,25 @@ export default privateCloudProductEdit(({ session }) => { const { formState, reset } = methods; useEffect(() => { - if (!currentProduct) return; + if (!snap.currentProduct) return; + + const resourceRequests = Object.fromEntries( + Object.entries(snap.currentProduct.resourceRequests ?? {}).map(([namespace, requests]) => [ + namespace, + { + ...requests, + gpu: requests.gpu ?? 0, + }, + ]), + ) as ResourceRequestsEnv; setDisabled(!currentProduct._permissions.edit); reset( { - ...currentProduct, - ...getRepositoryFormValues(currentProduct), + ...snap.currentProduct, + resourceRequests, + repositories: snap.currentProduct.repositories ?? [], isAgMinistry: false, isAgMinistryChecked: true, }, @@ -166,6 +177,7 @@ export default privateCloudProductEdit(({ session }) => { isGoldDR: currentProduct.golddrEnabled ?? false, originalResourceRequests: currentProduct.resourceRequests, quotaContactRequired: true, + isAdmin: session?.isAdmin, }, }, ]; diff --git a/app/app/private-cloud/products/(product)/create/page.tsx b/app/app/private-cloud/products/(product)/create/page.tsx index 4e692922ac..59456161b8 100644 --- a/app/app/private-cloud/products/(product)/create/page.tsx +++ b/app/app/private-cloud/products/(product)/create/page.tsx @@ -66,6 +66,7 @@ export default privateCloudProductNew(({ session }) => { Component: Quotas, componentArgs: { disabled: true, + isAdmin: session?.isAdmin, }, }, { diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index e637241911..8be9422a6d 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -18,6 +18,7 @@ const resourceUnit = { cpu: 'Core', memory: 'GiB', storage: 'GiB', + gpu: '', }; export default function Quotas({ @@ -27,6 +28,7 @@ export default function Quotas({ licencePlate, originalResourceRequests, quotaContactRequired = false, + isAdmin = false, }: { disabled: boolean; cluster?: Cluster; @@ -34,10 +36,17 @@ export default function Quotas({ licencePlate?: string; originalResourceRequests?: ResourceRequestsEnv; quotaContactRequired?: boolean; + isAdmin?: boolean; }) { const { watch } = useFormContext(); - const [resourceRequests] = watch(['resourceRequests']); + const [resourceRequests, formCluster] = watch(['resourceRequests', 'cluster']); + + const currentCluster = cluster ?? formCluster; + + const canShowGpu = isAdmin && (currentCluster === Cluster.EMERALD || currentCluster === Cluster.KLAB2); + + const visibleResourceKeys = resourceKeys.filter((resourceKey) => resourceKey !== 'gpu' || canShowGpu); const subnetInformation = useQueries({ queries: ['dev', 'test', 'prod', 'tools'].map((environment) => { @@ -132,9 +141,7 @@ export default function Quotas({ const newVal = (resourceRequests[namespace] || {}) as ResourceRequests; const changed = hasOriginalVal && - (originalVal?.cpu !== newVal?.cpu || - originalVal?.memory !== newVal?.memory || - originalVal?.storage !== newVal?.storage); + visibleResourceKeys.some((resourceKey) => originalVal?.[resourceKey] !== newVal?.[resourceKey]); let subnetInfo: ReactNode = null; if (cluster === Cluster.EMERALD) { @@ -179,24 +186,30 @@ export default function Quotas({ {clusterLink} {subnetInfo} - {resourceKeys.map((resourceKey) => { + {visibleResourceKeys.map((resourceKey) => { const oldval = String(originalVal?.[resourceKey]); const newval = String(newVal[resourceKey]); return (
{hasOriginalVal && oldval !== newval && (
diff --git a/app/constants/private-cloud.ts b/app/constants/private-cloud.ts index 8856f9fc89..b8013c1e2b 100644 --- a/app/constants/private-cloud.ts +++ b/app/constants/private-cloud.ts @@ -49,6 +49,7 @@ export const defaultResourceRequests = { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }; export const privateCloudProductSorts = productSorts.concat([ @@ -77,7 +78,7 @@ export type ResourceRequestsEnvKeys = Array; export type ResourceRequestsKeys = Array; export const namespaceKeys: ResourceRequestsEnvKeys = ['development', 'test', 'production', 'tools']; -export const resourceKeys: ResourceRequestsKeys = ['cpu', 'memory', 'storage']; +export const resourceKeys: ResourceRequestsKeys = ['cpu', 'memory', 'storage', 'gpu']; export const privateCloudQuotaJustificationMaxLength = 1000; export const privateCloudProductDescriptionMaxLength = 1500; diff --git a/app/helpers/mock-resources/private-cloud-product.ts b/app/helpers/mock-resources/private-cloud-product.ts index 5eb00e2397..ebe3a78c46 100644 --- a/app/helpers/mock-resources/private-cloud-product.ts +++ b/app/helpers/mock-resources/private-cloud-product.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { ProjectStatus } from '@/prisma/client'; +import { ProjectStatus, ResourceRequestsEnv } from '@/prisma/client'; import { PrivateCloudProductDetail } from '@/types/private-cloud'; import { generateShortId } from '@/utils/js'; import { getRandomOrganization, getRandomCluster, getRandomUser } from './core'; @@ -9,21 +9,25 @@ export const resourceRequests1 = { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }, test: { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }, production: { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }, tools: { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }, }; @@ -32,27 +36,35 @@ export const resourceRequests2 = { cpu: 1, memory: 5, storage: 3, + gpu: 0, }, test: { cpu: 0.5, memory: 2, storage: 2, + gpu: 0, }, production: { cpu: 0.5, memory: 4, storage: 1, + gpu: 0, }, tools: { cpu: 0.5, memory: 2, storage: 1, + gpu: 0, }, }; +export type NormalizedPrivateCloudProductDetail = Omit & { + resourceRequests: NormalizedResourceRequestsEnv; +}; + export function createSamplePrivateCloudProduct(args?: { data?: Partial; -}): PrivateCloudProductDetail { +}): NormalizedPrivateCloudProductDetail { const { data } = args ?? {}; const projectOwner = getRandomUser(); @@ -61,6 +73,8 @@ export function createSamplePrivateCloudProduct(args?: { const organization = getRandomOrganization(); + const resourceRequests = normalizeResourceRequests(data?.resourceRequests ?? resourceRequests1); + const product = { id: generateShortId(), licencePlate: faker.string.uuid().substring(0, 6), @@ -76,7 +90,6 @@ export function createSamplePrivateCloudProduct(args?: { secondaryTechnicalLeadId: secondaryTechnicalLead.id, secondaryTechnicalLead, members: [], - resourceRequests: resourceRequests1, golddrEnabled: false, supportPhoneNumber: '', createdAt: new Date(), @@ -90,7 +103,46 @@ export function createSamplePrivateCloudProduct(args?: { organizationId: organization.id, organization: organization, ...data, + resourceRequests, }; return product; } + +export type NormalizedResourceRequestsEnv = { + [K in keyof ResourceRequestsEnv]: Omit & { + gpu: number; + }; +}; + +export function normalizeResourceRequests(resourceRequests: ResourceRequestsEnv): NormalizedResourceRequestsEnv { + return { + development: { + ...resourceRequests.development, + gpu: resourceRequests.development.gpu ?? 0, + }, + test: { + ...resourceRequests.test, + gpu: resourceRequests.test.gpu ?? 0, + }, + production: { + ...resourceRequests.production, + gpu: resourceRequests.production.gpu ?? 0, + }, + tools: { + ...resourceRequests.tools, + gpu: resourceRequests.tools.gpu ?? 0, + }, + }; +} + +export function normalizePrivateCloudProduct( + product: T, +): Omit & { + resourceRequests: NormalizedResourceRequestsEnv; +} { + return { + ...product, + resourceRequests: normalizeResourceRequests(product.resourceRequests), + }; +} diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 7cb8bf1475..74b40d53cf 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -582,6 +582,7 @@ type ResourceRequests { cpu Float memory Float storage Float + gpu Int? } type Budget { @@ -724,6 +725,7 @@ enum ResourceType { cpu memory storage + gpu } enum PrivateCloudProductMemberRole { diff --git a/app/services/nats/private-cloud/index.ts b/app/services/nats/private-cloud/index.ts index 11b77eaf97..7c00211f60 100644 --- a/app/services/nats/private-cloud/index.ts +++ b/app/services/nats/private-cloud/index.ts @@ -32,7 +32,7 @@ export default async function createPrivateCloudNatsMessage( const users = await prisma.user.findMany({ where: { id: { in: subscribers.map((user) => user.userId) } } }); const allianceLabel = organization.isAgMinistry ? 'JAG' : 'none'; - + const supportsGpu = cluster === Cluster.EMERALD || cluster === Cluster.KLAB2; const messageBody = { action: request.type.toLocaleLowerCase(), profile_id: id, @@ -71,6 +71,7 @@ export default async function createPrivateCloudNatsMessage( pvc_count: isEmptyStorage ? 0 : 60, }, snapshot: { count: isEmptyStorage ? 0 : 5 }, + ...(supportsGpu && { gpu: { requests: requests.gpu ?? 0 } }), }, }; }), diff --git a/app/validation-schemas/private-cloud.ts b/app/validation-schemas/private-cloud.ts index 4a8de4e0ea..8cf62d0aaa 100644 --- a/app/validation-schemas/private-cloud.ts +++ b/app/validation-schemas/private-cloud.ts @@ -93,6 +93,12 @@ export const resourceRequestsSchema = z.object({ .refine((val) => val % 1 === 0, { message: 'Storage must be an integer', }), + gpu: z + .number() + .int('GPU must be an integer') + .min(0, 'GPU must be at least 0') + .max(8, 'GPU must be at most 8') + .default(0), }); export const resourceRequestsEnvSchema = z.object({ From 4c597f4dc885dd7f8d11083c040f739d0a3f4bd2 Mon Sep 17 00:00:00 2001 From: zhanna Date: Wed, 19 Aug 2026 09:56:34 -0700 Subject: [PATCH 02/15] chore(2951): enforce GPU quota permissions and sanitization --- .../products/_operations/create.ts | 14 ++------ .../products/_operations/update.ts | 5 ++- .../requests/[id]/decision/route.test.ts | 34 ++++--------------- .../requests/(request)/[id]/decision/page.tsx | 1 + .../private-cloud/sections/Quotas.tsx | 14 ++++++-- app/helpers/licence-plate.ts | 19 +++++++++++ app/prisma/schema.prisma | 2 +- app/services/nats/private-cloud/index.ts | 2 +- 8 files changed, 46 insertions(+), 45 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/create.ts b/app/app/api/private-cloud/products/_operations/create.ts index 97c205f205..c1f8f579a8 100644 --- a/app/app/api/private-cloud/products/_operations/create.ts +++ b/app/app/api/private-cloud/products/_operations/create.ts @@ -1,7 +1,7 @@ import { Session } from 'next-auth'; import prisma from '@/core/prisma'; import { OkResponse, UnauthorizedResponse } from '@/core/responses'; -import generateLicencePlate from '@/helpers/licence-plate'; +import generateLicencePlate, { sanitizeGpuResourceRequests } from '@/helpers/licence-plate'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; import { DecisionStatus, ProjectStatus, RequestType, EventType, TaskType, Cluster } from '@/prisma/client'; import { sendCreateRequestEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; @@ -45,17 +45,7 @@ export default async function createOp({ session, body }: { session: Session; bo if (rest.cluster === Cluster.GOLDDR) rest.cluster = Cluster.GOLD; - const gpuEnabledCluster = rest.cluster === Cluster.EMERALD || rest.cluster === Cluster.KLAB2; - - rest.resourceRequests = Object.fromEntries( - Object.entries(rest.resourceRequests).map(([namespace, requests]) => [ - namespace, - { - ...requests, - gpu: gpuEnabledCluster && session.isAdmin ? requests.gpu ?? 0 : 0, - }, - ]), - ) as typeof rest.resourceRequests; + rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, rest.cluster, session.isAdmin); const productData = { ...rest, diff --git a/app/app/api/private-cloud/products/_operations/update.ts b/app/app/api/private-cloud/products/_operations/update.ts index 3a89117478..085bac9608 100644 --- a/app/app/api/private-cloud/products/_operations/update.ts +++ b/app/app/api/private-cloud/products/_operations/update.ts @@ -3,6 +3,7 @@ import { TypeOf } from 'zod'; import prisma from '@/core/prisma'; import { OkResponse, UnauthorizedResponse } from '@/core/responses'; import { getQuotaChangeStatus } from '@/helpers/auto-approval-check'; +import { sanitizeGpuResourceRequests } from '@/helpers/licence-plate'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; import { comparePrivateProductData } from '@/helpers/product-change'; import { DecisionStatus, Cluster, RequestType, EventType, TaskType } from '@/prisma/client'; @@ -53,6 +54,8 @@ export default async function updateOp({ rest.members = product.members.map(({ userId, roles }) => ({ userId, roles })); } + rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, product.cluster, session.isAdmin); + const productData = { ...rest, licencePlate: product.licencePlate, @@ -73,7 +76,7 @@ export default async function updateOp({ licencePlate: product.licencePlate, cluster: product.cluster, currentResourceRequests: product.resourceRequests, - requestedResourceRequests: body.resourceRequests, + requestedResourceRequests: rest.resourceRequests, }); // If there is no quota change or no quota upgrade and no golddr flag changes, the request is automatically approved diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts index 3c9f8a2f9e..48dd0ce702 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts @@ -1,11 +1,7 @@ import { expect } from '@jest/globals'; import { GlobalRole } from '@/constants'; import { createSamplePrivateCloudProductData } from '@/helpers/mock-resources'; -import { - normalizeResourceRequests, - resourceRequests1, - resourceRequests2, -} from '@/helpers/mock-resources/private-cloud-product'; +import { resourceRequests1, resourceRequests2 } from '@/helpers/mock-resources/private-cloud-product'; import { pickProductData } from '@/helpers/product'; import { DecisionStatus, Cluster, RequestType } from '@/prisma/client'; import { mockSessionByIdirGuid, mockSessionByRole } from '@/services/api-test/core'; @@ -46,7 +42,6 @@ async function makeBasicProductReview(decision: DecisionStatus, extra = {}) { const response = await makePrivateCloudRequestDecision(requests.main.id, { type: RequestType.CREATE, ...decisionData, - resourceRequests: normalizeResourceRequests(decisionData.resourceRequests), ...extra, decision: decision as 'APPROVED' | 'REJECTED', }); @@ -58,10 +53,7 @@ describe('Review Private Cloud Create Request - Permissions', () => { it('should successfully submit a create request for PO', async () => { await mockSessionByIdirGuid(productData.main.projectOwner.idirGuid); - const response = await createPrivateCloudProduct({ - ...productData.main, - resourceRequests: normalizeResourceRequests(productData.main.resourceRequests), - }); + const response = await createPrivateCloudProduct(productData.main); expect(response.status).toBe(200); requests.main = await response.json(); @@ -256,10 +248,7 @@ describe('Review Private Cloud Request - Validations', () => { it('should successfully submit a create request for TL1', async () => { await mockSessionByIdirGuid(productData.main.primaryTechnicalLead.idirGuid); - const response = await createPrivateCloudProduct({ - ...productData.main, - resourceRequests: normalizeResourceRequests(productData.main.resourceRequests), - }); + const response = await createPrivateCloudProduct(productData.main); expect(response.status).toBe(200); requests.main = await response.json(); @@ -304,11 +293,7 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { it('should successfully submit a create request with GOLD cluster and golddrEnabled', async () => { await mockSessionByIdirGuid(goldProductData.main.projectOwner.idirGuid); - const response = await createPrivateCloudProduct({ - ...goldProductData.main, - resourceRequests: normalizeResourceRequests(goldProductData.main.resourceRequests), - golddrEnabled: true, - }); + const response = await createPrivateCloudProduct({ ...goldProductData.main, golddrEnabled: true }); expect(response.status).toBe(200); goldRequests.main = await response.json(); @@ -323,7 +308,6 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { const response = await makePrivateCloudRequestDecision(requestData.id, { type: RequestType.CREATE, ...requestData.decisionData, - resourceRequests: normalizeResourceRequests(requestData.decisionData.resourceRequests), cluster: Cluster.SILVER, golddrEnabled: true, decision: DecisionStatus.APPROVED as 'APPROVED' | 'REJECTED', @@ -358,10 +342,7 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { await mockSessionByRole(GlobalRole.Admin); - const response = await createPrivateCloudProduct({ - ...silverProductData, - resourceRequests: normalizeResourceRequests(silverProductData.resourceRequests), - }); + const response = await createPrivateCloudProduct(silverProductData); expect(response.status).toBe(200); @@ -389,10 +370,7 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { await mockSessionByIdirGuid(emeraldProductData.projectOwner.idirGuid); - const response = await createPrivateCloudProduct({ - ...emeraldProductData, - resourceRequests: normalizeResourceRequests(emeraldProductData.resourceRequests), - }); + const response = await createPrivateCloudProduct(emeraldProductData); expect(response.status).toBe(200); diff --git a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx index dcc733e07c..22d2614098 100644 --- a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx +++ b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx @@ -128,6 +128,7 @@ export default privateCloudRequestDecision(({ getPathParams, session, router }) isGoldDR: snap.currentRequest?.originalData?.golddrEnabled ?? false, originalResourceRequests: snap.currentRequest?.originalData?.resourceRequests, quotaContactRequired: true, + isAdmin: session?.isAdmin ?? false, }, }, ]; diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index 8be9422a6d..2822d5757d 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -80,6 +80,14 @@ export default function Quotas({ const isPdbPolicyLoading = pdbPolicyReports.some((query) => query.isLoading); const hasPdbPolicyError = pdbPolicyReports.some((query) => query.isError); + const getResourceValue = (resource: ResourceRequests | undefined, resourceKey: (typeof resourceKeys)[number]) => { + if (resourceKey === 'gpu') { + return resource?.gpu ?? 0; + } + + return resource?.[resourceKey]; + }; + return ( <> @@ -141,7 +149,9 @@ export default function Quotas({ const newVal = (resourceRequests[namespace] || {}) as ResourceRequests; const changed = hasOriginalVal && - visibleResourceKeys.some((resourceKey) => originalVal?.[resourceKey] !== newVal?.[resourceKey]); + visibleResourceKeys.some( + (resourceKey) => getResourceValue(originalVal, resourceKey) !== getResourceValue(newVal, resourceKey), + ); let subnetInfo: ReactNode = null; if (cluster === Cluster.EMERALD) { @@ -203,7 +213,7 @@ export default function Quotas({ step={resourceKey === 'cpu' ? 0.5 : 1} placeholder="0" required - disabled={resourceKey === 'gpu' ? false : disabled} + disabled={disabled} classNames={{ wrapper: 'mt-3' }} options={{ valueAsNumber: true }} min={0} diff --git a/app/helpers/licence-plate.ts b/app/helpers/licence-plate.ts index 9c0114be59..4484993b47 100644 --- a/app/helpers/licence-plate.ts +++ b/app/helpers/licence-plate.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import prisma from '@/core/prisma'; +import { Cluster, ResourceRequestsEnv } from '@/prisma/client'; function generateRandomString(len = 6) { const gen = (requiredLength: number) => @@ -30,3 +31,21 @@ export default async function generateLicencePlate() { if (cnt1 + cnt2 === 0) return licencePlate; } } + +export function sanitizeGpuResourceRequests( + resourceRequests: ResourceRequestsEnv, + cluster: Cluster, + isAdmin: boolean, +): ResourceRequestsEnv { + const gpuEnabled = isAdmin && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); + + return Object.fromEntries( + Object.entries(resourceRequests).map(([namespace, requests]) => [ + namespace, + { + ...requests, + gpu: gpuEnabled ? requests.gpu ?? 0 : 0, + }, + ]), + ) as ResourceRequestsEnv; +} diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 74b40d53cf..15252033ef 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -582,7 +582,7 @@ type ResourceRequests { cpu Float memory Float storage Float - gpu Int? + gpu Int } type Budget { diff --git a/app/services/nats/private-cloud/index.ts b/app/services/nats/private-cloud/index.ts index 7c00211f60..3482e0f44c 100644 --- a/app/services/nats/private-cloud/index.ts +++ b/app/services/nats/private-cloud/index.ts @@ -71,7 +71,7 @@ export default async function createPrivateCloudNatsMessage( pvc_count: isEmptyStorage ? 0 : 60, }, snapshot: { count: isEmptyStorage ? 0 : 5 }, - ...(supportsGpu && { gpu: { requests: requests.gpu ?? 0 } }), + ...(supportsGpu ? { gpu: { requests: requests.gpu ?? 0 } } : {}), }, }; }), From 81cb40d3ab0601e2b12a5da537fc49e64580f659 Mon Sep 17 00:00:00 2001 From: zhanna Date: Wed, 19 Aug 2026 14:40:06 -0700 Subject: [PATCH 03/15] chore(2951): exclude GPU from quota metrics calculations --- .../products/_operations/create.ts | 3 +- .../products/_operations/update.ts | 2 +- .../private-cloud/sections/Quotas.tsx | 4 +- app/helpers/auto-approval-check.ts | 28 ++++++++--- app/helpers/licence-plate.ts | 19 -------- app/helpers/quota-change.ts | 20 +++++++- ...260819203556-backfill-private-cloud-gpu.js | 48 +++++++++++++++++++ 7 files changed, 94 insertions(+), 30 deletions(-) create mode 100644 data-migrations/migrations/20260819203556-backfill-private-cloud-gpu.js diff --git a/app/app/api/private-cloud/products/_operations/create.ts b/app/app/api/private-cloud/products/_operations/create.ts index c1f8f579a8..ba2789e52e 100644 --- a/app/app/api/private-cloud/products/_operations/create.ts +++ b/app/app/api/private-cloud/products/_operations/create.ts @@ -1,8 +1,9 @@ import { Session } from 'next-auth'; import prisma from '@/core/prisma'; import { OkResponse, UnauthorizedResponse } from '@/core/responses'; -import generateLicencePlate, { sanitizeGpuResourceRequests } from '@/helpers/licence-plate'; +import generateLicencePlate from '@/helpers/licence-plate'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; +import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { DecisionStatus, ProjectStatus, RequestType, EventType, TaskType, Cluster } from '@/prisma/client'; import { sendCreateRequestEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { createEvent, models, privateCloudRequestDetailInclude, tasks } from '@/services/db'; diff --git a/app/app/api/private-cloud/products/_operations/update.ts b/app/app/api/private-cloud/products/_operations/update.ts index 085bac9608..f1c1e500a6 100644 --- a/app/app/api/private-cloud/products/_operations/update.ts +++ b/app/app/api/private-cloud/products/_operations/update.ts @@ -3,9 +3,9 @@ import { TypeOf } from 'zod'; import prisma from '@/core/prisma'; import { OkResponse, UnauthorizedResponse } from '@/core/responses'; import { getQuotaChangeStatus } from '@/helpers/auto-approval-check'; -import { sanitizeGpuResourceRequests } from '@/helpers/licence-plate'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; import { comparePrivateProductData } from '@/helpers/product-change'; +import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { DecisionStatus, Cluster, RequestType, EventType, TaskType } from '@/prisma/client'; import { sendEditRequestEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index 2822d5757d..db1e079087 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -197,8 +197,8 @@ export default function Quotas({ {subnetInfo} {visibleResourceKeys.map((resourceKey) => { - const oldval = String(originalVal?.[resourceKey]); - const newval = String(newVal[resourceKey]); + const oldval = String(getResourceValue(originalVal, resourceKey)); + const newval = String(getResourceValue(newVal, resourceKey)); return (
diff --git a/app/helpers/auto-approval-check.ts b/app/helpers/auto-approval-check.ts index 469bd9748c..98d6b5cd06 100644 --- a/app/helpers/auto-approval-check.ts +++ b/app/helpers/auto-approval-check.ts @@ -52,21 +52,28 @@ export async function getQuotaChangeStatus({ let hasChange = false; let hasIncrease = false; let hasSignificantIncrease = false; + const metricResourceTypes = [ResourceType.cpu, ResourceType.memory, ResourceType.storage] as const; + + type MetricResourceType = (typeof metricResourceTypes)[number]; const resourcesToCheck: { env: keyof ResourceRequestsEnv; - resourceName: keyof ResourceRequests; + resourceName: MetricResourceType; }[] = []; iterateObject(_currentResourceRequests, (resourceRequests: ResourceRequests, env: keyof ResourceRequestsEnv) => { - iterateObject(resourceRequests, (currentValue: number, resourceName: keyof ResourceRequests) => { + for (const resourceName of metricResourceTypes) { + const currentValue = resourceRequests[resourceName]; const requestedValue = _requestedResourceRequests[env][resourceName]; const diffValue = requestedValue - currentValue; - const diffPerc = (diffValue / currentValue) * 100; + const diffPerc = currentValue === 0 ? Infinity : (diffValue / currentValue) * 100; const allowedMin = allowedMinResource[resourceName]; - if (!hasChange) hasChange = diffValue !== 0; + if (diffValue !== 0) { + hasChange = true; + } + if (diffValue > 0) { hasIncrease = true; hasSignificantIncrease = requestedValue > allowedMin && diffPerc > allowedAutoApprovalPercentage; @@ -79,9 +86,18 @@ export async function getQuotaChangeStatus({ resourceName, }); } - }); + } + const currentGpu = resourceRequests.gpu ?? 0; + const requestedGpu = _requestedResourceRequests[env].gpu ?? 0; + const gpuDiff = requestedGpu - currentGpu; + + if (gpuDiff !== 0) { + hasChange = true; + } - if (hasSignificantIncrease) { + if (gpuDiff > 0) { + hasIncrease = true; + hasSignificantIncrease = true; return false; } }); diff --git a/app/helpers/licence-plate.ts b/app/helpers/licence-plate.ts index 4484993b47..9c0114be59 100644 --- a/app/helpers/licence-plate.ts +++ b/app/helpers/licence-plate.ts @@ -1,6 +1,5 @@ import crypto from 'crypto'; import prisma from '@/core/prisma'; -import { Cluster, ResourceRequestsEnv } from '@/prisma/client'; function generateRandomString(len = 6) { const gen = (requiredLength: number) => @@ -31,21 +30,3 @@ export default async function generateLicencePlate() { if (cnt1 + cnt2 === 0) return licencePlate; } } - -export function sanitizeGpuResourceRequests( - resourceRequests: ResourceRequestsEnv, - cluster: Cluster, - isAdmin: boolean, -): ResourceRequestsEnv { - const gpuEnabled = isAdmin && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); - - return Object.fromEntries( - Object.entries(resourceRequests).map(([namespace, requests]) => [ - namespace, - { - ...requests, - gpu: gpuEnabled ? requests.gpu ?? 0 : 0, - }, - ]), - ) as ResourceRequestsEnv; -} diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 29bf1487e1..cb4653141c 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -1,4 +1,4 @@ -import { ResourceRequestsEnv } from '@/prisma/client'; +import { Cluster, ResourceRequestsEnv } from '@/prisma/client'; import { extractNumbers } from '@/utils/js'; export const isResourseDowngrade = (req: string, prod: string) => { @@ -21,3 +21,21 @@ export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequ oldval.tools.storage < newval.tools.storage ); }; + +export function sanitizeGpuResourceRequests( + resourceRequests: ResourceRequestsEnv, + cluster: Cluster, + isAdmin: boolean, +): ResourceRequestsEnv { + const gpuEnabled = isAdmin && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); + + return Object.fromEntries( + Object.entries(resourceRequests).map(([namespace, requests]) => [ + namespace, + { + ...requests, + gpu: gpuEnabled ? requests.gpu ?? 0 : 0, + }, + ]), + ) as ResourceRequestsEnv; +} diff --git a/data-migrations/migrations/20260819203556-backfill-private-cloud-gpu.js b/data-migrations/migrations/20260819203556-backfill-private-cloud-gpu.js new file mode 100644 index 0000000000..b6acbf27de --- /dev/null +++ b/data-migrations/migrations/20260819203556-backfill-private-cloud-gpu.js @@ -0,0 +1,48 @@ +export const up = async (db, client) => { + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const PrivateCloudProduct = db.collection('PrivateCloudProduct'); + const PrivateCloudRequestData = db.collection('PrivateCloudRequestData'); + + const gpuBackfillPipeline = [ + { + $set: { + 'resourceRequests.development.gpu': { + $ifNull: ['$resourceRequests.development.gpu', 0], + }, + 'resourceRequests.test.gpu': { + $ifNull: ['$resourceRequests.test.gpu', 0], + }, + 'resourceRequests.production.gpu': { + $ifNull: ['$resourceRequests.production.gpu', 0], + }, + 'resourceRequests.tools.gpu': { + $ifNull: ['$resourceRequests.tools.gpu', 0], + }, + }, + }, + ]; + + const productResult = await PrivateCloudProduct.updateMany({}, gpuBackfillPipeline, { + session, + }); + + const requestDataResult = await PrivateCloudRequestData.updateMany({}, gpuBackfillPipeline, { + session, + }); + + console.log( + `backfill_private_cloud_gpu: Updated ${productResult.modifiedCount} PrivateCloudProduct documents and ${requestDataResult.modifiedCount} PrivateCloudRequestData documents.`, + ); + }); + } catch (error) { + console.error('An error occurred while processing the database transaction:', error); + throw error; + } finally { + await session.endSession(); + } +}; + +export const down = async (db, client) => {}; From 67fe280059b69bd4c20f8e1ee835c3c913a5549b Mon Sep 17 00:00:00 2001 From: zhanna Date: Wed, 19 Aug 2026 15:51:45 -0700 Subject: [PATCH 04/15] chore(2951): include GPU in quota upgrade detection --- app/helpers/quota-change.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index cb4653141c..6fb6de11f3 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -10,15 +10,19 @@ export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequ oldval.development.cpu < newval.development.cpu || oldval.development.memory < newval.development.memory || oldval.development.storage < newval.development.storage || + (oldval.development.gpu ?? 0) < (newval.development.gpu ?? 0) || oldval.test.cpu < newval.test.cpu || oldval.test.memory < newval.test.memory || oldval.test.storage < newval.test.storage || + (oldval.test.gpu ?? 0) < (newval.test.gpu ?? 0) || oldval.production.cpu < newval.production.cpu || oldval.production.memory < newval.production.memory || oldval.production.storage < newval.production.storage || + (oldval.production.gpu ?? 0) < (newval.production.gpu ?? 0) || oldval.tools.cpu < newval.tools.cpu || oldval.tools.memory < newval.tools.memory || - oldval.tools.storage < newval.tools.storage + oldval.tools.storage < newval.tools.storage || + (oldval.tools.gpu ?? 0) < (newval.tools.gpu ?? 0) ); }; From 22fba36f5d38bc35285bbad9cf4e0fef30e0444e Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 08:27:40 -0700 Subject: [PATCH 05/15] chore(2951): enforce GPU quota validation and approval rules --- .../requests/[id]/decision/route.test.ts | 77 ++++++++++++++++++- .../requests/[id]/decision/route.ts | 8 ++ .../private-cloud/sections/Quotas.tsx | 11 ++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts index 48dd0ce702..3b415c823f 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts @@ -321,7 +321,8 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { expect(decisionData.cluster).toBe(Cluster.SILVER); expect(decisionData.golddrEnabled).toBe(false); }); - +}); +describe('Review Private Cloud Request - GPU Validations', () => { it('should reset GPU quota to 0 for Silver', async () => { const silverProductData = createSamplePrivateCloudProductData({ data: { @@ -378,4 +379,78 @@ describe('Review Private Cloud Request - Gold DR Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); + + it('should reset GPU quota to 0 when non-admin reviewer approves an Emerald request', async () => { + const emeraldProductData = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByIdirGuid(emeraldProductData.primaryTechnicalLead.idirGuid); + + const createResponse = await createPrivateCloudProduct(emeraldProductData); + expect(createResponse.status).toBe(200); + + const requestData = await createResponse.json(); + + await mockSessionByRole(GlobalRole.PrivateReviewer); + + const response = await makePrivateCloudRequestDecision(requestData.id, { + type: RequestType.CREATE, + ...requestData.decisionData, + resourceRequests: { + ...requestData.decisionData.resourceRequests, + development: { + ...requestData.decisionData.resourceRequests.development, + gpu: 4, + }, + }, + decision: DecisionStatus.APPROVED as 'APPROVED' | 'REJECTED', + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); + + it('should preserve GPU quota when admin approves an Emerald request', async () => { + const emeraldProductData = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByIdirGuid(emeraldProductData.primaryTechnicalLead.idirGuid); + + const createResponse = await createPrivateCloudProduct(emeraldProductData); + expect(createResponse.status).toBe(200); + + const requestData = await createResponse.json(); + + await mockSessionByRole(GlobalRole.Admin); + + const response = await makePrivateCloudRequestDecision(requestData.id, { + type: RequestType.CREATE, + ...requestData.decisionData, + resourceRequests: { + ...requestData.decisionData.resourceRequests, + development: { + ...requestData.decisionData.resourceRequests.development, + gpu: 4, + }, + }, + decision: DecisionStatus.APPROVED as 'APPROVED' | 'REJECTED', + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(4); + }); }); diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.ts b/app/app/api/private-cloud/requests/[id]/decision/route.ts index 07b5841285..573ec09947 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.ts @@ -4,6 +4,7 @@ import createApiHandler from '@/core/api-handler'; import prisma from '@/core/prisma'; import { BadRequestResponse, OkResponse, UnprocessableEntityResponse } from '@/core/responses'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; +import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { Cluster, DecisionStatus, Prisma, ProjectStatus, RequestType, EventType, TaskType } from '@/prisma/client'; import { sendRequestRejectionEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { createEvent, models, privateCloudRequestDetailInclude, tasks } from '@/services/db'; @@ -76,9 +77,16 @@ export const POST = apiHandler(async ({ pathParams, body, session }) => { if (request.type !== RequestType.DELETE) { const resolvedCluster = request.project?.cluster ?? validFormData.cluster ?? request.decisionData.cluster; + const resourceRequests = sanitizeGpuResourceRequests( + validFormData.resourceRequests, + resolvedCluster, + session.isAdmin, + ); + dataToUpdate.decisionData = { update: { ...validFormData, + resourceRequests, status: ProjectStatus.ACTIVE, licencePlate: request.licencePlate, cluster: resolvedCluster, diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index db1e079087..dd60c67e43 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -21,6 +21,13 @@ const resourceUnit = { gpu: '', }; +const resourceMaxValue = { + cpu: 64, + memory: 128, + storage: 512, + gpu: 8, +} as const; + export default function Quotas({ disabled, cluster, @@ -217,9 +224,7 @@ export default function Quotas({ classNames={{ wrapper: 'mt-3' }} options={{ valueAsNumber: true }} min={0} - max={ - resourceKey === 'cpu' ? 64 : resourceKey === 'memory' ? 128 : resourceKey === 'gpu' ? 8 : 512 - } + max={resourceMaxValue[resourceKey]} /> {hasOriginalVal && oldval !== newval && (
From df8fa9951217755a04426b8e1b7a802217e28539 Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 08:48:47 -0700 Subject: [PATCH 06/15] chore(2951): use current cluster for quota queries and links --- app/components/private-cloud/sections/Quotas.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index dd60c67e43..e5908f9b42 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -58,18 +58,18 @@ export default function Quotas({ const subnetInformation = useQueries({ queries: ['dev', 'test', 'prod', 'tools'].map((environment) => { return { - queryKey: [licencePlate, environment], + queryKey: ['emerald-subnet', licencePlate, currentCluster, environment], queryFn: () => getSubnetForEmerald(licencePlate!, environment), - enabled: cluster === Cluster.EMERALD && !!licencePlate, + enabled: currentCluster === Cluster.EMERALD && !!licencePlate, }; }), }); const pdbPolicyReports = useQueries({ queries: namespaceKeys.map((namespace) => ({ - queryKey: ['pdb-policy-report', licencePlate, cluster, environmentShortNames[namespace]], - queryFn: () => getPdbPolicyStatus(licencePlate!, cluster!, environmentShortNames[namespace]), - enabled: !!licencePlate && !!cluster, + queryKey: ['pdb-policy-report', licencePlate, currentCluster, environmentShortNames[namespace]], + queryFn: () => getPdbPolicyStatus(licencePlate!, currentCluster!, environmentShortNames[namespace]), + enabled: !!licencePlate && !!currentCluster, staleTime: 60_000, retry: false, })), @@ -161,7 +161,7 @@ export default function Quotas({ ); let subnetInfo: ReactNode = null; - if (cluster === Cluster.EMERALD) { + if (currentCluster === Cluster.EMERALD) { if (subnetInformation[index].isLoading) { subnetInfo = ; } else if (subnetInformation[index].data) { @@ -178,10 +178,10 @@ export default function Quotas({ } let clusterLink: ReactNode = null; - if (licencePlate && cluster) { + if (licencePlate && currentCluster) { clusterLink = ( {licencePlate}-{environmentShortNames[namespace] || ''} From db8193fb8f8c8f0a079b0328e4d5e321482e5f1b Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 09:06:12 -0700 Subject: [PATCH 07/15] chore(2951): allow private admins to manage GPU quotas --- app/app/api/private-cloud/products/_operations/create.ts | 4 ++-- app/app/api/private-cloud/products/_operations/update.ts | 4 ++-- app/app/api/private-cloud/requests/[id]/decision/route.ts | 8 ++------ .../products/(product)/[licencePlate]/edit/page.tsx | 3 ++- app/app/private-cloud/products/(product)/create/page.tsx | 4 ++-- .../requests/(request)/[id]/decision/page.tsx | 3 ++- app/components/private-cloud/sections/Quotas.tsx | 6 +++--- app/helpers/quota-change.ts | 4 ++-- 8 files changed, 17 insertions(+), 19 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/create.ts b/app/app/api/private-cloud/products/_operations/create.ts index ba2789e52e..1cc419aec3 100644 --- a/app/app/api/private-cloud/products/_operations/create.ts +++ b/app/app/api/private-cloud/products/_operations/create.ts @@ -45,8 +45,8 @@ export default async function createOp({ session, body }: { session: Session; bo } = body; if (rest.cluster === Cluster.GOLDDR) rest.cluster = Cluster.GOLD; - - rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, rest.cluster, session.isAdmin); + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, rest.cluster, canManageGpu); const productData = { ...rest, diff --git a/app/app/api/private-cloud/products/_operations/update.ts b/app/app/api/private-cloud/products/_operations/update.ts index f1c1e500a6..8a7e09dd85 100644 --- a/app/app/api/private-cloud/products/_operations/update.ts +++ b/app/app/api/private-cloud/products/_operations/update.ts @@ -53,8 +53,8 @@ export default async function updateOp({ if (!product._permissions.manageMembers) { rest.members = product.members.map(({ userId, roles }) => ({ userId, roles })); } - - rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, product.cluster, session.isAdmin); + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, product.cluster, canManageGpu); const productData = { ...rest, diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.ts b/app/app/api/private-cloud/requests/[id]/decision/route.ts index 573ec09947..c69e13040b 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.ts @@ -76,12 +76,8 @@ export const POST = apiHandler(async ({ pathParams, body, session }) => { // No need to modify decision data when reviewing deletion requests. if (request.type !== RequestType.DELETE) { const resolvedCluster = request.project?.cluster ?? validFormData.cluster ?? request.decisionData.cluster; - - const resourceRequests = sanitizeGpuResourceRequests( - validFormData.resourceRequests, - resolvedCluster, - session.isAdmin, - ); + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const resourceRequests = sanitizeGpuResourceRequests(validFormData.resourceRequests, resolvedCluster, canManageGpu); dataToUpdate.decisionData = { update: { diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx index 0b37bd6b0d..99d5e17259 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx @@ -39,6 +39,7 @@ export default privateCloudProductEdit(({ session }) => { type PrivateCloudEditRequestInput = z.input; type PrivateCloudEditRequestOutput = z.output; + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; const methods = useForm({ resolver: async (values, context, options) => { const { resourceRequests } = values; @@ -177,7 +178,7 @@ export default privateCloudProductEdit(({ session }) => { isGoldDR: currentProduct.golddrEnabled ?? false, originalResourceRequests: currentProduct.resourceRequests, quotaContactRequired: true, - isAdmin: session?.isAdmin, + canManageGpu, }, }, ]; diff --git a/app/app/private-cloud/products/(product)/create/page.tsx b/app/app/private-cloud/products/(product)/create/page.tsx index 59456161b8..f0c3835642 100644 --- a/app/app/private-cloud/products/(product)/create/page.tsx +++ b/app/app/private-cloud/products/(product)/create/page.tsx @@ -34,7 +34,7 @@ export default privateCloudProductNew(({ session }) => { }, }, }); - + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; const accordionItems = [ { LeftIcon: IconInfoCircle, @@ -66,7 +66,7 @@ export default privateCloudProductNew(({ session }) => { Component: Quotas, componentArgs: { disabled: true, - isAdmin: session?.isAdmin, + canManageGpu, }, }, { diff --git a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx index 22d2614098..39cddc3a9f 100644 --- a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx +++ b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx @@ -56,6 +56,7 @@ export default privateCloudRequestDecision(({ getPathParams, session, router }) type PrivateCloudRequestDecisionOutput = z.output; const baseResolver = zodResolver(privateCloudRequestDecisionBodySchema); + const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; const methods = useForm({ resolver: async (values, context, options) => { @@ -128,7 +129,7 @@ export default privateCloudRequestDecision(({ getPathParams, session, router }) isGoldDR: snap.currentRequest?.originalData?.golddrEnabled ?? false, originalResourceRequests: snap.currentRequest?.originalData?.resourceRequests, quotaContactRequired: true, - isAdmin: session?.isAdmin ?? false, + canManageGpu: canManageGpu ?? false, }, }, ]; diff --git a/app/components/private-cloud/sections/Quotas.tsx b/app/components/private-cloud/sections/Quotas.tsx index e5908f9b42..80e72b84f7 100644 --- a/app/components/private-cloud/sections/Quotas.tsx +++ b/app/components/private-cloud/sections/Quotas.tsx @@ -35,7 +35,7 @@ export default function Quotas({ licencePlate, originalResourceRequests, quotaContactRequired = false, - isAdmin = false, + canManageGpu = false, }: { disabled: boolean; cluster?: Cluster; @@ -43,7 +43,7 @@ export default function Quotas({ licencePlate?: string; originalResourceRequests?: ResourceRequestsEnv; quotaContactRequired?: boolean; - isAdmin?: boolean; + canManageGpu?: boolean; }) { const { watch } = useFormContext(); @@ -51,7 +51,7 @@ export default function Quotas({ const currentCluster = cluster ?? formCluster; - const canShowGpu = isAdmin && (currentCluster === Cluster.EMERALD || currentCluster === Cluster.KLAB2); + const canShowGpu = canManageGpu && (currentCluster === Cluster.EMERALD || currentCluster === Cluster.KLAB2); const visibleResourceKeys = resourceKeys.filter((resourceKey) => resourceKey !== 'gpu' || canShowGpu); diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 6fb6de11f3..41fb944fa3 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -29,9 +29,9 @@ export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequ export function sanitizeGpuResourceRequests( resourceRequests: ResourceRequestsEnv, cluster: Cluster, - isAdmin: boolean, + canManageGpu: boolean, ): ResourceRequestsEnv { - const gpuEnabled = isAdmin && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); + const gpuEnabled = canManageGpu && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); return Object.fromEntries( Object.entries(resourceRequests).map(([namespace, requests]) => [ From 60ced7c74542b14dc1add66f2461d9b098babcc1 Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 09:13:27 -0700 Subject: [PATCH 08/15] chore(2951): test allow private reviewers to approve GPU quotas --- .../api/private-cloud/requests/[id]/decision/route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts index 3b415c823f..912909813e 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.test.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.test.ts @@ -380,7 +380,7 @@ describe('Review Private Cloud Request - GPU Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should reset GPU quota to 0 when non-admin reviewer approves an Emerald request', async () => { + it('should preserve GPU quota when private reviewer approves an Emerald request', async () => { const emeraldProductData = createSamplePrivateCloudProductData({ data: { cluster: Cluster.EMERALD, @@ -414,7 +414,7 @@ describe('Review Private Cloud Request - GPU Validations', () => { const responseData = await response.json(); - expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(4); }); it('should preserve GPU quota when admin approves an Emerald request', async () => { From e086e6825901d131c9386c8158878d67c998c25b Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 09:32:03 -0700 Subject: [PATCH 09/15] chore(2951): add GPU sanitization tests for updates --- .../products/_operations/create.test.ts | 75 +++++++++++++ .../products/_operations/update.test.ts | 106 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/app/app/api/private-cloud/products/_operations/create.test.ts b/app/app/api/private-cloud/products/_operations/create.test.ts index f6448c7d4a..712cb28d9b 100644 --- a/app/app/api/private-cloud/products/_operations/create.test.ts +++ b/app/app/api/private-cloud/products/_operations/create.test.ts @@ -1,6 +1,7 @@ import { expect } from '@jest/globals'; import { GlobalRole } from '@/constants'; import { createSamplePrivateCloudProductData } from '@/helpers/mock-resources'; +import { resourceRequests1 } from '@/helpers/mock-resources/private-cloud-product'; import { findOtherMockUsers } from '@/helpers/mock-users'; import { pickProductData } from '@/helpers/product'; import { Cluster } from '@/prisma/client'; @@ -203,4 +204,78 @@ describe('Create Private Cloud Request - Validations', () => { const response = await createPrivateCloudProduct(requestData); expect(response.status).toBe(200); }); + + it('should reset GPU quota to 0 for non-admin users on Emerald', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 4, + }, + }, + }, + }); + + await mockSessionByIdirGuid(product.projectOwner.idirGuid); + + const response = await createPrivateCloudProduct(product); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); + it('should reset GPU quota to 0 for unsupported clusters', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.SILVER, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 4, + }, + }, + }, + }); + + await mockSessionByRole(GlobalRole.Admin); + + const response = await createPrivateCloudProduct(product); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); + + it('should preserve GPU quota for private reviewers on Emerald', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 4, + }, + }, + }, + }); + + await mockSessionByRole(GlobalRole.PrivateReviewer); + + const response = await createPrivateCloudProduct(product); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(4); + }); }); diff --git a/app/app/api/private-cloud/products/_operations/update.test.ts b/app/app/api/private-cloud/products/_operations/update.test.ts index b42b276844..5a85f3662e 100644 --- a/app/app/api/private-cloud/products/_operations/update.test.ts +++ b/app/app/api/private-cloud/products/_operations/update.test.ts @@ -254,4 +254,110 @@ describe('Update Private Cloud Product - Validations', () => { const response = await makeBasicProductChange({ secondaryTechnicalLeadId: null }); expect(response.status).toBe(200); }); + + it('should reset GPU quota to 0 for regular users on Emerald', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByRole(GlobalRole.Admin); + + const createResponse = await createPrivateCloudProduct(product); + expect(createResponse.status).toBe(200); + + const created = await createResponse.json(); + + await mockSessionByIdirGuid(product.primaryTechnicalLead.idirGuid); + + const response = await editPrivateCloudProduct(created.licencePlate, { + ...created.decisionData, + isAgMinistry: created.project?.organization.isAgMinistry || false, + resourceRequests: { + ...created.decisionData.resourceRequests, + development: { + ...created.decisionData.resourceRequests.development, + gpu: 4, + }, + }, + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); + + it('should reset GPU quota to 0 for unsupported clusters on update', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.SILVER, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByRole(GlobalRole.Admin); + + const createResponse = await createPrivateCloudProduct(product); + expect(createResponse.status).toBe(200); + + const created = await createResponse.json(); + + const response = await editPrivateCloudProduct(created.licencePlate, { + ...created.decisionData, + isAgMinistry: created.project?.organization.isAgMinistry || false, + resourceRequests: { + ...created.decisionData.resourceRequests, + development: { + ...created.decisionData.resourceRequests.development, + gpu: 4, + }, + }, + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + }); + + it('should preserve GPU quota for private reviewers on Emerald update', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByRole(GlobalRole.Admin); + + const createResponse = await createPrivateCloudProduct(product); + expect(createResponse.status).toBe(200); + + const created = await createResponse.json(); + + await mockSessionByRole(GlobalRole.PrivateReviewer); + + const response = await editPrivateCloudProduct(created.licencePlate, { + ...created.decisionData, + isAgMinistry: created.project?.organization.isAgMinistry || false, + resourceRequests: { + ...created.decisionData.resourceRequests, + development: { + ...created.decisionData.resourceRequests.development, + gpu: 4, + }, + }, + }); + + expect(response.status).toBe(200); + + const responseData = await response.json(); + + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(4); + }); }); From 9dc3f0118758acbc7b0e750df9bd6d84dae691ee Mon Sep 17 00:00:00 2001 From: zhanna Date: Thu, 20 Aug 2026 10:12:08 -0700 Subject: [PATCH 10/15] chore(2951): add GPU quota coverage for private cloud create --- .../products/_operations/create.test.ts | 4 +- .../products/_operations/update.test.ts | 113 +++++++++--------- 2 files changed, 58 insertions(+), 59 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/create.test.ts b/app/app/api/private-cloud/products/_operations/create.test.ts index 712cb28d9b..6cd9993388 100644 --- a/app/app/api/private-cloud/products/_operations/create.test.ts +++ b/app/app/api/private-cloud/products/_operations/create.test.ts @@ -254,7 +254,7 @@ describe('Create Private Cloud Request - Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should preserve GPU quota for private reviewers on Emerald', async () => { + it('should preserve GPU quota for private admin on Emerald', async () => { const product = createSamplePrivateCloudProductData({ data: { cluster: Cluster.EMERALD, @@ -268,7 +268,7 @@ describe('Create Private Cloud Request - Validations', () => { }, }); - await mockSessionByRole(GlobalRole.PrivateReviewer); + await mockSessionByRole(GlobalRole.PrivateAdmin); const response = await createPrivateCloudProduct(product); diff --git a/app/app/api/private-cloud/products/_operations/update.test.ts b/app/app/api/private-cloud/products/_operations/update.test.ts index 5a85f3662e..99be621ad9 100644 --- a/app/app/api/private-cloud/products/_operations/update.test.ts +++ b/app/app/api/private-cloud/products/_operations/update.test.ts @@ -35,6 +35,38 @@ async function makeBasicProductChange(extra = {}) { return response; } +async function createAndProvisionProduct(cluster: Cluster) { + const product = createSamplePrivateCloudProductData({ + data: { + cluster, + resourceRequests: resourceRequests1, + }, + }); + + await mockSessionByIdirGuid(product.projectOwner.idirGuid); + + const createResponse = await createPrivateCloudProduct(product); + expect(createResponse.status).toBe(200); + + const request = await createResponse.json(); + + await mockSessionByRole(GlobalRole.PrivateReviewer); + + const approvalResponse = await makePrivateCloudRequestDecision(request.id, { + ...request.decisionData, + type: RequestType.CREATE, + decision: DecisionStatus.APPROVED, + }); + expect(approvalResponse.status).toBe(200); + + await mockTeamServiceAccount(['private-admin']); + + const provisionResponse = await provisionPrivateCloudProduct(request.licencePlate); + expect(provisionResponse.status).toBe(200); + + return { product, request }; +} + describe('Update Private Cloud Product - Permissions', () => { it('should successfully submit a create request for PO', async () => { await mockSessionByIdirGuid(productData.main.projectOwner.idirGuid); @@ -255,30 +287,18 @@ describe('Update Private Cloud Product - Validations', () => { expect(response.status).toBe(200); }); - it('should reset GPU quota to 0 for regular users on Emerald', async () => { - const product = createSamplePrivateCloudProductData({ - data: { - cluster: Cluster.EMERALD, - resourceRequests: resourceRequests1, - }, - }); + it('should reset GPU quota to 0 for unsupported clusters on update', async () => { + const { request } = await createAndProvisionProduct(Cluster.SILVER); await mockSessionByRole(GlobalRole.Admin); - const createResponse = await createPrivateCloudProduct(product); - expect(createResponse.status).toBe(200); - - const created = await createResponse.json(); - - await mockSessionByIdirGuid(product.primaryTechnicalLead.idirGuid); - - const response = await editPrivateCloudProduct(created.licencePlate, { - ...created.decisionData, - isAgMinistry: created.project?.organization.isAgMinistry || false, + const response = await editPrivateCloudProduct(request.licencePlate, { + ...request.decisionData, + isAgMinistry: request.project?.organization.isAgMinistry || false, resourceRequests: { - ...created.decisionData.resourceRequests, + ...request.decisionData.resourceRequests, development: { - ...created.decisionData.resourceRequests.development, + ...request.decisionData.resourceRequests.development, gpu: 4, }, }, @@ -291,28 +311,18 @@ describe('Update Private Cloud Product - Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should reset GPU quota to 0 for unsupported clusters on update', async () => { - const product = createSamplePrivateCloudProductData({ - data: { - cluster: Cluster.SILVER, - resourceRequests: resourceRequests1, - }, - }); - - await mockSessionByRole(GlobalRole.Admin); - - const createResponse = await createPrivateCloudProduct(product); - expect(createResponse.status).toBe(200); + it('should reset GPU quota to 0 for regular users on Emerald', async () => { + const { product, request } = await createAndProvisionProduct(Cluster.EMERALD); - const created = await createResponse.json(); + await mockSessionByIdirGuid(product.primaryTechnicalLead.idirGuid); - const response = await editPrivateCloudProduct(created.licencePlate, { - ...created.decisionData, - isAgMinistry: created.project?.organization.isAgMinistry || false, + const response = await editPrivateCloudProduct(request.licencePlate, { + ...request.decisionData, + isAgMinistry: request.project?.organization.isAgMinistry || false, resourceRequests: { - ...created.decisionData.resourceRequests, + ...request.decisionData.resourceRequests, development: { - ...created.decisionData.resourceRequests.development, + ...request.decisionData.resourceRequests.development, gpu: 4, }, }, @@ -325,34 +335,23 @@ describe('Update Private Cloud Product - Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should preserve GPU quota for private reviewers on Emerald update', async () => { + it('should preserve GPU quota for admin on Emerald', async () => { const product = createSamplePrivateCloudProductData({ data: { cluster: Cluster.EMERALD, - resourceRequests: resourceRequests1, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 4, + }, + }, }, }); await mockSessionByRole(GlobalRole.Admin); - const createResponse = await createPrivateCloudProduct(product); - expect(createResponse.status).toBe(200); - - const created = await createResponse.json(); - - await mockSessionByRole(GlobalRole.PrivateReviewer); - - const response = await editPrivateCloudProduct(created.licencePlate, { - ...created.decisionData, - isAgMinistry: created.project?.organization.isAgMinistry || false, - resourceRequests: { - ...created.decisionData.resourceRequests, - development: { - ...created.decisionData.resourceRequests.development, - gpu: 4, - }, - }, - }); + const response = await createPrivateCloudProduct(product); expect(response.status).toBe(200); From 1c5b6d742145420f016066235d024e12ca3305fa Mon Sep 17 00:00:00 2001 From: zhanna Date: Mon, 24 Aug 2026 15:54:27 -0700 Subject: [PATCH 11/15] chore(2951): centralize GPU permissions and quota upgrade checks --- .../products/_operations/create.ts | 4 +-- .../products/_operations/update.ts | 4 +-- .../requests/[id]/decision/route.ts | 4 +-- .../(product)/[licencePlate]/edit/page.tsx | 3 +- .../products/(product)/create/page.tsx | 3 +- .../requests/(request)/[id]/decision/page.tsx | 3 +- app/helpers/quota-change.ts | 33 ++++++++----------- 7 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/create.ts b/app/app/api/private-cloud/products/_operations/create.ts index 1cc419aec3..c90c748104 100644 --- a/app/app/api/private-cloud/products/_operations/create.ts +++ b/app/app/api/private-cloud/products/_operations/create.ts @@ -3,7 +3,7 @@ import prisma from '@/core/prisma'; import { OkResponse, UnauthorizedResponse } from '@/core/responses'; import generateLicencePlate from '@/helpers/licence-plate'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; -import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; +import { canManageGpuQuota, sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { DecisionStatus, ProjectStatus, RequestType, EventType, TaskType, Cluster } from '@/prisma/client'; import { sendCreateRequestEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { createEvent, models, privateCloudRequestDetailInclude, tasks } from '@/services/db'; @@ -45,7 +45,7 @@ export default async function createOp({ session, body }: { session: Session; bo } = body; if (rest.cluster === Cluster.GOLDDR) rest.cluster = Cluster.GOLD; - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, rest.cluster, canManageGpu); const productData = { diff --git a/app/app/api/private-cloud/products/_operations/update.ts b/app/app/api/private-cloud/products/_operations/update.ts index 8a7e09dd85..127ba6e2a4 100644 --- a/app/app/api/private-cloud/products/_operations/update.ts +++ b/app/app/api/private-cloud/products/_operations/update.ts @@ -5,7 +5,7 @@ import { OkResponse, UnauthorizedResponse } from '@/core/responses'; import { getQuotaChangeStatus } from '@/helpers/auto-approval-check'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; import { comparePrivateProductData } from '@/helpers/product-change'; -import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; +import { canManageGpuQuota, sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { DecisionStatus, Cluster, RequestType, EventType, TaskType } from '@/prisma/client'; import { sendEditRequestEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { @@ -53,7 +53,7 @@ export default async function updateOp({ if (!product._permissions.manageMembers) { rest.members = product.members.map(({ userId, roles }) => ({ userId, roles })); } - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, product.cluster, canManageGpu); const productData = { diff --git a/app/app/api/private-cloud/requests/[id]/decision/route.ts b/app/app/api/private-cloud/requests/[id]/decision/route.ts index c69e13040b..cb5b00a586 100644 --- a/app/app/api/private-cloud/requests/[id]/decision/route.ts +++ b/app/app/api/private-cloud/requests/[id]/decision/route.ts @@ -4,7 +4,7 @@ import createApiHandler from '@/core/api-handler'; import prisma from '@/core/prisma'; import { BadRequestResponse, OkResponse, UnprocessableEntityResponse } from '@/core/responses'; import { sendRequestNatsMessage } from '@/helpers/nats-message'; -import { sanitizeGpuResourceRequests } from '@/helpers/quota-change'; +import { canManageGpuQuota, sanitizeGpuResourceRequests } from '@/helpers/quota-change'; import { Cluster, DecisionStatus, Prisma, ProjectStatus, RequestType, EventType, TaskType } from '@/prisma/client'; import { sendRequestRejectionEmails, sendRequestApprovalEmails } from '@/services/ches/private-cloud'; import { createEvent, models, privateCloudRequestDetailInclude, tasks } from '@/services/db'; @@ -76,7 +76,7 @@ export const POST = apiHandler(async ({ pathParams, body, session }) => { // No need to modify decision data when reviewing deletion requests. if (request.type !== RequestType.DELETE) { const resolvedCluster = request.project?.cluster ?? validFormData.cluster ?? request.decisionData.cluster; - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); const resourceRequests = sanitizeGpuResourceRequests(validFormData.resourceRequests, resolvedCluster, canManageGpu); dataToUpdate.decisionData = { diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx index 99d5e17259..45fe24a019 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx @@ -17,6 +17,7 @@ import TeamContacts from '@/components/private-cloud/sections/TeamContacts'; import SiloAccordion from '@/components/private-cloud/SiloAccordion'; import { GlobalRole } from '@/constants'; import createClientPage from '@/core/client-page'; +import { canManageGpuQuota } from '@/helpers/quota-change'; import { areOnlyRepositoryFieldsDirty, getRepositoryFormValues } from '@/helpers/repository'; import { ResourceRequestsEnv } from '@/prisma/client'; import { getQuotaChangeStatus, updatePrivateCloudProductRepositories } from '@/services/backend/private-cloud/products'; @@ -39,7 +40,7 @@ export default privateCloudProductEdit(({ session }) => { type PrivateCloudEditRequestInput = z.input; type PrivateCloudEditRequestOutput = z.output; - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); const methods = useForm({ resolver: async (values, context, options) => { const { resourceRequests } = values; diff --git a/app/app/private-cloud/products/(product)/create/page.tsx b/app/app/private-cloud/products/(product)/create/page.tsx index f0c3835642..873c55f56d 100644 --- a/app/app/private-cloud/products/(product)/create/page.tsx +++ b/app/app/private-cloud/products/(product)/create/page.tsx @@ -15,6 +15,7 @@ import TeamContacts from '@/components/private-cloud/sections/TeamContacts'; import Webhooks from '@/components/private-cloud/sections/Webhooks'; import { GlobalRole, defaultResourceRequests } from '@/constants'; import createClientPage from '@/core/client-page'; +import { canManageGpuQuota } from '@/helpers/quota-change'; import { privateCloudCreateRequestBodySchema } from '@/validation-schemas/private-cloud'; const privateCloudProductNew = createClientPage({ @@ -34,7 +35,7 @@ export default privateCloudProductNew(({ session }) => { }, }, }); - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); const accordionItems = [ { LeftIcon: IconInfoCircle, diff --git a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx index 39cddc3a9f..06abb7c9af 100644 --- a/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx +++ b/app/app/private-cloud/requests/(request)/[id]/decision/page.tsx @@ -17,6 +17,7 @@ import Quotas from '@/components/private-cloud/sections/Quotas'; import TeamContacts from '@/components/private-cloud/sections/TeamContacts'; import { GlobalRole } from '@/constants'; import createClientPage from '@/core/client-page'; +import { canManageGpuQuota } from '@/helpers/quota-change'; import { DecisionStatus, ProjectContext, RequestType } from '@/prisma/client'; import { usePrivateProductState } from '@/states/global'; import { RequestDecision } from '@/validation-schemas'; @@ -56,7 +57,7 @@ export default privateCloudRequestDecision(({ getPathParams, session, router }) type PrivateCloudRequestDecisionOutput = z.output; const baseResolver = zodResolver(privateCloudRequestDecisionBodySchema); - const canManageGpu = !!session?.isAdmin || !!session?.permissions.reviewAllPrivateCloudRequests; + const canManageGpu = canManageGpuQuota(session); const methods = useForm({ resolver: async (values, context, options) => { diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 41fb944fa3..0033a8b50b 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -1,3 +1,4 @@ +import { namespaceKeys, resourceKeys } from '@/constants/private-cloud'; import { Cluster, ResourceRequestsEnv } from '@/prisma/client'; import { extractNumbers } from '@/utils/js'; @@ -5,27 +6,21 @@ export const isResourseDowngrade = (req: string, prod: string) => { return extractNumbers(req)[0] < extractNumbers(prod)[0]; }; -export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequestsEnv) => { - return ( - oldval.development.cpu < newval.development.cpu || - oldval.development.memory < newval.development.memory || - oldval.development.storage < newval.development.storage || - (oldval.development.gpu ?? 0) < (newval.development.gpu ?? 0) || - oldval.test.cpu < newval.test.cpu || - oldval.test.memory < newval.test.memory || - oldval.test.storage < newval.test.storage || - (oldval.test.gpu ?? 0) < (newval.test.gpu ?? 0) || - oldval.production.cpu < newval.production.cpu || - oldval.production.memory < newval.production.memory || - oldval.production.storage < newval.production.storage || - (oldval.production.gpu ?? 0) < (newval.production.gpu ?? 0) || - oldval.tools.cpu < newval.tools.cpu || - oldval.tools.memory < newval.tools.memory || - oldval.tools.storage < newval.tools.storage || - (oldval.tools.gpu ?? 0) < (newval.tools.gpu ?? 0) - ); +type GpuPermissionSession = { + isAdmin?: boolean; + permissions?: { + reviewAllPrivateCloudRequests?: boolean; + }; }; +export const canManageGpuQuota = (session?: GpuPermissionSession | null) => + !!session?.isAdmin || !!session?.permissions?.reviewAllPrivateCloudRequests; + +export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequestsEnv) => + namespaceKeys.some((namespace) => + resourceKeys.some((resource) => oldval[namespace][resource] < newval[namespace][resource]), + ); + export function sanitizeGpuResourceRequests( resourceRequests: ResourceRequestsEnv, cluster: Cluster, From 73c52ac0f4da64644c12101b093e3e86765403de Mon Sep 17 00:00:00 2001 From: zhanna Date: Tue, 25 Aug 2026 08:45:32 -0700 Subject: [PATCH 12/15] chore(2951): handle undefined current product in edit form --- .../products/(product)/[licencePlate]/edit/page.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx index 45fe24a019..dfe515f718 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx @@ -18,7 +18,7 @@ import SiloAccordion from '@/components/private-cloud/SiloAccordion'; import { GlobalRole } from '@/constants'; import createClientPage from '@/core/client-page'; import { canManageGpuQuota } from '@/helpers/quota-change'; -import { areOnlyRepositoryFieldsDirty, getRepositoryFormValues } from '@/helpers/repository'; +import { areOnlyRepositoryFieldsDirty } from '@/helpers/repository'; import { ResourceRequestsEnv } from '@/prisma/client'; import { getQuotaChangeStatus, updatePrivateCloudProductRepositories } from '@/services/backend/private-cloud/products'; import { usePrivateProductState } from '@/states/global'; @@ -101,10 +101,9 @@ export default privateCloudProductEdit(({ session }) => { const { formState, reset } = methods; useEffect(() => { - if (!snap.currentProduct) return; - + if (!currentProduct) return; const resourceRequests = Object.fromEntries( - Object.entries(snap.currentProduct.resourceRequests ?? {}).map(([namespace, requests]) => [ + Object.entries(currentProduct.resourceRequests ?? {}).map(([namespace, requests]) => [ namespace, { ...requests, @@ -117,9 +116,9 @@ export default privateCloudProductEdit(({ session }) => { reset( { - ...snap.currentProduct, + ...currentProduct, resourceRequests, - repositories: snap.currentProduct.repositories ?? [], + repositories: currentProduct.repositories ?? [], isAgMinistry: false, isAgMinistryChecked: true, }, From c4b0b636744e5842d57bc18c8c3c94ee6f0023e7 Mon Sep 17 00:00:00 2001 From: zhanna Date: Tue, 25 Aug 2026 09:07:07 -0700 Subject: [PATCH 13/15] chore(2951): handle missing quota resource values --- .../products/(product)/[licencePlate]/edit/page.tsx | 4 ++-- app/helpers/quota-change.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx index dfe515f718..89796c281b 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/edit/page.tsx @@ -18,7 +18,7 @@ import SiloAccordion from '@/components/private-cloud/SiloAccordion'; import { GlobalRole } from '@/constants'; import createClientPage from '@/core/client-page'; import { canManageGpuQuota } from '@/helpers/quota-change'; -import { areOnlyRepositoryFieldsDirty } from '@/helpers/repository'; +import { areOnlyRepositoryFieldsDirty, getRepositoryFormValues } from '@/helpers/repository'; import { ResourceRequestsEnv } from '@/prisma/client'; import { getQuotaChangeStatus, updatePrivateCloudProductRepositories } from '@/services/backend/private-cloud/products'; import { usePrivateProductState } from '@/states/global'; @@ -117,8 +117,8 @@ export default privateCloudProductEdit(({ session }) => { reset( { ...currentProduct, + ...getRepositoryFormValues(currentProduct), resourceRequests, - repositories: currentProduct.repositories ?? [], isAgMinistry: false, isAgMinistryChecked: true, }, diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 0033a8b50b..81c4981f82 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -18,7 +18,7 @@ export const canManageGpuQuota = (session?: GpuPermissionSession | null) => export const isQuotaUpgrade = (oldval: ResourceRequestsEnv, newval: ResourceRequestsEnv) => namespaceKeys.some((namespace) => - resourceKeys.some((resource) => oldval[namespace][resource] < newval[namespace][resource]), + resourceKeys.some((resource) => (oldval[namespace][resource] ?? 0) < (newval[namespace][resource] ?? 0)), ); export function sanitizeGpuResourceRequests( From 4317f54b2611e2da1a91b5be783b62e14ac6cfb1 Mon Sep 17 00:00:00 2001 From: zhanna Date: Wed, 26 Aug 2026 10:29:04 -0700 Subject: [PATCH 14/15] chore(2951): preserve GPU quota when regular users edit resources --- .../products/_operations/update.test.ts | 45 +++++++++++++++++-- .../products/_operations/update.ts | 7 ++- app/helpers/quota-change.ts | 21 +++++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/update.test.ts b/app/app/api/private-cloud/products/_operations/update.test.ts index 99be621ad9..032df3972a 100644 --- a/app/app/api/private-cloud/products/_operations/update.test.ts +++ b/app/app/api/private-cloud/products/_operations/update.test.ts @@ -311,9 +311,46 @@ describe('Update Private Cloud Product - Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should reset GPU quota to 0 for regular users on Emerald', async () => { - const { product, request } = await createAndProvisionProduct(Cluster.EMERALD); + it('should preserve existing GPU quota when regular user updates other resources on Emerald', async () => { + const product = createSamplePrivateCloudProductData({ + data: { + cluster: Cluster.EMERALD, + resourceRequests: { + ...resourceRequests1, + development: { + ...resourceRequests1.development, + gpu: 4, + }, + }, + }, + }); + + // Create with admin so GPU 4 is allowed + await mockSessionByRole(GlobalRole.Admin); + + const createResponse = await createPrivateCloudProduct(product); + expect(createResponse.status).toBe(200); + const request = await createResponse.json(); + + // Approve + await mockSessionByRole(GlobalRole.PrivateReviewer); + + const approvalResponse = await makePrivateCloudRequestDecision(request.id, { + ...request.decisionData, + type: RequestType.CREATE, + decision: DecisionStatus.APPROVED, + }); + + expect(approvalResponse.status).toBe(200); + + // Provision + await mockTeamServiceAccount(['private-admin']); + + const provisionResponse = await provisionPrivateCloudProduct(request.licencePlate); + expect(provisionResponse.status).toBe(200); + + // Regular user edits another resource await mockSessionByIdirGuid(product.primaryTechnicalLead.idirGuid); const response = await editPrivateCloudProduct(request.licencePlate, { @@ -323,7 +360,7 @@ describe('Update Private Cloud Product - Validations', () => { ...request.decisionData.resourceRequests, development: { ...request.decisionData.resourceRequests.development, - gpu: 4, + cpu: request.decisionData.resourceRequests.development.cpu + 0.5, }, }, }); @@ -332,7 +369,7 @@ describe('Update Private Cloud Product - Validations', () => { const responseData = await response.json(); - expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); + expect(responseData.decisionData.resourceRequests.development.gpu).toBe(4); }); it('should preserve GPU quota for admin on Emerald', async () => { diff --git a/app/app/api/private-cloud/products/_operations/update.ts b/app/app/api/private-cloud/products/_operations/update.ts index 127ba6e2a4..abd753649c 100644 --- a/app/app/api/private-cloud/products/_operations/update.ts +++ b/app/app/api/private-cloud/products/_operations/update.ts @@ -54,7 +54,12 @@ export default async function updateOp({ rest.members = product.members.map(({ userId, roles }) => ({ userId, roles })); } const canManageGpu = canManageGpuQuota(session); - rest.resourceRequests = sanitizeGpuResourceRequests(rest.resourceRequests, product.cluster, canManageGpu); + rest.resourceRequests = sanitizeGpuResourceRequests( + rest.resourceRequests, + product.cluster, + canManageGpu, + product.resourceRequests, + ); const productData = { ...rest, diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 81c4981f82..3030b79d2c 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -25,16 +25,21 @@ export function sanitizeGpuResourceRequests( resourceRequests: ResourceRequestsEnv, cluster: Cluster, canManageGpu: boolean, + currentResourceRequests?: ResourceRequestsEnv, ): ResourceRequestsEnv { - const gpuEnabled = canManageGpu && (cluster === Cluster.EMERALD || cluster === Cluster.KLAB2); + const gpuEnabled = cluster === Cluster.EMERALD || cluster === Cluster.KLAB2; return Object.fromEntries( - Object.entries(resourceRequests).map(([namespace, requests]) => [ - namespace, - { - ...requests, - gpu: gpuEnabled ? requests.gpu ?? 0 : 0, - }, - ]), + Object.entries(resourceRequests).map(([namespace, requests]) => { + const key = namespace as keyof ResourceRequestsEnv; + + return [ + namespace, + { + ...requests, + gpu: !gpuEnabled ? 0 : canManageGpu ? requests.gpu ?? 0 : currentResourceRequests?.[key]?.gpu ?? 0, + }, + ]; + }), ) as ResourceRequestsEnv; } From f5beb4e937abdc391f9dfc91d813e08445d0b00f Mon Sep 17 00:00:00 2001 From: zhanna Date: Wed, 26 Aug 2026 13:30:07 -0700 Subject: [PATCH 15/15] chore(2951): verify GPU quota is preserved for regular users --- .../products/_operations/update.test.ts | 15 +++------------ app/helpers/quota-change.ts | 12 +++++++++++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/app/api/private-cloud/products/_operations/update.test.ts b/app/app/api/private-cloud/products/_operations/update.test.ts index 032df3972a..13541f006c 100644 --- a/app/app/api/private-cloud/products/_operations/update.test.ts +++ b/app/app/api/private-cloud/products/_operations/update.test.ts @@ -311,7 +311,7 @@ describe('Update Private Cloud Product - Validations', () => { expect(responseData.decisionData.resourceRequests.development.gpu).toBe(0); }); - it('should preserve existing GPU quota when regular user updates other resources on Emerald', async () => { + it('should preserve existing GPU quota when regular user updates other fields on Emerald', async () => { const product = createSamplePrivateCloudProductData({ data: { cluster: Cluster.EMERALD, @@ -325,7 +325,6 @@ describe('Update Private Cloud Product - Validations', () => { }, }); - // Create with admin so GPU 4 is allowed await mockSessionByRole(GlobalRole.Admin); const createResponse = await createPrivateCloudProduct(product); @@ -333,7 +332,6 @@ describe('Update Private Cloud Product - Validations', () => { const request = await createResponse.json(); - // Approve await mockSessionByRole(GlobalRole.PrivateReviewer); const approvalResponse = await makePrivateCloudRequestDecision(request.id, { @@ -344,25 +342,18 @@ describe('Update Private Cloud Product - Validations', () => { expect(approvalResponse.status).toBe(200); - // Provision await mockTeamServiceAccount(['private-admin']); const provisionResponse = await provisionPrivateCloudProduct(request.licencePlate); expect(provisionResponse.status).toBe(200); - // Regular user edits another resource await mockSessionByIdirGuid(product.primaryTechnicalLead.idirGuid); const response = await editPrivateCloudProduct(request.licencePlate, { ...request.decisionData, + description: `${request.decisionData.description} updated`, isAgMinistry: request.project?.organization.isAgMinistry || false, - resourceRequests: { - ...request.decisionData.resourceRequests, - development: { - ...request.decisionData.resourceRequests.development, - cpu: request.decisionData.resourceRequests.development.cpu + 0.5, - }, - }, + resourceRequests: request.decisionData.resourceRequests, }); expect(response.status).toBe(200); diff --git a/app/helpers/quota-change.ts b/app/helpers/quota-change.ts index 3030b79d2c..433953ac72 100644 --- a/app/helpers/quota-change.ts +++ b/app/helpers/quota-change.ts @@ -33,11 +33,21 @@ export function sanitizeGpuResourceRequests( Object.entries(resourceRequests).map(([namespace, requests]) => { const key = namespace as keyof ResourceRequestsEnv; + let gpu = 0; + + if (gpuEnabled) { + if (canManageGpu) { + gpu = requests.gpu ?? 0; + } else { + gpu = currentResourceRequests?.[key]?.gpu ?? 0; + } + } + return [ namespace, { ...requests, - gpu: !gpuEnabled ? 0 : canManageGpu ? requests.gpu ?? 0 : currentResourceRequests?.[key]?.gpu ?? 0, + gpu, }, ]; }),