diff --git a/package-lock.json b/package-lock.json index 6588927f61..0854254dcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "socks-proxy-agent": "^8.0.4", "stream-buffers": "^3.0.2", "tar-fs": "^3.0.9", + "undici": "^7.24.0", "ws": "^8.18.2" }, "devDependencies": { @@ -3691,6 +3692,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.0.tgz", + "integrity": "sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index b8cb9b7efd..95aa1c6f14 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "socks-proxy-agent": "^8.0.4", "stream-buffers": "^3.0.2", "tar-fs": "^3.0.9", + "undici": "^7.24.0", "ws": "^8.18.2" }, "devDependencies": { diff --git a/src/azure_auth_test.ts b/src/azure_auth_test.ts index 979c31dadc..cafd87c115 100644 --- a/src/azure_auth_test.ts +++ b/src/azure_auth_test.ts @@ -7,6 +7,7 @@ import { User, Cluster } from './config_types.js'; import { AzureAuth } from './azure_auth.js'; import { KubeConfig } from './config.js'; import { HttpMethod, RequestContext } from './index.js'; +import { Agent as UndiciAgent } from 'undici'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -105,8 +106,14 @@ describe('AzureAuth', () => { const requestContext = new RequestContext(testUrl1, HttpMethod.GET); await config.applySecurityAuthentication(requestContext); - // @ts-expect-error - strictEqual(requestContext.getAgent().options.rejectUnauthorized, false); + const dispatcher = requestContext.getDispatcher() as UndiciAgent; + strictEqual(dispatcher instanceof UndiciAgent, true); + strictEqual( + dispatcher[ + Object.getOwnPropertySymbols(dispatcher).find((s) => s.toString() === 'Symbol(options)')! + ].connect.rejectUnauthorized, + false, + ); }); it('should not set rejectUnauthorized if skipTLSVerify is not set', async () => { @@ -128,8 +135,8 @@ describe('AzureAuth', () => { const requestContext = new RequestContext(testUrl1, HttpMethod.GET); await config.applySecurityAuthentication(requestContext); - // @ts-expect-error - strictEqual(requestContext.getAgent().options.rejectUnauthorized, undefined); + // When skipTLSVerify is not set, no custom dispatcher is needed - undici validates certs by default + strictEqual(requestContext.getDispatcher(), undefined); }); it('should throw with expired token and no cmd', async () => { diff --git a/src/config.ts b/src/config.ts index a9dd9982b8..7edbd4650d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import net from 'node:net'; import path from 'node:path'; import { Headers, RequestInit } from 'node-fetch'; +import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent, type Dispatcher } from 'undici'; import { RequestContext } from './api.js'; import { Authenticator } from './auth.js'; import { AzureAuth } from './azure_auth.js'; @@ -275,7 +276,10 @@ export class KubeConfig implements SecurityAuthentication { agentOptions.rejectUnauthorized = httpsOptions.rejectUnauthorized; } - context.setAgent(this.createAgent(cluster, agentOptions)); + const dispatcher = this.createDispatcher(cluster, agentOptions); + if (dispatcher !== undefined) { + context.setDispatcher(dispatcher); + } } /** @@ -571,6 +575,43 @@ export class KubeConfig implements SecurityAuthentication { return agent; } + private createDispatcher( + cluster: Cluster | null, + agentOptions: https.AgentOptions, + ): Dispatcher | undefined { + const connectOptions: Record = {}; + if (agentOptions.ca !== undefined) connectOptions.ca = agentOptions.ca; + if (agentOptions.cert !== undefined) connectOptions.cert = agentOptions.cert; + if (agentOptions.key !== undefined) connectOptions.key = agentOptions.key; + if (agentOptions.pfx !== undefined) connectOptions.pfx = agentOptions.pfx; + if (agentOptions.passphrase !== undefined) connectOptions.passphrase = agentOptions.passphrase; + if (agentOptions.rejectUnauthorized !== undefined) + connectOptions.rejectUnauthorized = agentOptions.rejectUnauthorized; + if ((agentOptions as any).servername !== undefined) + connectOptions.servername = (agentOptions as any).servername; + + if (cluster && cluster.proxyUrl) { + if (cluster.proxyUrl.startsWith('socks')) { + throw new Error( + 'SOCKS proxy is not supported with the undici HTTP client. ' + + 'Use an HTTP/HTTPS proxy or configure a custom dispatcher.', + ); + } + if (!cluster.server.startsWith('https') && !cluster.server.startsWith('http')) { + throw new Error('Unsupported proxy type'); + } + return new UndiciProxyAgent({ uri: cluster.proxyUrl, requestTls: connectOptions }); + } else if (cluster?.server?.startsWith('http:') && !cluster.skipTLSVerify) { + throw new Error('HTTP protocol is not allowed when skipTLSVerify is not set or false'); + } + // Only create a custom agent when there are TLS options to configure. + // Otherwise, let undici use its default/global dispatcher (important for testing with MockAgent). + if (Object.keys(connectOptions).length === 0) { + return undefined; + } + return new UndiciAgent({ connect: connectOptions }); + } + private applyHTTPSOptions(opts: https.RequestOptions | WebSocket.ClientOptions): void { const cluster = this.getCurrentCluster(); const user = this.getCurrentUser(); diff --git a/src/config_test.ts b/src/config_test.ts index 0e4dc27fd3..888b2138d5 100644 --- a/src/config_test.ts +++ b/src/config_test.ts @@ -25,8 +25,7 @@ import { CoreV1Api, RequestContext } from './api.js'; import { bufferFromFileOrString, findHomeDir, findObject, KubeConfig, makeAbsolutePath } from './config.js'; import { ActionOnInvalid, Cluster, newClusters, newContexts, newUsers, User } from './config_types.js'; import { ExecAuth } from './exec_auth.js'; -import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; -import { SocksProxyAgent } from 'socks-proxy-agent'; +import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent } from 'undici'; import { AddressInfo } from 'node:net'; const kcFileName = 'testdata/kubeconfig.yaml'; @@ -339,7 +338,14 @@ describe('KubeConfig', () => { }; assertRequestOptionsEqual(opts, expectedOptions); - strictEqual((requestContext.getAgent()! as any).options.servername, 'kube.example2.com'); + const dispatcher = requestContext.getDispatcher(); + strictEqual(dispatcher instanceof UndiciAgent, true); + strictEqual( + (dispatcher as any)[ + Object.getOwnPropertySymbols(dispatcher!).find((s) => s.toString() === 'Symbol(options)')! + ].connect.servername, + 'kube.example2.com', + ); }); it('should apply cert configs', async () => { const kc = new KubeConfig(); @@ -404,16 +410,9 @@ describe('KubeConfig', () => { const testServerName = 'https://example.com'; const rc = new RequestContext(testServerName, HttpMethod.GET); - await kc.applySecurityAuthentication(rc); - const expectedCA = Buffer.from('CADAT@', 'utf-8'); - const expectedProxyHost = 'example'; - const expectedProxyPort = 1187; - - strictEqual(rc.getAgent() instanceof SocksProxyAgent, true); - const agent = rc.getAgent() as SocksProxyAgent; - strictEqual(agent.options.ca?.toString(), expectedCA.toString()); - strictEqual(agent.proxy.host, expectedProxyHost); - strictEqual(agent.proxy.port, expectedProxyPort); + await rejects(kc.applySecurityAuthentication(rc), { + message: /SOCKS proxy is not supported/, + }); }); it('should apply https proxy', async () => { const kc = new KubeConfig(); @@ -427,10 +426,16 @@ describe('KubeConfig', () => { const expectedCA = Buffer.from('CADAT@', 'utf-8'); const expectedProxyHref = 'http://example:9443/'; - strictEqual(rc.getAgent() instanceof HttpsProxyAgent, true); - const agent = rc.getAgent() as HttpsProxyAgent; - strictEqual(agent.options.ca?.toString(), expectedCA.toString()); - strictEqual((agent as any).proxy.href, expectedProxyHref); + const dispatcher = rc.getDispatcher() as UndiciProxyAgent; + strictEqual(dispatcher instanceof UndiciProxyAgent, true); + const kProxyOpts = Object.getOwnPropertySymbols(dispatcher).find( + (s) => s.toString() === 'Symbol(proxy agent options)', + )!; + const kRequestTls = Object.getOwnPropertySymbols(dispatcher).find( + (s) => s.toString() === 'Symbol(request tls settings)', + )!; + strictEqual((dispatcher as any)[kProxyOpts].uri, expectedProxyHref); + strictEqual(Buffer.from((dispatcher as any)[kRequestTls].ca).toString(), expectedCA.toString()); }); it('should apply http proxy', async () => { const kc = new KubeConfig(); @@ -444,10 +449,16 @@ describe('KubeConfig', () => { const expectedCA = Buffer.from('CADAT@', 'utf-8'); const expectedProxyHref = 'http://example:8080/'; - strictEqual(rc.getAgent() instanceof HttpProxyAgent, true); - const agent = rc.getAgent() as HttpProxyAgent; - strictEqual((agent as any).options.ca?.toString(), expectedCA.toString()); - strictEqual((agent as any).proxy.href, expectedProxyHref); + const dispatcher = rc.getDispatcher() as UndiciProxyAgent; + strictEqual(dispatcher instanceof UndiciProxyAgent, true); + const kProxyOpts = Object.getOwnPropertySymbols(dispatcher).find( + (s) => s.toString() === 'Symbol(proxy agent options)', + )!; + const kRequestTls = Object.getOwnPropertySymbols(dispatcher).find( + (s) => s.toString() === 'Symbol(request tls settings)', + )!; + strictEqual((dispatcher as any)[kProxyOpts].uri, expectedProxyHref); + strictEqual(Buffer.from((dispatcher as any)[kRequestTls].ca).toString(), expectedCA.toString()); }); it('should throw an error if proxy-url is provided but the server protocol is not http or https', async () => { const kc = new KubeConfig(); @@ -471,7 +482,7 @@ describe('KubeConfig', () => { await kc.applySecurityAuthentication(rc); - strictEqual(rc.getAgent() instanceof http.Agent, true); + strictEqual(rc.getDispatcher() instanceof UndiciAgent, true); }); it('should throw an error if cluster.server starts with http, no proxy-url is provided and insecure-skip-tls-verify is not set', async () => { const kc = new KubeConfig(); @@ -493,7 +504,7 @@ describe('KubeConfig', () => { await kc.applySecurityAuthentication(rc); - strictEqual(rc.getAgent() instanceof https.Agent, true); + strictEqual(rc.getDispatcher() instanceof UndiciAgent, true); }); it('should apply NODE_TLS_REJECT_UNAUTHORIZED from environment to agent', async () => { diff --git a/src/gcp_auth_test.ts b/src/gcp_auth_test.ts index 5649e2309e..8962fefdb1 100644 --- a/src/gcp_auth_test.ts +++ b/src/gcp_auth_test.ts @@ -7,7 +7,7 @@ import { User, Cluster } from './config_types.js'; import { GoogleCloudPlatformAuth } from './gcp_auth.js'; import { KubeConfig } from './config.js'; import { HttpMethod, RequestContext } from './gen/index.js'; -import { Agent } from 'node:https'; +import { Agent as UndiciAgent } from 'undici'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -107,9 +107,14 @@ describe('GoogleCloudPlatformAuth', () => { await config.applySecurityAuthentication(requestContext); - // @ts-expect-error - const agent: Agent = requestContext.getAgent(); - strictEqual(agent.options.rejectUnauthorized, false); + const dispatcher = requestContext.getDispatcher() as UndiciAgent; + strictEqual(dispatcher instanceof UndiciAgent, true); + strictEqual( + dispatcher[ + Object.getOwnPropertySymbols(dispatcher).find((s) => s.toString() === 'Symbol(options)')! + ].connect.rejectUnauthorized, + false, + ); }); it('should not set rejectUnauthorized if skipTLSVerify is not set', async () => { diff --git a/src/integration_test.ts b/src/integration_test.ts index a62796a166..4fce28c55e 100644 --- a/src/integration_test.ts +++ b/src/integration_test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import { deepEqual } from 'node:assert'; -import nock from 'nock'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { CoreV1Api } from './api.js'; import { KubeConfig } from './config.js'; @@ -31,17 +31,28 @@ describe('FullRequest', () => { items: [], }; const auth = Buffer.from(`${username}:${password}`).toString('base64'); - nock('https://nowhere.foo', { - reqheaders: { - authorization: `Basic ${auth}`, - }, - }) - .get('/api/v1/namespaces/default/pods') - .reply(200, result); - const list = await k8sApi.listNamespacedPod({ namespace: 'default' }); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); - return deepEqual(list, result); + const pool = mockAgent.get('https://nowhere.foo'); + pool.intercept({ + path: '/api/v1/namespaces/default/pods', + method: 'GET', + headers: { authorization: `Basic ${auth}` }, + }).reply(200, JSON.stringify(result), { + headers: { 'content-type': 'application/json' }, + }); + + try { + const list = await k8sApi.listNamespacedPod({ namespace: 'default' }); + deepEqual(list, result); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); }); diff --git a/src/metrics.ts b/src/metrics.ts index 5e72086508..ff923eff80 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,4 +1,5 @@ -import fetch from 'node-fetch'; +import { fetch } from 'undici'; +import { HttpMethod, RequestContext } from './gen/index.js'; import { KubeConfig } from './config.js'; import { ApiException, V1Status } from './gen/index.js'; import { normalizeResponseHeaders } from './util.js'; @@ -85,11 +86,15 @@ export class Metrics { const requestURL = cluster.server + path; - const requestInit = await this.config.applyToFetchOptions({}); - requestInit.method = 'GET'; + const ctx = new RequestContext(requestURL, HttpMethod.GET); + await this.config.applySecurityAuthentication(ctx); try { - const response = await fetch(requestURL, requestInit); + const response = await fetch(requestURL, { + method: 'GET', + headers: ctx.getHeaders(), + dispatcher: ctx.getDispatcher(), + }); const json = await response.json(); const { status } = response; @@ -123,7 +128,7 @@ export class Metrics { } throw new ApiException( 500, - `Error occurred in metrics request: ${e.message}`, + `Error occurred in metrics request: ${e.message}${e.cause ? ': ' + e.cause.message : ''}`, {}, {}, ); diff --git a/src/metrics_test.ts b/src/metrics_test.ts index 9be69a326a..b05138307e 100644 --- a/src/metrics_test.ts +++ b/src/metrics_test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import { deepStrictEqual, ok, match, rejects, strictEqual } from 'node:assert'; -import nock from 'nock'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { KubeConfig } from './config.js'; import { V1Status, ApiException } from './gen/index.js'; import { Metrics, NodeMetricsList, PodMetricsList } from './metrics.js'; @@ -81,62 +81,111 @@ const testConfigOptions: any = { currentContext: 'currentContext', }; -const systemUnderTest = (options: any = testConfigOptions): [Metrics, nock.Scope] => { +const systemUnderTest = (options: any = testConfigOptions): [Metrics, MockAgent] => { const kc = new KubeConfig(); kc.loadFromOptions(options); const metricsClient = new Metrics(kc); - const scope = nock(testConfigOptions.clusters[0].server); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); - return [metricsClient, scope]; + return [metricsClient, mockAgent]; }; describe('Metrics', () => { describe('getPodMetrics', () => { it('should return cluster scope empty pods list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, emptyPodMetrics); - - const response = await metricsClient.getPodMetrics(); - deepStrictEqual(response, emptyPodMetrics); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(emptyPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const response = await metricsClient.getPodMetrics(); + deepStrictEqual(response, emptyPodMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return cluster scope empty pods list when namespace is empty string', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, emptyPodMetrics); - - const response = await metricsClient.getPodMetrics(''); - deepStrictEqual(response, emptyPodMetrics); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(emptyPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const response = await metricsClient.getPodMetrics(''); + deepStrictEqual(response, emptyPodMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return namespace scope empty pods list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope - .get(`/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`) - .reply(200, emptyPodMetrics); - - const response = await metricsClient.getPodMetrics(TEST_NAMESPACE); - deepStrictEqual(response, emptyPodMetrics); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ + path: `/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`, + method: 'GET', + }).reply(200, JSON.stringify(emptyPodMetrics), { + headers: { 'content-type': 'application/json' }, + }); + try { + const response = await metricsClient.getPodMetrics(TEST_NAMESPACE); + deepStrictEqual(response, emptyPodMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return cluster scope pods metrics list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, mockedPodMetrics); - - const response = await metricsClient.getPodMetrics(); - deepStrictEqual(response, mockedPodMetrics); - - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(mockedPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const response = await metricsClient.getPodMetrics(); + deepStrictEqual(response, mockedPodMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return namespace scope pods metric list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope - .get(`/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`) - .reply(200, mockedPodMetrics); - - const response = await metricsClient.getPodMetrics(TEST_NAMESPACE); - deepStrictEqual(response, mockedPodMetrics); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ + path: `/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`, + method: 'GET', + }).reply(200, JSON.stringify(mockedPodMetrics), { + headers: { 'content-type': 'application/json' }, + }); + try { + const response = await metricsClient.getPodMetrics(TEST_NAMESPACE); + deepStrictEqual(response, mockedPodMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should when connection refused', async () => { const kc = new KubeConfig(); @@ -154,80 +203,135 @@ describe('Metrics', () => { }); }); it('should throw when no current cluster', async () => { - const [metricsClient, scope] = systemUnderTest({ + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest({ clusters: [{ name: 'cluster', server: 'https://127.0.0.1:51010' }], users: [{ name: 'user', password: 'password' }], contexts: [{ name: 'currentContext', cluster: 'cluster', user: 'user' }], }); - await rejects(metricsClient.getPodMetrics(), { - name: 'Error', - message: 'No currently active cluster', - }); - scope.done(); + try { + await rejects(metricsClient.getPodMetrics(), { + name: 'Error', + message: 'No currently active cluster', + }); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should resolve to error when 500 - V1 Status', async () => { const response: V1Status = { code: 12345, message: 'some message', }; - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(500, response); - - await rejects(metricsClient.getPodMetrics(), (e) => { - ok(e instanceof ApiException); - strictEqual(e.code, response.code); - strictEqual(e.body.message, response.message); - return true; - }); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 500, + JSON.stringify(response), + { headers: { 'content-type': 'application/json' } }, + ); + try { + await rejects(metricsClient.getPodMetrics(), (e) => { + ok(e instanceof ApiException); + strictEqual(e.code, response.code); + strictEqual(e.body.message, response.message); + return true; + }); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should resolve to error when 500 - non-V1Status', async () => { const response = 'some other response'; - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(500, response); - - await rejects(metricsClient.getPodMetrics(), (e) => { - ok(e instanceof ApiException); - strictEqual(e.code, 500); - match(e.message, /Error occurred in metrics request/); - return true; - }); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 500, + JSON.stringify(response), + { headers: { 'content-type': 'application/json' } }, + ); + try { + await rejects(metricsClient.getPodMetrics(), (e) => { + ok(e instanceof ApiException); + strictEqual(e.code, 500); + match(e.message, /Error occurred in metrics request/); + return true; + }); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); describe('getNodeMetrics', () => { it('should return empty nodes list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/nodes').reply(200, emptyNodeMetrics); - - const response = await metricsClient.getNodeMetrics(); - deepStrictEqual(response, emptyNodeMetrics); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/nodes', method: 'GET' }).reply( + 200, + JSON.stringify(emptyNodeMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const response = await metricsClient.getNodeMetrics(); + deepStrictEqual(response, emptyNodeMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return nodes metrics list', async () => { - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/nodes').reply(200, mockedNodeMetrics); - - const response = await metricsClient.getNodeMetrics(); - deepStrictEqual(response, mockedNodeMetrics); - - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/nodes', method: 'GET' }).reply( + 200, + JSON.stringify(mockedNodeMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const response = await metricsClient.getNodeMetrics(); + deepStrictEqual(response, mockedNodeMetrics); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should resolve to error when 500', async () => { const response: V1Status = { code: 12345, message: 'some message', }; - const [metricsClient, scope] = systemUnderTest(); - const s = scope.get('/apis/metrics.k8s.io/v1beta1/nodes').reply(500, response); - - await rejects(metricsClient.getNodeMetrics(), (e) => { - ok(e instanceof ApiException); - strictEqual(e.code, response.code); - strictEqual(e.body.message, response.message); - return true; - }); - s.done(); + const originalDispatcher = getGlobalDispatcher(); + const [metricsClient, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/nodes', method: 'GET' }).reply( + 500, + JSON.stringify(response), + { headers: { 'content-type': 'application/json' } }, + ); + try { + await rejects(metricsClient.getNodeMetrics(), (e) => { + ok(e instanceof ApiException); + strictEqual(e.code, response.code); + strictEqual(e.body.message, response.message); + return true; + }); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); }); diff --git a/src/object_test.ts b/src/object_test.ts index e27f77edf2..529cdb9dfd 100644 --- a/src/object_test.ts +++ b/src/object_test.ts @@ -1,6 +1,6 @@ import { before, describe, it } from 'node:test'; import { deepStrictEqual, ok, rejects, strictEqual } from 'node:assert'; -import nock from 'nock'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { Configuration, V1APIResource, V1APIResourceList, V1Secret } from './api.js'; import { KubeConfig } from './config.js'; import { KubernetesObjectApi } from './object.js'; @@ -61,10 +61,6 @@ describe('KubernetesObject', () => { } } - const contentTypeJsonHeader = { - 'Content-Type': 'application/json', - }; - const resourceBodies = { core: `{ "groupVersion": "v1", @@ -489,12 +485,22 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'patch'); - strictEqual(r, '/api/v1/namespaces/fugazi/services/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'patch'); + strictEqual(r, '/api/v1/namespaces/fugazi/services/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should default to apiVersion v1', async () => { @@ -506,12 +512,22 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'patch'); - strictEqual(r, '/api/v1/namespaces/fugazi/serviceaccounts/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'patch'); + strictEqual(r, '/api/v1/namespaces/fugazi/serviceaccounts/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should default to context namespace', async () => { @@ -530,12 +546,22 @@ describe('KubernetesObject', () => { name: 'repeater', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'patch'); - strictEqual(r, '/api/v1/namespaces/straight-edge/pods/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'patch'); + strictEqual(r, '/api/v1/namespaces/straight-edge/pods/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should default to default namespace', async () => { @@ -554,12 +580,22 @@ describe('KubernetesObject', () => { name: 'repeater', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'patch'); - strictEqual(r, '/api/v1/namespaces/default/pods/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'patch'); + strictEqual(r, '/api/v1/namespaces/default/pods/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return a non-namespaced path', async () => { @@ -571,12 +607,22 @@ describe('KubernetesObject', () => { name: 'repeater', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'delete'); - strictEqual(r, '/api/v1/namespaces/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'delete'); + strictEqual(r, '/api/v1/namespaces/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return a namespaced path without name', async () => { @@ -588,12 +634,22 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'create'); - strictEqual(r, '/api/v1/namespaces/fugazi/services'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'create'); + strictEqual(r, '/api/v1/namespaces/fugazi/services'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return a non-namespaced path without name', async () => { @@ -605,12 +661,22 @@ describe('KubernetesObject', () => { name: 'repeater', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'create'); - strictEqual(r, '/api/v1/namespaces'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'create'); + strictEqual(r, '/api/v1/namespaces'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return a namespaced path for non-core resource', async () => { @@ -623,12 +689,22 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/apis/apps/v1') - .reply(200, resourceBodies.apps, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'read'); - strictEqual(r, '/apis/apps/v1/namespaces/fugazi/deployments/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/apis/apps/v1', method: 'GET' }).reply(200, resourceBodies.apps, { + headers: { 'content-type': 'application/json' }, + }); + const r = await c.specUriPath(o, 'read'); + strictEqual(r, '/apis/apps/v1/namespaces/fugazi/deployments/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return a non-namespaced path for non-core resource', async () => { @@ -640,12 +716,24 @@ describe('KubernetesObject', () => { name: 'repeater', }, }; - const scope = nock('https://d.i.y') - .get('/apis/rbac.authorization.k8s.io/v1') - .reply(200, resourceBodies.rbac, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'read'); - strictEqual(r, '/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/apis/rbac.authorization.k8s.io/v1', method: 'GET' }).reply( + 200, + resourceBodies.rbac, + { headers: { 'content-type': 'application/json' } }, + ); + const r = await c.specUriPath(o, 'read'); + strictEqual(r, '/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should handle a variety of resources', async () => { @@ -747,23 +835,37 @@ describe('KubernetesObject', () => { e: '/apis/storage.k8s.io/v1/storageclasses/repeater', }, ]; - for (const k of a) { - const c = KubernetesObjectApiTest.makeApiClient(); - const o: KubernetesObject = { - apiVersion: k.apiVersion, - kind: k.kind, - metadata: { - name: 'repeater', - }, - }; - if (k.ns) { - o.metadata = o.metadata || {}; - o.metadata.namespace = 'fugazi'; + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const k of a) { + pool.intercept({ path: k.p, method: 'GET' }).reply(200, k.b, { + headers: { 'content-type': 'application/json' }, + }); } - const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'patch'); - strictEqual(r, k.e); - scope.done(); + for (const k of a) { + const c = KubernetesObjectApiTest.makeApiClient(); + const o: KubernetesObject = { + apiVersion: k.apiVersion, + kind: k.kind, + metadata: { + name: 'repeater', + }, + }; + if (k.ns) { + o.metadata = o.metadata || {}; + o.metadata.namespace = 'fugazi'; + } + const r = await c.specUriPath(o, 'patch'); + strictEqual(r, k.e); + } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -866,19 +968,33 @@ describe('KubernetesObject', () => { e: '/apis/storage.k8s.io/v1/storageclasses', }, ]; - for (const k of a) { - const c = KubernetesObjectApiTest.makeApiClient(); - const o: KubernetesObject = { - apiVersion: k.apiVersion, - kind: k.kind, - }; - if (k.ns) { - o.metadata = { namespace: 'fugazi' }; + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const k of a) { + pool.intercept({ path: k.p, method: 'GET' }).reply(200, k.b, { + headers: { 'content-type': 'application/json' }, + }); } - const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader); - const r = await c.specUriPath(o, 'create'); - strictEqual(r, k.e); - scope.done(); + for (const k of a) { + const c = KubernetesObjectApiTest.makeApiClient(); + const o: KubernetesObject = { + apiVersion: k.apiVersion, + kind: k.kind, + }; + if (k.ns) { + o.metadata = { namespace: 'fugazi' }; + } + const r = await c.specUriPath(o, 'create'); + strictEqual(r, k.e); + } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -906,15 +1022,24 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - - await rejects(c.specUriPath(o, 'read'), { - name: 'Error', - message: 'Required spec property name is not set', - }); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + await rejects(c.specUriPath(o, 'read'), { + name: 'Error', + message: 'Required spec property name is not set', + }); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should throw an error if resource is not valid', async () => { @@ -927,15 +1052,24 @@ describe('KubernetesObject', () => { namespace: 'fugazi', }, }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - - await rejects(c.specUriPath(o, 'create'), { - name: 'Error', - message: 'Unrecognized API version and kind: v1 Ingress', - }); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + await rejects(c.specUriPath(o, 'create'), { + name: 'Error', + message: 'Unrecognized API version and kind: v1 Ingress', + }); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); @@ -979,51 +1113,71 @@ describe('KubernetesObject', () => { }, }); - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - await c.resource('v1', 'Service'); - strictEqual(preMiddlewareCalled, true); - strictEqual(postMiddlewareCalled, true); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + await c.resource('v1', 'Service'); + strictEqual(preMiddlewareCalled, true); + strictEqual(postMiddlewareCalled, true); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should cache API response', async () => { const c = KubernetesObjectApiTest.makeApiClient(); - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const s = await c.resource('v1', 'Service'); - if (!s) { - throw new Error('old TypeScript compiler'); - } - strictEqual(s.kind, 'Service'); - strictEqual(s.name, 'services'); - strictEqual(s.namespaced, true); - ok(c.apiVersionResourceCache); - ok(c.apiVersionResourceCache.v1); - const sa = await c.resource('v1', 'ServiceAccount'); - if (!sa) { - throw new Error('old TypeScript compiler'); - } - strictEqual(sa.kind, 'ServiceAccount'); - strictEqual(sa.name, 'serviceaccounts'); - strictEqual(sa.namespaced, true); - const p = await c.resource('v1', 'Pod'); - if (!p) { - throw new Error('old TypeScript compiler'); - } - strictEqual(p.kind, 'Pod'); - strictEqual(p.name, 'pods'); - strictEqual(p.namespaced, true); - const pv = await c.resource('v1', 'PersistentVolume'); - if (!pv) { - throw new Error('old TypeScript compiler'); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const s = await c.resource('v1', 'Service'); + if (!s) { + throw new Error('old TypeScript compiler'); + } + strictEqual(s.kind, 'Service'); + strictEqual(s.name, 'services'); + strictEqual(s.namespaced, true); + ok(c.apiVersionResourceCache); + ok(c.apiVersionResourceCache.v1); + const sa = await c.resource('v1', 'ServiceAccount'); + if (!sa) { + throw new Error('old TypeScript compiler'); + } + strictEqual(sa.kind, 'ServiceAccount'); + strictEqual(sa.name, 'serviceaccounts'); + strictEqual(sa.namespaced, true); + const p = await c.resource('v1', 'Pod'); + if (!p) { + throw new Error('old TypeScript compiler'); + } + strictEqual(p.kind, 'Pod'); + strictEqual(p.name, 'pods'); + strictEqual(p.namespaced, true); + const pv = await c.resource('v1', 'PersistentVolume'); + if (!pv) { + throw new Error('old TypeScript compiler'); + } + strictEqual(pv.kind, 'PersistentVolume'); + strictEqual(pv.name, 'persistentvolumes'); + strictEqual(pv.namespaced, false); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } - strictEqual(pv.kind, 'PersistentVolume'); - strictEqual(pv.name, 'persistentvolumes'); - strictEqual(pv.namespaced, false); - scope.done(); }); it('should re-request on cache miss', async () => { @@ -1044,23 +1198,33 @@ describe('KubernetesObject', () => { }, ], } as any; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader); - const s = await c.resource('v1', 'Service'); - if (!s) { - throw new Error('old TypeScript compiler'); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + const s = await c.resource('v1', 'Service'); + if (!s) { + throw new Error('old TypeScript compiler'); + } + strictEqual(s.kind, 'Service'); + strictEqual(s.name, 'services'); + strictEqual(s.namespaced, true); + ok(c.apiVersionResourceCache); + ok(c.apiVersionResourceCache.v1); + strictEqual( + c.apiVersionResourceCache.v1.resources.length, + JSON.parse(resourceBodies.core).resources.length, + ); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } - strictEqual(s.kind, 'Service'); - strictEqual(s.name, 'services'); - strictEqual(s.namespaced, true); - ok(c.apiVersionResourceCache); - ok(c.apiVersionResourceCache.v1); - strictEqual( - c.apiVersionResourceCache.v1.resources.length, - JSON.parse(resourceBodies.core).resources.length, - ); - scope.done(); }); }); @@ -1224,14 +1388,26 @@ describe('KubernetesObject', () => { }`, }, ]; - for (const m of methods) { - const scope = nock('https://d.i.y') - .intercept(m.p, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s) - .reply(m.c, m.b, contentTypeJsonHeader); - // TODO: Figure out why Typescript barfs if we do m.call - const hack_m = m.m as any; - await hack_m.call(client, s); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const m of methods) { + pool.intercept({ path: m.p, method: m.v }).reply(m.c, m.b, { + headers: { 'content-type': 'application/json' }, + }); + } + for (const m of methods) { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s); + } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -1383,20 +1559,30 @@ describe('KubernetesObject', () => { }`, }, ]; - for (const p of ['true', 'false']) { - for (const m of methods) { - const scope = nock('https://d.i.y') - .intercept( - `${m.p}?pretty=${p}`, - m.v, - m.v === 'DELETE' || m.v === 'GET' ? undefined : s, - ) - .reply(m.c, m.b, contentTypeJsonHeader); - // TODO: Figure out why Typescript barfs if we do m.call - const hack_m = m.m as any; - await hack_m.call(client, s, p); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const p of ['true', 'false']) { + for (const m of methods) { + pool.intercept({ path: `${m.p}?pretty=${p}`, method: m.v }).reply(m.c, m.b, { + headers: { 'content-type': 'application/json' }, + }); + } + } + for (const p of ['true', 'false']) { + for (const m of methods) { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s, p); + } } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -1512,14 +1698,26 @@ describe('KubernetesObject', () => { }`, }, ]; - for (const m of methods) { - const scope = nock('https://d.i.y') - .intercept(`${m.p}?dryRun=All`, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s) - .reply(m.c, m.b, contentTypeJsonHeader); - // TODO: Figure out why Typescript barfs if we do m.call - const hack_m = m.m as any; - await hack_m.call(client, s, undefined, 'All'); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const m of methods) { + pool.intercept({ path: `${m.p}?dryRun=All`, method: m.v }).reply(m.c, m.b, { + headers: { 'content-type': 'application/json' }, + }); + } + for (const m of methods) { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, s, undefined, 'All'); + } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -1550,32 +1748,6 @@ describe('KubernetesObject', () => { ], }, }; - const serializedNetPol = { - apiVersion: 'networking.k8s.io/v1', - kind: 'NetworkPolicy', - metadata: { - name: 'k8s-js-client-test', - namespace: 'default', - }, - spec: { - podSelector: { - matchLabels: { - app: 'my-app', - }, - }, - policyTypes: ['Ingress'], - ingress: [ - { - from: [ - { - podSelector: { matchLabels: { app: 'foo' } }, - }, - ], - ports: [{ port: 123 }], - }, - ], - }, - }; const returnBody = `{ "kind": "NetworkPolicy", "apiVersion": "networking.k8s.io/v1", @@ -1631,14 +1803,27 @@ describe('KubernetesObject', () => { b: returnBody, }, ]; - for (const m of methods) { - const scope = nock('https://d.i.y') - .intercept(m.p, m.v, serializedNetPol) - .reply(m.c, m.b, contentTypeJsonHeader); - // TODO: Figure out why Typescript barfs if we do m.call - const hack_m = m.m as any; - await hack_m.call(client, netPol); - scope.done(); + const originalDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + for (const m of methods) { + // Use regex to verify the body contains 'from' (not '_from' - testing TypeScript→JSON serialization) + pool.intercept({ path: m.p, method: m.v, body: /"from":/ }).reply(m.c, m.b, { + headers: { 'content-type': 'application/json' }, + }); + } + for (const m of methods) { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m.m as any; + await hack_m.call(client, netPol); + } + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); } }); @@ -1666,9 +1851,16 @@ describe('KubernetesObject', () => { }, }, }; - const scope = nock('https://d.i.y') - .post('/api/v1/namespaces/default/services?fieldManager=ManageField', s) - .reply( + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ + path: '/api/v1/namespaces/default/services?fieldManager=ManageField', + method: 'POST', + }).reply( 201, `{ "kind": "Service", @@ -1703,43 +1895,12 @@ describe('KubernetesObject', () => { "loadBalancer": {} } }`, - contentTypeJsonHeader, - ) - .put('/api/v1/namespaces/default/services/k8s-js-client-test?pretty=true', { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'k8s-js-client-test', - namespace: 'default', - selfLink: '/api/v1/namespaces/default/services/k8s-js-client-test', - uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', - resourceVersion: '41183', - creationTimestamp: '2020-05-11T19:35:01.000Z', - annotations: { - owner: 'test', - test: '1', - }, - }, - spec: { - ports: [ - { - protocol: 'TCP', - port: 80, - targetPort: 80, - }, - ], - selector: { - app: 'sleep', - }, - clusterIP: '10.106.153.133', - type: 'ClusterIP', - sessionAffinity: 'None', - }, - status: { - loadBalancer: {}, - }, - }) - .reply( + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ + path: '/api/v1/namespaces/default/services/k8s-js-client-test?pretty=true', + method: 'PUT', + }).reply( 200, `{ "kind": "Service", @@ -1775,12 +1936,12 @@ describe('KubernetesObject', () => { "loadBalancer": {} } }`, - contentTypeJsonHeader, - ) - .delete( - '/api/v1/namespaces/default/services/k8s-js-client-test?gracePeriodSeconds=7&propagationPolicy=Foreground', - ) - .reply( + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ + path: '/api/v1/namespaces/default/services/k8s-js-client-test?gracePeriodSeconds=7&propagationPolicy=Foreground', + method: 'DELETE', + }).reply( 200, `{ "apiVersion": "v1", @@ -1793,26 +1954,38 @@ describe('KubernetesObject', () => { "metadata": {}, "status": "Success" }`, - contentTypeJsonHeader, + { headers: { 'content-type': 'application/json' } }, ); - const c = await client.create(s, undefined, undefined, 'ManageField'); - (c.metadata.annotations as Record).test = '1'; - const r = await client.replace(c, 'true'); - strictEqual((r.metadata.annotations as Record).test, '1'); - ok( - parseInt((r.metadata as any).resourceVersion, 10) > - parseInt((c.metadata as any).resourceVersion, 10), - ); - await client.delete(s, undefined, undefined, 7, undefined, 'Foreground'); - scope.done(); + + const c = await client.create(s, undefined, undefined, 'ManageField'); + (c.metadata.annotations as Record).test = '1'; + const r = await client.replace(c, 'true'); + strictEqual((r.metadata.annotations as Record).test, '1'); + ok( + parseInt((r.metadata as any).resourceVersion, 10) > + parseInt((c.metadata as any).resourceVersion, 10), + ); + await client.delete(s, undefined, undefined, 7, undefined, 'Foreground'); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } }); it('should read a resource', async () => { - const scope = nock('https://d.i.y') - .get('/api/v1/namespaces/default/secrets/test-secret-1') - .reply( + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ + path: '/api/v1/namespaces/default/secrets/test-secret-1', + method: 'GET', + }).reply( 200, - { + JSON.stringify({ apiVersion: 'v1', kind: 'Secret', metadata: { @@ -1821,240 +1994,262 @@ describe('KubernetesObject', () => { uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', creationTimestamp: '2022-01-01T00:00:00.000Z', }, - data: { - key: 'value', - }, - }, - contentTypeJsonHeader, + data: { key: 'value' }, + }), + { headers: { 'content-type': 'application/json' } }, ); - const secret = await client.read({ - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: 'test-secret-1', - namespace: 'default', - }, - }); - strictEqual(secret instanceof V1Secret, true); - deepStrictEqual(secret.data, { - key: 'value', - }); - ok(secret.metadata); - deepStrictEqual(secret.metadata!.creationTimestamp, new Date('2022-01-01T00:00:00.000Z')); - scope.done(); - }); + const secret = await client.read({ + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: 'test-secret-1', namespace: 'default' }, + }); + strictEqual(secret instanceof V1Secret, true); + deepStrictEqual(secret.data, { key: 'value' }); + ok(secret.metadata); + deepStrictEqual(secret.metadata!.creationTimestamp, new Date('2022-01-01T00:00:00.000Z')); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } - it('should read a custom resource', async () => { - interface CustomTestResource extends KubernetesObject { - spec: { - key: string; + it('should read a custom resource', async () => { + interface CustomTestResource extends KubernetesObject { + spec: { + key: string; + }; + } + (client as any).apiVersionResourceCache['example.com/v1'] = { + groupVersion: 'example.com/v1', + kind: 'APIResourceList', + resources: [ + { + kind: 'CustomTestResource', + name: 'customtestresources', + namespaced: true, + }, + ], }; - } - (client as any).apiVersionResourceCache['example.com/v1'] = { - groupVersion: 'example.com/v1', - kind: 'APIResourceList', - resources: [ - { - kind: 'CustomTestResource', - name: 'customtestresources', - namespaced: true, - }, - ], - }; - const scope = nock('https://d.i.y') - .get('/apis/example.com/v1/namespaces/default/customtestresources/test-1') - .reply( - 200, - { + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ + path: '/apis/example.com/v1/namespaces/default/customtestresources/test-1', + method: 'GET', + }).reply( + 200, + JSON.stringify({ + apiVersion: 'example.com/v1', + kind: 'CustomTestResource', + metadata: { + name: 'test-1', + namespace: 'default', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + creationTimestamp: '2022-01-01T00:00:00.000Z', + }, + spec: { key: 'value' }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + const custom = await client.read({ apiVersion: 'example.com/v1', kind: 'CustomTestResource', - metadata: { - name: 'test-1', - namespace: 'default', - uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', - creationTimestamp: '2022-01-01T00:00:00.000Z', - }, - spec: { - key: 'value', - }, - }, - contentTypeJsonHeader, - ); - const custom = await client.read({ - apiVersion: 'example.com/v1', - kind: 'CustomTestResource', - metadata: { - name: 'test-1', - namespace: 'default', - }, - }); - deepStrictEqual(custom.spec, { - key: 'value', + metadata: { name: 'test-1', namespace: 'default' }, + }); + deepStrictEqual(custom.spec, { key: 'value' }); + ok(custom.metadata); + deepStrictEqual(custom.metadata!.creationTimestamp, new Date('2022-01-01T00:00:00.000Z')); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } }); - ok(custom.metadata); - deepStrictEqual(custom.metadata!.creationTimestamp, new Date('2022-01-01T00:00:00.000Z')); - scope.done(); - }); - it('should list resources in a namespace', async () => { - const scope = nock('https://d.i.y') - .get('/api/v1/namespaces/default/secrets') - .reply( - 200, - { - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: 'test-secret-1', - uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + it('should list resources in a namespace', async () => { + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1/namespaces/default/secrets', method: 'GET' }).reply( + 200, + JSON.stringify({ + apiVersion: 'v1', + kind: 'SecretList', + items: [ + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + }, }, - }, - ], - metadata: { - resourceVersion: '216532459', - continue: 'abc', - }, - }, - contentTypeJsonHeader, - ); - const lr = await client.list('v1', 'Secret', 'default'); - const items = lr.items; - strictEqual(items.length, 1); - strictEqual(items[0] instanceof V1Secret, true); - scope.done(); - }); + ], + metadata: { resourceVersion: '216532459', continue: 'abc' }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + const lr = await client.list('v1', 'Secret', 'default'); + const items = lr.items; + strictEqual(items.length, 1); + strictEqual(items[0] instanceof V1Secret, true); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } + }); - it('should list resources in all namespaces', async () => { - const scope = nock('https://d.i.y') - .get( - '/api/v1/secrets?fieldSelector=metadata.name%3Dtest-secret1&labelSelector=app%3Dmy-app&limit=5', - ) - .reply( - 200, - { - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: 'test-secret-1', - uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + it('should list resources in all namespaces', async () => { + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ + path: '/api/v1/secrets?fieldSelector=metadata.name%3Dtest-secret1&labelSelector=app%3Dmy-app&limit=5', + method: 'GET', + }).reply( + 200, + JSON.stringify({ + apiVersion: 'v1', + kind: 'SecretList', + items: [ + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'test-secret-1', + uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821', + }, }, - }, - ], - metadata: { - resourceVersion: '216532459', - continue: 'abc', - }, - }, - contentTypeJsonHeader, - ); - const lr = await client.list( - 'v1', - 'Secret', - undefined, - undefined, - undefined, - undefined, - 'metadata.name=test-secret1', - 'app=my-app', - 5, - ); - const items = lr.items; - strictEqual(items.length, 1); - scope.done(); + ], + metadata: { resourceVersion: '216532459', continue: 'abc' }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + const lr = await client.list( + 'v1', + 'Secret', + undefined, + undefined, + undefined, + undefined, + 'metadata.name=test-secret1', + 'app=my-app', + 5, + ); + const items = lr.items; + strictEqual(items.length, 1); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } + }); }); - }); - describe('errors', () => { - let client: KubernetesObjectApi; - before(() => { - const kc = new KubeConfig(); - kc.loadFromOptions(testConfigOptions); - client = KubernetesObjectApi.makeApiClient(kc); - }); + describe('errors', () => { + let client: KubernetesObjectApi; + before(() => { + const kc = new KubeConfig(); + kc.loadFromOptions(testConfigOptions); + client = KubernetesObjectApi.makeApiClient(kc); + }); - it('should throw error if no spec', async () => { - const methods = [client.create, client.patch, client.read, client.replace, client.delete]; - for (const s of [null, undefined]) { - for (const m of methods) { - // TODO: Figure out why Typescript barfs if we do m.call - const hack_m = m as any; - await rejects(hack_m.call(client, s), { - name: 'Error', - message: /Required parameter spec was null or undefined when calling /, - }); + it('should throw error if no spec', async () => { + const methods = [client.create, client.patch, client.read, client.replace, client.delete]; + for (const s of [null, undefined]) { + for (const m of methods) { + // TODO: Figure out why Typescript barfs if we do m.call + const hack_m = m as any; + await rejects(hack_m.call(client, s), { + name: 'Error', + message: /Required parameter spec was null or undefined when calling /, + }); + } } - } - }); + }); - it('should throw an error if request throws an error', async () => { - const s = { - apiVersion: 'v1', - kind: 'Service', - metadata: { - name: 'valid-name', - namespace: 'default', - }, - spec: { - ports: [ - { - port: 80, - protocol: 'TCP', - targetPort: 80, + it('should throw an error if request throws an error', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: 'valid-name', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', }, - ], - selector: { - app: 'sleep', }, - }, - }; - nock('https://d.i.y'); - await rejects(client.read(s), { - code: 'ERR_NOCK_NO_MATCH', - message: /Nock: No match for request/, + }; + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + try { + await rejects(client.read(s)); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } }); - }); - it('should throw an error if name not valid', async () => { - const s = { - apiVersion: 'v1', - kind: 'Service', - metadata: { - name: '_not_a_valid_name_', - namespace: 'default', - }, - spec: { - ports: [ - { - port: 80, - protocol: 'TCP', - targetPort: 80, + it('should throw an error if name not valid', async () => { + const s = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: '_not_a_valid_name_', + namespace: 'default', + }, + spec: { + ports: [ + { + port: 80, + protocol: 'TCP', + targetPort: 80, + }, + ], + selector: { + app: 'sleep', }, - ], - selector: { - app: 'sleep', }, - }, - }; - const scope = nock('https://d.i.y') - .get('/api/v1') - .reply(200, resourceBodies.core, contentTypeJsonHeader) - .post('/api/v1/namespaces/default/services', s) - .reply( - 422, - `{ + }; + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/api/v1', method: 'GET' }).reply(200, resourceBodies.core, { + headers: { 'content-type': 'application/json' }, + }); + pool.intercept({ path: '/api/v1/namespaces/default/services', method: 'POST' }).reply( + 422, + `{ "kind": "Status", "apiVersion": "v1", "metadata": {}, "status": "Failure", - "message": "Service "_not_a_valid_name_" is invalid: metadata.name: Invalid value: "_not_a_valid_name_": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", + "message": "Service "_not_a_valid_name_" is invalid: metadata.name: Invalid value: "_not_a_valid_name_": a DNS-1035 label must consist of lower case alphanumeric characters or '\x2d', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", "reason": "Invalid", "details": { "name": "_not_a_valid_name_", @@ -2062,79 +2257,94 @@ describe('KubernetesObject', () => { "causes": [ { "reason": "FieldValueInvalid", - "message": "Invalid value: "_not_a_valid_name_": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", + "message": "Invalid value: "_not_a_valid_name_": a DNS-1035 label must consist of lower case alphanumeric characters or '\x2d', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')", "field": "metadata.name" } ] }, "code": 422 }`, - contentTypeJsonHeader, - ); + { headers: { 'content-type': 'application/json' } }, + ); - await rejects(client.create(s), { - name: 'Error', - code: 422, + await rejects(client.create(s), { + name: 'Error', + code: 422, + }); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } }); - scope.done(); - }); - it('should throw an error if apiVersion not valid', async () => { - const d = { - apiVersion: 'applications/v1', - kind: 'Deployment', - metadata: { - name: 'should-not-be-created', - namespace: 'default', - }, - spec: { - selector: { - matchLabels: { - app: 'sleep', - }, + it('should throw an error if apiVersion not valid', async () => { + const d = { + apiVersion: 'applications/v1', + kind: 'Deployment', + metadata: { + name: 'should-not-be-created', + namespace: 'default', }, - template: { - metadata: { - labels: { + spec: { + selector: { + matchLabels: { app: 'sleep', }, }, - spec: { - containers: [ - { - args: ['60'], - command: ['sleep'], - image: 'alpine', - name: 'sleep', - ports: [{ containerPort: 80 }], + template: { + metadata: { + labels: { + app: 'sleep', }, - ], + }, + spec: { + containers: [ + { + args: ['60'], + command: ['sleep'], + image: 'alpine', + name: 'sleep', + ports: [{ containerPort: 80 }], + }, + ], + }, }, }, - }, - }; - const scope = nock('https://d.i.y') - .get('/apis/applications/v1') - .reply(404, '{}', contentTypeJsonHeader); - await rejects(client.create(d), { - name: 'Error', - code: 404, - message: /Failed to fetch resource metadata for applications\/v1\/Deployment/, + }; + const origDispatcher = getGlobalDispatcher(); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + const pool = mockAgent.get('https://d.i.y'); + try { + pool.intercept({ path: '/apis/applications/v1', method: 'GET' }).reply(404, '{}', { + headers: { 'content-type': 'application/json' }, + }); + await rejects(client.create(d), { + name: 'Error', + code: 404, + message: /Failed to fetch resource metadata for applications\/v1\/Deployment/, + }); + } finally { + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + setGlobalDispatcher(origDispatcher); + } }); - scope.done(); - }); - it('should throw error if no apiVersion', async () => { - await rejects((client.list as any)(undefined, undefined), { - name: 'Error', - message: 'Required parameter apiVersion was null or undefined when calling list.', + it('should throw error if no apiVersion', async () => { + await rejects((client.list as any)(undefined, undefined), { + name: 'Error', + message: 'Required parameter apiVersion was null or undefined when calling list.', + }); }); - }); - it('should throw error if no kind', async () => { - await rejects((client.list as any)('', undefined), { - name: 'Error', - message: 'Required parameter kind was null or undefined when calling list.', + it('should throw error if no kind', async () => { + await rejects((client.list as any)('', undefined), { + name: 'Error', + message: 'Required parameter kind was null or undefined when calling list.', + }); }); }); }); diff --git a/src/top_test.ts b/src/top_test.ts index bccf5c10f9..ad28dd7087 100644 --- a/src/top_test.ts +++ b/src/top_test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import { deepEqual, deepStrictEqual, strictEqual } from 'assert'; -import nock from 'nock'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { KubeConfig } from './config.js'; import { Metrics, PodMetricsList } from './metrics.js'; import { CurrentResourceUsage, ResourceUsage, topNodes, topPods } from './top.js'; @@ -176,7 +176,7 @@ const testConfigOptions: any = { const systemUnderTest = ( namespace?: string, options: any = testConfigOptions, -): [() => ReturnType, () => ReturnType, nock.Scope] => { +): [() => ReturnType, () => ReturnType, MockAgent] => { const kc = new KubeConfig(); kc.loadFromOptions(options); const metricsClient = new Metrics(kc); @@ -184,258 +184,362 @@ const systemUnderTest = ( const topPodsFunc = () => topPods(core, metricsClient, namespace); const topNodesFunc = () => topNodes(core); - const scope = nock(testConfigOptions.clusters[0].server); + const mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); - return [topPodsFunc, topNodesFunc, scope]; + return [topPodsFunc, topNodesFunc, mockAgent]; }; describe('Top', () => { describe('topPods', () => { it('should return empty when no pods', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, emptyPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: [], - }); - const result = await topPodsFunc(); - deepStrictEqual(result, []); - podMetrics.done(); - pods.done(); + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(emptyPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: [] }), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const result = await topPodsFunc(); + deepStrictEqual(result, []); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return use cluster scope when namespace empty string', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(''); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, emptyPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: [], - }); - const result = await topPodsFunc(); - deepStrictEqual(result, []); - podMetrics.done(); - pods.done(); + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(''); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(emptyPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: [] }), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const result = await topPodsFunc(); + deepStrictEqual(result, []); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return cluster wide pod metrics', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, mockedPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: podList, - }); - const result = await topPodsFunc(); - strictEqual(result.length, 2); - deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0.1, 0.1)); - deepStrictEqual( - result[0].Memory, - new CurrentResourceUsage(BigInt('4005888'), BigInt('104857600'), BigInt('104857600')), + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(mockedPodMetrics), + { headers: { 'content-type': 'application/json' } }, ); - deepEqual(result[0].Containers, [ - { - CPUUsage: { - CurrentUsage: 0.05, - LimitTotal: 0.1, - RequestTotal: 0.1, - }, - Container: 'nginx', - MemoryUsage: { - CurrentUsage: BigInt('4005888'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('104857600'), - }, - }, - ]); - deepStrictEqual(result[1].CPU, new CurrentResourceUsage(1.4, 2.1, 2.1)); - deepStrictEqual( - result[1].Memory, - new CurrentResourceUsage(BigInt('7192576'), BigInt('157286400'), BigInt('209715200')), + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: podList }), + { headers: { 'content-type': 'application/json' } }, ); - deepEqual(result[1].Containers, [ - { - CPUUsage: { - CurrentUsage: 0, - LimitTotal: 0.1, - RequestTotal: 0.1, - }, - Container: 'nginx', - MemoryUsage: { - CurrentUsage: BigInt('4108288'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('104857600'), + try { + const result = await topPodsFunc(); + strictEqual(result.length, 2); + deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0.1, 0.1)); + deepStrictEqual( + result[0].Memory, + new CurrentResourceUsage(BigInt('4005888'), BigInt('104857600'), BigInt('104857600')), + ); + deepEqual(result[0].Containers, [ + { + CPUUsage: { + CurrentUsage: 0.05, + LimitTotal: 0.1, + RequestTotal: 0.1, + }, + Container: 'nginx', + MemoryUsage: { + CurrentUsage: BigInt('4005888'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('104857600'), + }, }, - }, - { - CPUUsage: { - CurrentUsage: 1.4, - LimitTotal: 2, - RequestTotal: 2, + ]); + deepStrictEqual(result[1].CPU, new CurrentResourceUsage(1.4, 2.1, 2.1)); + deepStrictEqual( + result[1].Memory, + new CurrentResourceUsage(BigInt('7192576'), BigInt('157286400'), BigInt('209715200')), + ); + deepEqual(result[1].Containers, [ + { + CPUUsage: { + CurrentUsage: 0, + LimitTotal: 0.1, + RequestTotal: 0.1, + }, + Container: 'nginx', + MemoryUsage: { + CurrentUsage: BigInt('4108288'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('104857600'), + }, }, - Container: 'sidecar', - MemoryUsage: { - CurrentUsage: BigInt('3084288'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('52428800'), + { + CPUUsage: { + CurrentUsage: 1.4, + LimitTotal: 2, + RequestTotal: 2, + }, + Container: 'sidecar', + MemoryUsage: { + CurrentUsage: BigInt('3084288'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('52428800'), + }, }, - }, - ]); - podMetrics.done(); - pods.done(); + ]); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return best effort pod metrics', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, mockedPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: bestEffortPodList, - }); - const result = await topPodsFunc(); - strictEqual(result.length, 1); - deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0, 0)); - deepStrictEqual(result[0].Memory, new CurrentResourceUsage(BigInt('4005888'), 0, 0)); - deepEqual(result[0].Containers, [ - { - CPUUsage: { - CurrentUsage: 0.05, - LimitTotal: 0, - RequestTotal: 0, - }, - Container: 'nginx', - MemoryUsage: { - CurrentUsage: BigInt('4005888'), - LimitTotal: 0, - RequestTotal: 0, + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(mockedPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: bestEffortPodList }), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const result = await topPodsFunc(); + strictEqual(result.length, 1); + deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0, 0)); + deepStrictEqual(result[0].Memory, new CurrentResourceUsage(BigInt('4005888'), 0, 0)); + deepEqual(result[0].Containers, [ + { + CPUUsage: { + CurrentUsage: 0.05, + LimitTotal: 0, + RequestTotal: 0, + }, + Container: 'nginx', + MemoryUsage: { + CurrentUsage: BigInt('4005888'), + LimitTotal: 0, + RequestTotal: 0, + }, }, - }, - ]); - podMetrics.done(); - pods.done(); + ]); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return 0 when pod metrics missing', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, emptyPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: podList, - }); - const result = await topPodsFunc(); - strictEqual(result.length, 2); - deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0, 0.1, 0.1)); - deepStrictEqual( - result[0].Memory, - new CurrentResourceUsage(0, BigInt('104857600'), BigInt('104857600')), + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(emptyPodMetrics), + { headers: { 'content-type': 'application/json' } }, ); - deepStrictEqual(result[0].Containers, []); - deepStrictEqual(result[1].CPU, new CurrentResourceUsage(0, 2.1, 2.1)); - deepStrictEqual( - result[1].Memory, - new CurrentResourceUsage(0, BigInt('157286400'), BigInt('209715200')), + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: podList }), + { headers: { 'content-type': 'application/json' } }, ); - deepStrictEqual(result[1].Containers, []); - podMetrics.done(); - pods.done(); + try { + const result = await topPodsFunc(); + strictEqual(result.length, 2); + deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0, 0.1, 0.1)); + deepStrictEqual( + result[0].Memory, + new CurrentResourceUsage(0, BigInt('104857600'), BigInt('104857600')), + ); + deepStrictEqual(result[0].Containers, []); + deepStrictEqual(result[1].CPU, new CurrentResourceUsage(0, 2.1, 2.1)); + deepStrictEqual( + result[1].Memory, + new CurrentResourceUsage(0, BigInt('157286400'), BigInt('209715200')), + ); + deepStrictEqual(result[1].Containers, []); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return empty array when pods missing', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(); - const podMetrics = scope.get('/apis/metrics.k8s.io/v1beta1/pods').reply(200, mockedPodMetrics); - const pods = scope.get('/api/v1/pods').reply(200, { - items: [], - }); - const result = await topPodsFunc(); - strictEqual(result.length, 0); - podMetrics.done(); - pods.done(); + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/apis/metrics.k8s.io/v1beta1/pods', method: 'GET' }).reply( + 200, + JSON.stringify(mockedPodMetrics), + { headers: { 'content-type': 'application/json' } }, + ); + pool.intercept({ path: '/api/v1/pods', method: 'GET' }).reply( + 200, + JSON.stringify({ items: [] }), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const result = await topPodsFunc(); + strictEqual(result.length, 0); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return namespace pod metrics', async () => { - const [topPodsFunc, _, scope] = systemUnderTest(TEST_NAMESPACE); - const podMetrics = scope - .get(`/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`) - .reply(200, mockedPodMetrics); - const pods = scope.get(`/api/v1/namespaces/${TEST_NAMESPACE}/pods`).reply(200, { - items: podList, + const originalDispatcher = getGlobalDispatcher(); + const [topPodsFunc, _, mockAgent] = systemUnderTest(TEST_NAMESPACE); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ + path: `/apis/metrics.k8s.io/v1beta1/namespaces/${TEST_NAMESPACE}/pods`, + method: 'GET', + }).reply(200, JSON.stringify(mockedPodMetrics), { + headers: { 'content-type': 'application/json' }, }); - const result = await topPodsFunc(); - strictEqual(result.length, 2); - deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0.1, 0.1)); - deepStrictEqual( - result[0].Memory, - new CurrentResourceUsage(BigInt('4005888'), BigInt('104857600'), BigInt('104857600')), + pool.intercept({ path: `/api/v1/namespaces/${TEST_NAMESPACE}/pods`, method: 'GET' }).reply( + 200, + JSON.stringify({ items: podList }), + { headers: { 'content-type': 'application/json' } }, ); - deepEqual(result[0].Containers, [ - { - CPUUsage: { - CurrentUsage: 0.05, - LimitTotal: 0.1, - RequestTotal: 0.1, - }, - Container: 'nginx', - MemoryUsage: { - CurrentUsage: BigInt('4005888'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('104857600'), - }, - }, - ]); - deepStrictEqual(result[1].CPU, new CurrentResourceUsage(1.4, 2.1, 2.1)); - deepStrictEqual( - result[1].Memory, - new CurrentResourceUsage(BigInt('7192576'), BigInt('157286400'), BigInt('209715200')), - ); - deepEqual(result[1].Containers, [ - { - CPUUsage: { - CurrentUsage: 0, - LimitTotal: 0.1, - RequestTotal: 0.1, - }, - Container: 'nginx', - MemoryUsage: { - CurrentUsage: BigInt('4108288'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('104857600'), + try { + const result = await topPodsFunc(); + strictEqual(result.length, 2); + deepStrictEqual(result[0].CPU, new CurrentResourceUsage(0.05, 0.1, 0.1)); + deepStrictEqual( + result[0].Memory, + new CurrentResourceUsage(BigInt('4005888'), BigInt('104857600'), BigInt('104857600')), + ); + deepEqual(result[0].Containers, [ + { + CPUUsage: { + CurrentUsage: 0.05, + LimitTotal: 0.1, + RequestTotal: 0.1, + }, + Container: 'nginx', + MemoryUsage: { + CurrentUsage: BigInt('4005888'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('104857600'), + }, }, - }, - { - CPUUsage: { - CurrentUsage: 1.4, - LimitTotal: 2, - RequestTotal: 2, + ]); + deepStrictEqual(result[1].CPU, new CurrentResourceUsage(1.4, 2.1, 2.1)); + deepStrictEqual( + result[1].Memory, + new CurrentResourceUsage(BigInt('7192576'), BigInt('157286400'), BigInt('209715200')), + ); + deepEqual(result[1].Containers, [ + { + CPUUsage: { + CurrentUsage: 0, + LimitTotal: 0.1, + RequestTotal: 0.1, + }, + Container: 'nginx', + MemoryUsage: { + CurrentUsage: BigInt('4108288'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('104857600'), + }, }, - Container: 'sidecar', - MemoryUsage: { - CurrentUsage: BigInt('3084288'), - LimitTotal: BigInt('104857600'), - RequestTotal: BigInt('52428800'), + { + CPUUsage: { + CurrentUsage: 1.4, + LimitTotal: 2, + RequestTotal: 2, + }, + Container: 'sidecar', + MemoryUsage: { + CurrentUsage: BigInt('3084288'), + LimitTotal: BigInt('104857600'), + RequestTotal: BigInt('52428800'), + }, }, - }, - ]); - podMetrics.done(); - pods.done(); + ]); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); describe('topNodes', () => { it('should return empty when no nodes', async () => { - const [_, topNodesFunc, scope] = systemUnderTest(); - const nodes = scope.get('/api/v1/nodes').reply(200, { - items: [], - }); - const result = await topNodesFunc(); - deepStrictEqual(result, []); - nodes.done(); + const originalDispatcher = getGlobalDispatcher(); + const [_, topNodesFunc, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/api/v1/nodes', method: 'GET' }).reply( + 200, + JSON.stringify({ items: [] }), + { headers: { 'content-type': 'application/json' } }, + ); + try { + const result = await topNodesFunc(); + deepStrictEqual(result, []); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); it('should return cluster wide node metrics', async () => { - const [_, topNodesFunc, scope] = systemUnderTest(); - const pods = scope.get('/api/v1/pods').times(2).reply(200, { - items: podList, - }); - const nodes = scope.get('/api/v1/nodes').reply(200, { - items: nodeList, - }); - const result = await topNodesFunc(); - strictEqual(result.length, 2); - deepStrictEqual(result[0].CPU, new ResourceUsage(4, 2.2, 2.2)); - deepStrictEqual( - result[0].Memory, - new ResourceUsage(BigInt('17179869184'), BigInt('262144000'), BigInt('314572800')), + const originalDispatcher = getGlobalDispatcher(); + const [_, topNodesFunc, mockAgent] = systemUnderTest(); + const pool = mockAgent.get(testConfigOptions.clusters[0].server); + pool.intercept({ path: '/api/v1/pods', method: 'GET' }) + .reply(200, JSON.stringify({ items: podList }), { + headers: { 'content-type': 'application/json' }, + }) + .times(2); + pool.intercept({ path: '/api/v1/nodes', method: 'GET' }).reply( + 200, + JSON.stringify({ items: nodeList }), + { headers: { 'content-type': 'application/json' } }, ); - deepStrictEqual(result[1].CPU, new ResourceUsage(8, 0, 0)); - deepStrictEqual(result[1].Memory, new ResourceUsage(BigInt('34359738368'), 0, 0)); - pods.done(); - nodes.done(); + try { + const result = await topNodesFunc(); + strictEqual(result.length, 2); + deepStrictEqual(result[0].CPU, new ResourceUsage(4, 2.2, 2.2)); + deepStrictEqual( + result[0].Memory, + new ResourceUsage(BigInt('17179869184'), BigInt('262144000'), BigInt('314572800')), + ); + deepStrictEqual(result[1].CPU, new ResourceUsage(8, 0, 0)); + deepStrictEqual(result[1].Memory, new ResourceUsage(BigInt('34359738368'), 0, 0)); + mockAgent.assertNoPendingInterceptors(); + } finally { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + } }); }); }); diff --git a/src/util.ts b/src/util.ts index fe6a0a3558..8d81c54263 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,3 @@ -import { Response } from 'node-fetch'; import { CoreV1Api, V1Container, V1Pod } from './gen/index.js'; export async function podsForNode(api: CoreV1Api, nodeName: string): Promise { @@ -151,11 +150,13 @@ export function totalForResource(pod: V1Pod, resource: string): ResourceStatus { return new ResourceStatus(reqTotal, limitTotal, resource); } -// There is a disconnect between the ApiException headers and the response headers from node-fetch -// ApiException expects { [key: string]: string } whereas node-fetch provides: { [key: string]: string[] } +// There is a disconnect between the ApiException headers and the response headers from fetch +// ApiException expects { [key: string]: string } whereas some fetch implementations provide: { [key: string]: string[] } // https://github.com/node-fetch/node-fetch/issues/783 // https://github.com/node-fetch/node-fetch/pull/1757 -export function normalizeResponseHeaders(response: Response): { [key: string]: string } { +export function normalizeResponseHeaders(response: { headers: { entries(): Iterable<[string, string]> } }): { + [key: string]: string; +} { const normalizedHeaders = {}; for (const [key, value] of response.headers.entries()) { diff --git a/testdata/kubeconfig-proxy-url.yaml b/testdata/kubeconfig-proxy-url.yaml index 03ea1a2cdf..ee97c7f4fa 100644 --- a/testdata/kubeconfig-proxy-url.yaml +++ b/testdata/kubeconfig-proxy-url.yaml @@ -29,6 +29,10 @@ clusters: certificate-authority-data: Q0FEQVRA server: http://exampleerror.com name: clusterF + - cluster: + certificate-authority-data: Q0FEQVRA + server: https://example7.com + name: clusterG contexts: - context: @@ -55,6 +59,10 @@ contexts: cluster: clusterF user: userF name: contextF + - context: + cluster: clusterG + user: userG + name: contextG current-context: contextA kind: Config @@ -84,3 +92,7 @@ users: user: client-certificate-data: XVNFUl9DQURBVEE= client-key-data: XVNFUl9DS0RBVEE= + - name: userG + user: + client-certificate-data: XVNFUl9DQURBVEE= + client-key-data: XVNFUl9DS0RBVEE=