Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions frontend/desktop/docs/native-client-quota.md
Original file line number Diff line number Diff line change
@@ -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-<workspaceId>.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.
180 changes: 180 additions & 0 deletions frontend/desktop/src/__tests__/unit/api/workspace/getQuota.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 37 additions & 0 deletions frontend/desktop/src/pages/api/workspace/getQuota.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
}
65 changes: 65 additions & 0 deletions frontend/desktop/src/services/backend/workspaceQuota.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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;
}
Loading