From f0a79a243d99ce3775b0a55ed73915463a0eb471 Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 21 Oct 2026 17:38:23 +0800 Subject: [PATCH] fix(desktop): add native client workspace quota endpoint --- frontend/desktop/docs/native-client-quota.md | 53 ++++++ .../unit/api/workspace/getQuota.test.ts | 180 ++++++++++++++++++ .../src/pages/api/workspace/getQuota.ts | 37 ++++ .../src/services/backend/workspaceQuota.ts | 65 +++++++ 4 files changed, 335 insertions(+) create mode 100644 frontend/desktop/docs/native-client-quota.md create mode 100644 frontend/desktop/src/__tests__/unit/api/workspace/getQuota.test.ts create mode 100644 frontend/desktop/src/pages/api/workspace/getQuota.ts create mode 100644 frontend/desktop/src/services/backend/workspaceQuota.ts diff --git a/frontend/desktop/docs/native-client-quota.md b/frontend/desktop/docs/native-client-quota.md new file mode 100644 index 000000000000..04beeb899bc3 --- /dev/null +++ b/frontend/desktop/docs/native-client-quota.md @@ -0,0 +1,53 @@ +# Native desktop workspace quota API + +`GET /api/workspace/getQuota` adapts Kubernetes ResourceQuota to the native +desktop client's billing overview contract. It reports allocated resource usage, +not AIProxy token consumption, actual CPU/memory utilization, or account balance. +No additional configuration or environment variables are required. + +Send the regional token in `Authorization`, as for `/api/account/getAmount`. +The workspace is always taken from the verified token's `workspaceId`; query +parameters and request bodies cannot select another workspace. Reads use the +user's kubeconfig and Kubernetes RBAC. The endpoint does not modify resources. + +The response uses the existing desktop API envelope: + +```json +{ + "code": 200, + "message": "", + "data": { + "quota": [ + { "type": "cpu", "used": 250, "limit": 2000 }, + { "type": "memory", "used": 512, "limit": 4096 }, + { "type": "storage", "used": 2048, "limit": 102400 }, + { "type": "pod", "used": 3, "limit": 20 }, + { "type": "nodeport", "used": 1, "limit": 5 } + ] + } +} +``` + +| Client type | ResourceQuota keys, in priority order | Response unit | +| ----------- | -------------------------------------------- | ------------- | +| `cpu` | `limits.cpu`, `requests.cpu`, `cpu` | millicores | +| `memory` | `limits.memory`, `requests.memory`, `memory` | Mi | +| `storage` | `requests.storage` | Mi | +| `pod` | `pods`, `count/pods` | count | +| `nodeport` | `services.nodeports` | count | + +Both `used` and `limit` come from the same selected key in +`quota-.status`. Unconfigured resources are omitted, configured zero +limits are preserved, and absent usage defaults to zero. Kubernetes decimal SI, +binary SI and exponent quantities are converted to the response units. + +Responses include `Cache-Control: no-store`. As with existing desktop APIs, +clients must inspect envelope `code` even when HTTP status is 200: 401 means an +invalid regional token, 403 means Kubernetes denied access, 404 means the user's +kubeconfig or quota was not found, and 500 means the read or normalization failed +(including unavailable quota status). These failures return `data: null`, never +a successful empty quota. Non-GET requests return envelope code 405 and `Allow: GET`. + +This endpoint preserves the current native client contract. The client's combined +balance/quota failure handling remains unchanged; deploying the endpoint addresses +the missing-route error but does not prove authenticated billing acceptance. diff --git a/frontend/desktop/src/__tests__/unit/api/workspace/getQuota.test.ts b/frontend/desktop/src/__tests__/unit/api/workspace/getQuota.test.ts new file mode 100644 index 000000000000..fca732e23fa5 --- /dev/null +++ b/frontend/desktop/src/__tests__/unit/api/workspace/getQuota.test.ts @@ -0,0 +1,180 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { load } from 'js-yaml'; +import handler from '@/pages/api/workspace/getQuota'; +import { verifyAccessToken } from '@/services/backend/auth'; +import { getUserKubeconfigNotPatch } from '@/services/backend/kubernetes/admin'; +import { K8sApi } from '@/services/backend/kubernetes/user'; +import { normalizeWorkspaceQuota } from '@/services/backend/workspaceQuota'; + +jest.mock('@/services/backend/auth', () => ({ verifyAccessToken: jest.fn() })); +jest.mock('@/services/backend/kubernetes/admin', () => ({ + getUserKubeconfigNotPatch: jest.fn() +})); +jest.mock('@/services/backend/kubernetes/user', () => ({ K8sApi: jest.fn() })); + +const readQuota = jest.fn(); +const createResponse = () => + ({ json: jest.fn(), setHeader: jest.fn() } as unknown as NextApiResponse); +const createRequest = (method = 'GET') => + ({ + method, + headers: { authorization: 'regional-token' }, + query: { namespace: 'ns-victim', workspaceId: 'ns-victim' }, + body: { namespace: 'ns-victim', workspaceId: 'ns-victim' } + } as unknown as NextApiRequest); + +describe('GET /api/workspace/getQuota', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(verifyAccessToken).mockResolvedValue({ + regionUid: 'region', + userUid: 'user-uid', + userId: 'user-id', + userCrUid: 'user-cr-uid', + userCrName: 'user-cr', + workspaceUid: 'workspace-uid', + workspaceId: 'ns-team' + }); + jest + .mocked(getUserKubeconfigNotPatch) + .mockResolvedValue('contexts:\n - context:\n namespace: ns-original\n'); + jest.mocked(K8sApi).mockReturnValue({ + makeApiClient: () => ({ readNamespacedResourceQuota: readQuota }) + } as any); + readQuota.mockResolvedValue({ + body: { + status: { + hard: { + 'limits.cpu': '2', + 'limits.memory': '4Gi', + 'requests.storage': '100Gi', + pods: '20', + 'services.nodeports': '5' + }, + used: { + 'limits.cpu': '250m', + 'limits.memory': '512Mi', + 'requests.storage': '2Gi', + pods: '3', + 'services.nodeports': '1' + } + } + } + }); + }); + + it('returns the exact native client contract and reads only the token workspace', async () => { + const res = createResponse(); + await handler(createRequest(), res); + + expect(verifyAccessToken).toHaveBeenCalledWith({ authorization: 'regional-token' }); + expect(getUserKubeconfigNotPatch).toHaveBeenCalledWith('user-cr'); + expect(load(jest.mocked(K8sApi).mock.calls[0][0])).toEqual({ + contexts: [{ context: { namespace: 'ns-team' } }] + }); + expect(readQuota).toHaveBeenCalledTimes(1); + expect(readQuota).toHaveBeenCalledWith('quota-ns-team', 'ns-team'); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(res.json).toHaveBeenCalledWith({ + code: 200, + message: '', + data: { + quota: [ + { type: 'cpu', used: 250, limit: 2000 }, + { type: 'memory', used: 512, limit: 4096 }, + { type: 'storage', used: 2048, limit: 102400 }, + { type: 'pod', used: 3, limit: 20 }, + { type: 'nodeport', used: 1, limit: 5 } + ] + } + }); + }); + + it('rejects missing or expired regional tokens without accessing Kubernetes', async () => { + jest.mocked(verifyAccessToken).mockResolvedValue(null); + const res = createResponse(); + await handler(createRequest(), res); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 401, data: null })); + expect(getUserKubeconfigNotPatch).not.toHaveBeenCalled(); + expect(readQuota).not.toHaveBeenCalled(); + }); + + it('rejects other methods without accessing Kubernetes', async () => { + const res = createResponse(); + await handler(createRequest('POST'), res); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 405, data: null })); + expect(res.setHeader).toHaveBeenCalledWith('Allow', 'GET'); + expect(verifyAccessToken).not.toHaveBeenCalled(); + expect(readQuota).not.toHaveBeenCalled(); + }); + + it('reports a missing user kubeconfig', async () => { + jest.mocked(getUserKubeconfigNotPatch).mockResolvedValue(null); + const res = createResponse(); + await handler(createRequest(), res); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 404, data: null })); + expect(readQuota).not.toHaveBeenCalled(); + }); + + it.each([403, 404, 500])('reports Kubernetes error %i without leaking details', async (code) => { + readQuota.mockRejectedValue({ statusCode: code, body: 'sensitive upstream details' }); + const res = createResponse(); + await handler(createRequest(), res); + expect(res.json).toHaveBeenCalledWith({ + code, + message: 'Failed to read workspace quota', + data: null + }); + }); + + it('does not turn an unavailable quota status into successful empty data', async () => { + readQuota.mockResolvedValue({ body: {} }); + const res = createResponse(); + await handler(createRequest(), res); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 500, data: null })); + }); +}); + +describe('native client quantity units', () => { + it.each([ + ['500m', 'cpu', 500], + ['250000u', 'cpu', 250], + ['100000000n', 'cpu', 100], + ['1e-3', 'cpu', 1], + ['1.5', 'cpu', 1500], + ['1048576', 'memory', 1], + ['1024Ki', 'memory', 1], + ['1.5Gi', 'memory', 1536], + ['1G', 'memory', 1000000000 / 1048576], + ['1Ti', 'storage', 1048576] + ])('converts %s for %s', (quantity, type, expected) => { + const key = type === 'storage' ? 'requests.storage' : `limits.${type}`; + expect(normalizeWorkspaceQuota({ status: { hard: { [key]: quantity } } })).toEqual([ + { type, used: 0, limit: expected } + ]); + }); + + it('uses matching hard/used keys, preserves zero limits and omits unconfigured resources', () => { + expect( + normalizeWorkspaceQuota({ + status: { + hard: { + 'limits.cpu': '0', + 'requests.cpu': '2', + 'requests.memory': '1Gi', + 'count/pods': '10' + }, + used: { 'requests.cpu': '1', 'requests.memory': '256Mi', 'count/pods': '2' } + } + }) + ).toEqual([ + { type: 'cpu', used: 0, limit: 0 }, + { type: 'memory', used: 256, limit: 1024 }, + { type: 'pod', used: 2, limit: 10 } + ]); + }); + + it.each(['invalid', '-1', '1GiB', '1e999'])('rejects invalid quantity %s', (value) => { + expect(() => normalizeWorkspaceQuota({ status: { hard: { 'limits.cpu': value } } })).toThrow(); + }); +}); diff --git a/frontend/desktop/src/pages/api/workspace/getQuota.ts b/frontend/desktop/src/pages/api/workspace/getQuota.ts new file mode 100644 index 000000000000..56f8816ad8e5 --- /dev/null +++ b/frontend/desktop/src/pages/api/workspace/getQuota.ts @@ -0,0 +1,37 @@ +import { verifyAccessToken } from '@/services/backend/auth'; +import { getUserKubeconfigNotPatch } from '@/services/backend/kubernetes/admin'; +import { K8sApi } from '@/services/backend/kubernetes/user'; +import { jsonRes } from '@/services/backend/response'; +import { normalizeWorkspaceQuota } from '@/services/backend/workspaceQuota'; +import { switchKubeconfigNamespace } from '@/utils/switchKubeconfigNamespace'; +import * as k8s from '@kubernetes/client-node'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + res.setHeader('Cache-Control', 'no-store'); + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return jsonRes(res, { code: 405, message: 'Method not allowed' }); + } + + try { + const payload = await verifyAccessToken(req.headers); + if (!payload?.workspaceId) { + return jsonRes(res, { code: 401, message: 'Invalid regional token' }); + } + const config = await getUserKubeconfigNotPatch(payload.userCrName); + if (!config) return jsonRes(res, { code: 404, message: 'User kubeconfig not found' }); + + // Never accept a namespace from the request; Kubernetes also enforces the user's RBAC. + const namespace = payload.workspaceId; + const kc = K8sApi(switchKubeconfigNamespace(config, namespace)); + const { body } = await kc + .makeApiClient(k8s.CoreV1Api) + .readNamespacedResourceQuota(`quota-${namespace}`, namespace); + return jsonRes(res, { data: { quota: normalizeWorkspaceQuota(body) } }); + } catch (error) { + const statusCode = (error as { statusCode?: number })?.statusCode; + const code = statusCode === 403 || statusCode === 404 ? statusCode : 500; + return jsonRes(res, { code, message: 'Failed to read workspace quota' }); + } +} diff --git a/frontend/desktop/src/services/backend/workspaceQuota.ts b/frontend/desktop/src/services/backend/workspaceQuota.ts new file mode 100644 index 000000000000..6a440221c339 --- /dev/null +++ b/frontend/desktop/src/services/backend/workspaceQuota.ts @@ -0,0 +1,65 @@ +import type { V1ResourceQuota } from '@kubernetes/client-node'; +import Decimal from 'decimal.js'; + +type QuotaType = 'cpu' | 'memory' | 'storage' | 'pod' | 'nodeport'; + +const resources: { type: QuotaType; keys: string[] }[] = [ + { type: 'cpu', keys: ['limits.cpu', 'requests.cpu', 'cpu'] }, + { type: 'memory', keys: ['limits.memory', 'requests.memory', 'memory'] }, + { type: 'storage', keys: ['requests.storage'] }, + { type: 'pod', keys: ['pods', 'count/pods'] }, + { type: 'nodeport', keys: ['services.nodeports'] } +]; + +// Kubernetes Quantity supports decimal SI, binary SI and decimal exponents. +function parseQuantity(value: string): Decimal { + const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))([eE][+-]?\d+|[KMGTPE]i|[numkKMGTPE])?$/.exec( + value + ); + if (!match) throw new Error('Invalid resource quantity'); + const [, amount, suffix = ''] = match; + const decimalPowers: Record = { + n: -9, + u: -6, + m: -3, + '': 0, + k: 3, + K: 3, + M: 6, + G: 9, + T: 12, + P: 15, + E: 18 + }; + const multiplier = suffix.endsWith('i') + ? new Decimal(1024).pow('KMGTPE'.indexOf(suffix[0]) + 1) + : new Decimal(10).pow( + /^[eE][+-]?\d+$/.test(suffix) ? Number(suffix.slice(1)) : decimalPowers[suffix] + ); + const quantity = new Decimal(amount).mul(multiplier); + if (!quantity.isFinite() || quantity.isNegative()) throw new Error('Invalid resource quantity'); + return quantity; +} + +/** Native desktop contract: CPU in millicores, memory/storage in Mi, counts unchanged. */ +export function normalizeWorkspaceQuota(quota: V1ResourceQuota) { + const hard = quota.status?.hard; + if (!hard) throw new Error('Resource quota status unavailable'); + const used = quota.status?.used || {}; + const items: { type: QuotaType; used: number; limit: number }[] = []; + + for (const { type, keys } of resources) { + const key = keys.find((key) => hard[key] !== undefined); + if (!key) continue; + const scale = type === 'cpu' ? 1000 : type === 'memory' || type === 'storage' ? 1 / 1048576 : 1; + const limit = parseQuantity(hard[key]).mul(scale).toNumber(); + const usage = parseQuantity(used[key] ?? '0') + .mul(scale) + .toNumber(); + if (!Number.isFinite(limit) || !Number.isFinite(usage)) { + throw new Error('Resource quantity out of range'); + } + items.push({ type, used: usage, limit }); + } + return items; +}