From f373402d1a570b062c2e74b6b210846adc6eab5e Mon Sep 17 00:00:00 2001 From: Randy Bruno Piverger <21374229+Randy424@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:05 -0700 Subject: [PATCH] ACM-40502 fix(backend): use GET /api with body drain instead of GET /apis [release-2.14] Backport of the memory leak fix to release-2.14. isAuthenticated() was calling GET /apis on every authenticated request and never consuming the response body on the success path. This left sockets stuck outside the keepAlive pool, causing unbounded native (external) memory growth in console-chart-console-v2 pods. Fix: switch to GET /api (~200 bytes vs 35+ KB for /apis which scales with CRDs), drain the response body via .resume() so the socket returns to the keepAlive pool immediately, and return the numeric status so callers no longer hold a Response reference. Signed-off-by: Randy Bruno Piverger <21374229+Randy424@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/src/lib/authenticated.ts | 5 ++--- backend/src/lib/token.ts | 16 +++++++++----- backend/test/routes/aggregator.test.ts | 8 +++---- backend/test/routes/ansibletower.test.ts | 22 +++++++++---------- backend/test/routes/apiPath.test.ts | 2 +- backend/test/routes/hub.test.ts | 2 +- backend/test/routes/metrics.test.ts | 4 ++-- backend/test/routes/metricsProxy.test.ts | 4 ++-- backend/test/routes/operatorCheck.test.ts | 6 ++--- backend/test/routes/search.test.ts | 4 ++-- .../routes/upgrade-risks-prediction.test.ts | 2 +- backend/test/routes/username.test.ts | 4 ++-- backend/test/routes/userpreference.test.ts | 4 ++-- .../test/routes/virtualMachineProxy.test.ts | 20 ++++++++--------- 14 files changed, 53 insertions(+), 50 deletions(-) diff --git a/backend/src/lib/authenticated.ts b/backend/src/lib/authenticated.ts index 7d3bf1f1b0b..f9e1dd2caee 100644 --- a/backend/src/lib/authenticated.ts +++ b/backend/src/lib/authenticated.ts @@ -7,9 +7,8 @@ export function authenticated(req: Http2ServerRequest, res: Http2ServerResponse) const token = getToken(req) if (!token) return unauthorized(req, res) isAuthenticated(token) - .then((response) => { - res.writeHead(response.status).end() - void response.blob() + .then((status) => { + res.writeHead(status).end() }) .catch(catchInternalServerError(res)) } diff --git a/backend/src/lib/token.ts b/backend/src/lib/token.ts index 884b7b465de..181fc86337e 100644 --- a/backend/src/lib/token.ts +++ b/backend/src/lib/token.ts @@ -26,26 +26,30 @@ export function getToken(req: Http2ServerRequest): string | undefined { return token } -export async function isAuthenticated(token: string) { - return fetchRetry(process.env.CLUSTER_API_URL + '/apis', { +// GET /api returns the core API group (~200 bytes) — unlike /apis which grows +// with every installed CRD. The response body is drained so the socket returns +// to the keepAlive pool immediately and native memory does not accumulate. +export async function isAuthenticated(token: string): Promise { + const response = await fetchRetry(process.env.CLUSTER_API_URL + '/api', { headers: { [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}` }, }) + response.body?.on('error', () => undefined).resume() + return response.status } export async function getAuthenticatedToken(req: Http2ServerRequest, res: Http2ServerResponse): Promise { const token = getToken(req) if (token) { - const authResponse = await isAuthenticated(token) + const status = await isAuthenticated(token) /* istanbul ignore if */ - if (authResponse.status === constants.HTTP_STATUS_OK) { + if (status === constants.HTTP_STATUS_OK) { if (process.env.NODE_ENV === 'development') { const localStorage = new LocalStorage(LOCAL_STORAGE) localStorage.setItem(ADMIN_TOKEN, token) } return token } else { - res.writeHead(authResponse.status).end() - void authResponse.blob() + res.writeHead(status).end() } } else { unauthorized(req, res) diff --git a/backend/test/routes/aggregator.test.ts b/backend/test/routes/aggregator.test.ts index 65d0112340b..875ef2878f7 100644 --- a/backend/test/routes/aggregator.test.ts +++ b/backend/test/routes/aggregator.test.ts @@ -49,7 +49,7 @@ describe(`aggregator Route`, function () { }) it(`should page Unfiltered Applications`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) // initialize events - cache sequentially to ensure deterministic order for (const resource of resources) { @@ -94,7 +94,7 @@ describe(`aggregator Route`, function () { expect(await parseResponseJsonBody(res)).toEqual(responseNoFilter) }) it(`should page Filtered Applications`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) // initialize events - cache sequentially to ensure deterministic order for (const resource of resources) { @@ -126,7 +126,7 @@ describe(`aggregator Route`, function () { expect(JSON.stringify(await parseResponseJsonBody(res))).toEqual(JSON.stringify(responseFiltered)) }) it(`should return application counts`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) // initialize events - cache sequentially to ensure deterministic order for (const resource of resources) { @@ -150,7 +150,7 @@ describe(`aggregator Route`, function () { expect(JSON.stringify(await parseResponseJsonBody(res))).toEqual(JSON.stringify(responseCount)) }) it(`should return ui data`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) // initialize events - cache sequentially to ensure deterministic order for (const resource of resources) { diff --git a/backend/test/routes/ansibletower.test.ts b/backend/test/routes/ansibletower.test.ts index 2c6b77bf982..c6fb2b08413 100644 --- a/backend/test/routes/ansibletower.test.ts +++ b/backend/test/routes/ansibletower.test.ts @@ -24,7 +24,7 @@ function nockCredentialSecret(host: string) { describe(`ansibletower Route`, function () { it(`should list Ansible TowerJobs`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) const res = await request('POST', '/ansibletower', { @@ -37,7 +37,7 @@ describe(`ansibletower Route`, function () { }) it(`should reject body-supplied tower hostname`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) const res = await request('POST', '/ansibletower', { towerHost: TOWER_HOST + ansiblePaths[0], token: '12345', @@ -46,7 +46,7 @@ describe(`ansibletower Route`, function () { }) it(`should preserve the query string for paginated requests`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) nock(TOWER_HOST).get(ansiblePaths[0]).query({ page: '2', page_size: '20' }).reply(200, response) const res = await request('POST', '/ansibletower', { @@ -59,7 +59,7 @@ describe(`ansibletower Route`, function () { }) it(`should reject an external absolute URL in ansiblePath`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) const res = await request('POST', '/ansibletower', { secretNamespace: SECRET_NS, @@ -70,7 +70,7 @@ describe(`ansibletower Route`, function () { }) it(`should reject a network-path reference in ansiblePath`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) const res = await request('POST', '/ansibletower', { secretNamespace: SECRET_NS, @@ -81,7 +81,7 @@ describe(`ansibletower Route`, function () { }) it(`should fail closed when caller cannot read the credential secret`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .get(`/api/v1/namespaces/${SECRET_NS}/secrets/${SECRET_NAME}`) .reply(403, { kind: 'Status', apiVersion: 'v1', status: 'Failure', reason: 'Forbidden', code: 403 }) @@ -94,7 +94,7 @@ describe(`ansibletower Route`, function () { }) it(`should reject body-supplied tower hostname`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) const res = await request('POST', '/ansibletower', { towerHost: TOWER_HOST + ansiblePaths[0], token: '12345', @@ -103,7 +103,7 @@ describe(`ansibletower Route`, function () { }) it(`should fail closed when caller cannot read the credential secret`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .get(`/api/v1/namespaces/${SECRET_NS}/secrets/${SECRET_NAME}`) .reply(403, { kind: 'Status', apiVersion: 'v1', status: 'Failure', reason: 'Forbidden', code: 403 }) @@ -116,7 +116,7 @@ describe(`ansibletower Route`, function () { }) it(`when bad things happen to Ansible TowerJobs 1`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) const res = await request('POST', '/ansibletower', { @@ -129,7 +129,7 @@ describe(`ansibletower Route`, function () { }) it(`when bad things happen to Ansible TowerJobs 2`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nockCredentialSecret(TOWER_HOST) nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) const res = await request('POST', '/ansibletower', { @@ -141,7 +141,7 @@ describe(`ansibletower Route`, function () { }) it(`when bad things happen to Ansible TowerJobs 3`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(400) + nock(process.env.CLUSTER_API_URL).get('/api').reply(400) const res = await request('POST', '/ansibletower') expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify({})) }) diff --git a/backend/test/routes/apiPath.test.ts b/backend/test/routes/apiPath.test.ts index 75732fcfc28..2d24f1f7d36 100644 --- a/backend/test/routes/apiPath.test.ts +++ b/backend/test/routes/apiPath.test.ts @@ -15,7 +15,7 @@ describe(`apiPath Route`, function () { nock(process.env.CLUSTER_API_URL).get(paths[0]).reply(200, response) - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, paths: response, }) diff --git a/backend/test/routes/hub.test.ts b/backend/test/routes/hub.test.ts index c199119b017..9ecdef794cd 100644 --- a/backend/test/routes/hub.test.ts +++ b/backend/test/routes/hub.test.ts @@ -5,7 +5,7 @@ import { request } from '../mock-request' describe('global hub', function () { it('should return the boolean', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) diff --git a/backend/test/routes/metrics.test.ts b/backend/test/routes/metrics.test.ts index ab95801d227..43e44f97da5 100644 --- a/backend/test/routes/metrics.test.ts +++ b/backend/test/routes/metrics.test.ts @@ -4,7 +4,7 @@ import { request } from '../mock-request' describe('metrics route', function () { it('Should response with successful metrics GET', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) @@ -25,7 +25,7 @@ describe('metrics route', function () { }) it('Should response with successful metrics GET request with page param', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) diff --git a/backend/test/routes/metricsProxy.test.ts b/backend/test/routes/metricsProxy.test.ts index c6a55a6d0fc..6539faee920 100644 --- a/backend/test/routes/metricsProxy.test.ts +++ b/backend/test/routes/metricsProxy.test.ts @@ -4,14 +4,14 @@ import { request } from '../mock-request' describe('metrics proxy route', function () { it('Successfully calls prometheus endpoint', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) const res = await request('GET', '/prometheus/query') expect(res.statusCode).toEqual(200) }) it(`Successfully calls observability endpoint`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) const res = await request('GET', '/observability/query') diff --git a/backend/test/routes/operatorCheck.test.ts b/backend/test/routes/operatorCheck.test.ts index 2af878d2412..c94327efee4 100644 --- a/backend/test/routes/operatorCheck.test.ts +++ b/backend/test/routes/operatorCheck.test.ts @@ -23,7 +23,7 @@ const subscriptionOperators = { describe(`operatorCheck Route`, function () { it(`returns valid response with version for installed operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) @@ -38,7 +38,7 @@ describe(`operatorCheck Route`, function () { }) }) it(`returns valid response for not-installed operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) @@ -52,7 +52,7 @@ describe(`operatorCheck Route`, function () { }) }) it(`returns bad request for arbitrary operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) diff --git a/backend/test/routes/search.test.ts b/backend/test/routes/search.test.ts index 0a50d6c469b..f4fc879f29f 100644 --- a/backend/test/routes/search.test.ts +++ b/backend/test/routes/search.test.ts @@ -4,7 +4,7 @@ import nock from 'nock' describe(`search Route`, function () { it(`uses search-api in the namespace of the MultiClusterHub`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) @@ -27,7 +27,7 @@ describe(`search Route`, function () { //expect(res.statusCode).toEqual(200) }) it(`uses search-api in namespace of pod if no MultiClusterHub`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL).get('/apis/operator.open-cluster-management.io/v1/multiclusterhubs').reply(200, { diff --git a/backend/test/routes/upgrade-risks-prediction.test.ts b/backend/test/routes/upgrade-risks-prediction.test.ts index c0a1a260d5d..ae459214246 100644 --- a/backend/test/routes/upgrade-risks-prediction.test.ts +++ b/backend/test/routes/upgrade-risks-prediction.test.ts @@ -6,7 +6,7 @@ import { getProxyAgent } from '../../src/lib/agent' describe('Upgrade risks prediction Route', function () { it('should return the upgrade risks', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .get('/api/v1/namespaces/openshift-config/secrets') .reply(200, { diff --git a/backend/test/routes/username.test.ts b/backend/test/routes/username.test.ts index dbb12649116..8ca0112a498 100644 --- a/backend/test/routes/username.test.ts +++ b/backend/test/routes/username.test.ts @@ -5,7 +5,7 @@ import nock from 'nock' describe('username Route', function () { it('should return the username', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) @@ -23,7 +23,7 @@ describe('username Route', function () { expect(body).toEqual({ username: 'testuser' }) }) it('should return empty string if no username provided', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200, { + nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200, }) nock(process.env.CLUSTER_API_URL) diff --git a/backend/test/routes/userpreference.test.ts b/backend/test/routes/userpreference.test.ts index b6b2b0456ea..8f53479cf89 100644 --- a/backend/test/routes/userpreference.test.ts +++ b/backend/test/routes/userpreference.test.ts @@ -5,7 +5,7 @@ import { request } from '../mock-request' describe('userpreference Route', function () { it('should return the userpreference', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post('/apis/authentication.k8s.io/v1/tokenreviews') .reply(200, { @@ -53,7 +53,7 @@ describe('userpreference Route', function () { savedSearches: [{ description: '', id: '1678205878189', name: 'testing', searchText: 'kind:Pod' }], }, } - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post('/apis/authentication.k8s.io/v1/tokenreviews') .reply(200, { diff --git a/backend/test/routes/virtualMachineProxy.test.ts b/backend/test/routes/virtualMachineProxy.test.ts index bf61be4ad84..ff3a7cf9c90 100644 --- a/backend/test/routes/virtualMachineProxy.test.ts +++ b/backend/test/routes/virtualMachineProxy.test.ts @@ -8,7 +8,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully call start action', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -55,7 +55,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully call pause action', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -102,7 +102,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully take snapshot action', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -173,7 +173,7 @@ describe('Virtual Machine actions', function () { }) it('should error on start action request', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -218,7 +218,7 @@ describe('Virtual Machine actions', function () { }) it('should fail with invalid route and secret', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -250,7 +250,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully restore a snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -321,7 +321,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully get VM', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -364,7 +364,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully get VM snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -407,7 +407,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully delete VM', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', @@ -455,7 +455,7 @@ describe('Virtual Machine actions', function () { }) it('should successfully delete VM Snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(200) + nock(process.env.CLUSTER_API_URL).get('/api').reply(200) nock(process.env.CLUSTER_API_URL) .post( '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews',