diff --git a/.changeset/brave-eggs-impress.md b/.changeset/brave-eggs-impress.md new file mode 100644 index 0000000000..a0df24d8f9 --- /dev/null +++ b/.changeset/brave-eggs-impress.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +'@backstage/plugin-kubernetes-common': minor +'@backstage/plugin-kubernetes': patch +--- + +Kubernetes plugin now gracefully surfaces transport-level errors (like DNS or timeout, or other socket errors) occurring while fetching data. This will be merged into any data that is fetched successfully, fixing a bug where the whole page would be empty if any fetch operation encountered such an error. diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 2161df7c9a..4ec043a8f9 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -5,21 +5,17 @@ ```ts import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { CoreV1Api } from '@kubernetes/client-node'; import { Credentials } from 'aws-sdk'; -import { CustomObjectsApi } from '@kubernetes/client-node'; import type { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { Duration } from 'luxon'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; import type { JsonObject } from '@backstage/types'; -import { KubeConfig } from '@kubernetes/client-node'; import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger } from 'winston'; -import { Metrics } from '@kubernetes/client-node'; import type { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import type { RequestHandler } from 'express'; @@ -262,18 +258,6 @@ export type KubernetesBuilderReturn = Promise<{ serviceLocator: KubernetesServiceLocator; }>; -// @alpha (undocumented) -export class KubernetesClientProvider { - // (undocumented) - getCoreClientByClusterDetails(clusterDetails: ClusterDetails): CoreV1Api; - // (undocumented) - getCustomObjectsClient(clusterDetails: ClusterDetails): CustomObjectsApi; - // (undocumented) - getKubeConfig(clusterDetails: ClusterDetails): KubeConfig; - // (undocumented) - getMetricsClient(clusterDetails: ClusterDetails): Metrics; -} - // @alpha export interface KubernetesClustersSupplier { getClusters(): Promise; diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index c8ff98f592..1e7b704ec5 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -34,7 +34,6 @@ import { ObjectsByEntityRequest, ServiceLocatorMethod, } from '../types/types'; -import { KubernetesClientProvider } from './KubernetesClientProvider'; import { DEFAULT_OBJECTS, KubernetesFanOutHandler, @@ -211,7 +210,6 @@ export class KubernetesBuilder { protected buildFetcher(): KubernetesFetcher { this.fetcher = new KubernetesClientBasedFetcher({ - kubernetesClientProvider: new KubernetesClientProvider(), logger: this.env.logger, }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts deleted file mode 100644 index 0f73d129b3..0000000000 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import '@backstage/backend-common'; -import { KubernetesClientProvider } from './KubernetesClientProvider'; -import { ClusterDetails } from '../types/types'; -import * as https from 'https'; -import mockFs from 'mock-fs'; - -describe('KubernetesClientProvider', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - afterEach(() => { - mockFs.restore(); - }); - - it('can get core client by cluster details', () => { - const sut = new KubernetesClientProvider(); - const getKubeConfig = jest.spyOn(sut, 'getKubeConfig'); - - const clusterDetails: ClusterDetails = { - name: 'cluster-name', - url: 'http://localhost:9999', - serviceAccountToken: 'TOKEN', - authProvider: 'serviceAccount', - }; - const result = sut.getCoreClientByClusterDetails(clusterDetails); - - expect(result.basePath).toBe('http://localhost:9999'); - // These fields aren't on the type but are there - const auth = (result as any).authentications.default; - expect(auth.users[0].token).toBe('TOKEN'); - expect(auth.clusters[0].name).toBe('cluster-name'); - expect(auth.clusters[0].skipTLSVerify).toBe(false); - - expect(getKubeConfig).toHaveBeenCalledTimes(1); - }); - - it('can get custom objects client by cluster details', () => { - const sut = new KubernetesClientProvider(); - const getKubeConfig = jest.spyOn(sut, 'getKubeConfig'); - - const clusterDetails: ClusterDetails = { - name: 'cluster-name', - url: 'http://localhost:9999', - serviceAccountToken: 'TOKEN', - authProvider: 'serviceAccount', - skipTLSVerify: false, - }; - const result = sut.getCustomObjectsClient(clusterDetails); - - expect(result.basePath).toBe('http://localhost:9999'); - // These fields aren't on the type but are there - const auth = (result as any).authentications.default; - expect(auth.users[0].token).toBe('TOKEN'); - expect(auth.clusters[0].name).toBe('cluster-name'); - - expect(getKubeConfig).toHaveBeenCalledTimes(1); - }); - - it('respects caFile', async () => { - mockFs({ - '/path/to/ca.crt': 'my-ca', - }); - const clusterDetails: ClusterDetails = { - name: 'cluster-name', - url: 'https://localhost:9999', - authProvider: 'serviceAccount', - serviceAccountToken: 'TOKEN', - caFile: '/path/to/ca.crt', - }; - const kubeConfig = new KubernetesClientProvider().getKubeConfig( - clusterDetails, - ); - - const options: https.RequestOptions = {}; - await kubeConfig.applytoHTTPSOptions(options); - - expect(options.ca?.toString()).toEqual('my-ca'); - }); -}); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts deleted file mode 100644 index 14c8e422fc..0000000000 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Cluster, - Context, - CoreV1Api, - CustomObjectsApi, - KubeConfig, - Metrics, - User, -} from '@kubernetes/client-node'; -import { ClusterDetails } from '../types/types'; - -/** - * - * @alpha - */ -export class KubernetesClientProvider { - // visible for testing - getKubeConfig(clusterDetails: ClusterDetails): KubeConfig { - const cluster: Cluster = { - name: clusterDetails.name, - server: clusterDetails.url, - skipTLSVerify: clusterDetails.skipTLSVerify || false, - caData: clusterDetails.caData, - caFile: clusterDetails.caFile, - }; - - // TODO configure - const user: User = { - name: 'backstage', - token: clusterDetails.serviceAccountToken, - }; - - const context: Context = { - name: `${clusterDetails.name}`, - user: user.name, - cluster: cluster.name, - }; - - const kc: KubeConfig = new KubeConfig(); - if (clusterDetails.serviceAccountToken) { - kc.loadFromOptions({ - clusters: [cluster], - users: [user], - contexts: [context], - currentContext: context.name, - }); - } else { - kc.loadFromDefault(); - } - - return kc; - } - - getCoreClientByClusterDetails(clusterDetails: ClusterDetails): CoreV1Api { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(CoreV1Api); - } - - getMetricsClient(clusterDetails: ClusterDetails): Metrics { - const kc = this.getKubeConfig(clusterDetails); - - return new Metrics(kc); - } - - getCustomObjectsClient(clusterDetails: ClusterDetails): CustomObjectsApi { - const kc = this.getKubeConfig(clusterDetails); - - return kc.makeApiClient(CustomObjectsApi); - } -} diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index 1af5371e53..17a86eb30d 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -20,8 +20,14 @@ import { CustomResource, FetchResponseWrapper, ObjectFetchParams, + KubernetesServiceLocator, } from '../types/types'; import { KubernetesFanOutHandler } from './KubernetesFanOutHandler'; +import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; const fetchObjectsForService = jest.fn(); const fetchPodMetricsByNamespaces = jest.fn(); @@ -751,6 +757,122 @@ describe('getKubernetesObjectsByEntity', () => { ], }); }); + it('fails when fetcher rejects with a non-FetchError', async () => { + const nonFetchError = new Error('not a fetch error'); + getClustersByEntity.mockResolvedValue({ + clusters: [ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + skipMetricsLookup: true, + }, + ], + }); + fetchObjectsForService.mockRejectedValue(nonFetchError); + + const sut = getKubernetesFanOutHandler([]); + + const result = sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + await expect(result).rejects.toThrow(nonFetchError); + }); + describe('with a real fetcher', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + it('fetch error short-circuits requests to a single cluster, recovering across the fleet', async () => { + const pods = [{ metadata: { name: 'pod-name' } }]; + const services = [{ metadata: { name: 'service-name' } }]; + worker.use( + rest.get('https://works/api/v1/pods', (_, res, ctx) => + res(ctx.json({ items: pods })), + ), + rest.get('https://works/api/v1/services', (_, res, ctx) => + res(ctx.json({ items: services })), + ), + rest.get('https://fails/api/v1/pods', (_, res) => + res.networkError('socket error'), + ), + rest.get('https://fails/api/v1/services', (_, res, ctx) => + res(ctx.json({ items: services })), + ), + ); + const fleet: jest.Mocked = { + getClustersByEntity: jest.fn().mockResolvedValue({ + clusters: [ + { + name: 'works', + url: 'https://works', + authProvider: 'serviceAccount', + serviceAccountToken: 'token', + skipMetricsLookup: true, + }, + { + name: 'fails', + url: 'https://fails', + authProvider: 'serviceAccount', + serviceAccountToken: 'token', + skipMetricsLookup: true, + }, + ], + }), + }; + const logger = getVoidLogger(); + const sut = new KubernetesFanOutHandler({ + logger, + fetcher: new KubernetesClientBasedFetcher({ logger }), + serviceLocator: fleet, + customResources: [], + objectTypesToFetch: [ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + { + group: '', + apiVersion: 'v1', + plural: 'services', + objectType: 'services', + }, + ], + }); + + const result = await sut.getKubernetesObjectsByEntity({ + entity, + auth: {}, + }); + + const expected: ObjectsByEntityResponse = { + items: [ + { + cluster: { name: 'works' }, + resources: [ + { type: 'pods', resources: pods }, + { type: 'services', resources: services }, + ], + podMetrics: [], + errors: [], + }, + { + cluster: { name: 'fails' }, + resources: [], + podMetrics: [], + errors: [ + { + errorType: 'FETCH_ERROR', + message: + 'request to https://fails/api/v1/pods?labelSelector=backstage.io/kubernetes-id=test-component failed, reason: socket error', + }, + ], + }, + ], + }; + expect(result).toStrictEqual(expected); + }); + }); }); describe('getCustomResourcesByEntity', () => { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 1fa9d23837..a738c69bcc 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -274,6 +274,20 @@ export class KubernetesFanOutHandler { namespace, }) .then(result => this.getMetricsForPods(clusterDetailsItem, result)) + .catch( + (e): Promise => + e.name === 'FetchError' + ? Promise.resolve([ + { + errors: [ + { errorType: 'FETCH_ERROR', message: e.message }, + ], + responses: [], + }, + [], + ]) + : Promise.reject(e), + ) .then(r => this.toClusterObjects(clusterDetailsItem, r)); }), ).then(this.toObjectsByEntityResponse); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index c2560e58b0..065416f8c1 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -17,12 +17,16 @@ import { getVoidLogger } from '@backstage/backend-common'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { ObjectToFetch } from '../types/types'; -import { topPods } from '@kubernetes/client-node'; - -jest.mock('@kubernetes/client-node', () => ({ - ...jest.requireActual('@kubernetes/client-node'), - topPods: jest.fn(), -})); +import { + MockedRequest, + RestContext, + ResponseTransformer, + compose, + rest, +} from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import mockFs from 'mock-fs'; const OBJECTS_TO_FETCH = new Set([ { @@ -39,62 +43,95 @@ const OBJECTS_TO_FETCH = new Set([ }, ]); -const POD_METRICS_FIXTURE = { - containers: [], - cpu: { - currentUsage: 100, - limitTotal: 102, - requestTotal: 101, +const POD_METRICS_FIXTURE = [ + { + type: 'podstatus', + resources: [ + { + CPU: { CurrentUsage: 0, LimitTotal: 1, RequestTotal: 0.5 }, + Memory: { + CurrentUsage: 0, + LimitTotal: 1000000000n, + RequestTotal: 512000000n, + }, + }, + ], }, - memory: { - currentUsage: '1000', - limitTotal: '1002', - requestTotal: '1001', - }, - pod: {}, -}; +]; describe('KubernetesFetcher', () => { - describe('fetchObjectsForService', () => { - let clientMock: any; - let kubernetesClientProvider: any; - let sut: KubernetesClientBasedFetcher; + const worker = setupServer(); + setupRequestMockHandlers(worker); - beforeEach(() => { - jest.resetAllMocks(); - clientMock = { - listClusterCustomObject: jest.fn(), - listNamespacedCustomObject: jest.fn(), - addInterceptor: jest.fn(), - }; - - kubernetesClientProvider = { - getCustomObjectsClient: jest.fn(() => clientMock), - }; - - sut = new KubernetesClientBasedFetcher({ - kubernetesClientProvider, - logger: getVoidLogger(), - }); + const labels = (req: MockedRequest): object => { + const selectorParam = req.url.searchParams.get('labelSelector'); + if (selectorParam) { + const [key, value] = selectorParam.split('='); + return { [key]: value }; + } + return {}; + }; + const checkToken = ( + req: MockedRequest, + ctx: RestContext, + token: string, + ): ResponseTransformer => { + switch (req.headers.get('Authorization')) { + case `Bearer ${token}`: + return ctx.status(200); + default: + return compose( + ctx.status(401), + ctx.json({ + kind: 'Status', + apiVersion: 'v1', + code: 401, + }), + ); + } + }; + const withLabels = ( + req: MockedRequest, + ctx: RestContext, + body: T, + ): ResponseTransformer => + ctx.json({ + ...body, + items: body.items.map(item => ({ + ...item, + metadata: { ...item.metadata, labels: labels(req) }, + })), }); + describe('fetchObjectsForService', () => { + let sut: KubernetesClientBasedFetcher; + const logger = getVoidLogger(); + const testErrorResponse = async ( errorResponse: any, expectedResult: any, ) => { - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'pod-name', - }, - }, - ], - }, - }); - - clientMock.listClusterCustomObject.mockRejectedValue(errorResponse); + worker.use( + rest.get('http://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get('http://localhost:9999/api/v1/services', (_, res, ctx) => { + return res( + ctx.status(errorResponse.response.statusCode), + ctx.json({ + kind: 'Status', + apiVersion: 'v1', + status: 'Failure', + code: errorResponse.response.statusCode, + }), + ); + }), + ); const result = await sut.fetchObjectsForService({ serviceId: 'some-service', @@ -118,66 +155,40 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], }, ], }); - - expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(2); - - expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([ - '', - 'v1', - 'pods', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([ - '', - 'v1', - 'services', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect( - kubernetesClientProvider.getCustomObjectsClient.mock.calls.length, - ).toBe(2); }; - it('should return pods, services', async () => { - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'pod-name', - }, - }, - ], - }, + beforeEach(() => { + sut = new KubernetesClientBasedFetcher({ + logger, }); + }); - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'service-name', - }, - }, - ], - }, - }); + it('should return pods, services', async () => { + worker.use( + rest.get('http://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get('http://localhost:9999/api/v1/services', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'service-name' } }], + }), + ), + ), + ); const result = await sut.fetchObjectsForService({ serviceId: 'some-service', @@ -201,6 +212,7 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], @@ -211,77 +223,43 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'service-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], }, ], }); - - expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(2); - - expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([ - '', - 'v1', - 'pods', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([ - '', - 'v1', - 'services', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect( - kubernetesClientProvider.getCustomObjectsClient.mock.calls.length, - ).toBe(2); }); it('should return pods, services and customobjects', async () => { - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'pod-name', - }, - }, - ], - }, - }); - - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'service-name', - }, - }, - ], - }, - }); - - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'something-else', - }, - }, - ], - }, - }); + worker.use( + rest.get('http://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get('http://localhost:9999/api/v1/services', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'service-name' } }], + }), + ), + ), + rest.get( + 'http://localhost:9999/apis/some-group/v2/things', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'something-else' } }], + }), + ), + ), + ); const result = await sut.fetchObjectsForService({ serviceId: 'some-service', @@ -312,6 +290,7 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], @@ -322,6 +301,7 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'service-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], @@ -332,51 +312,13 @@ describe('KubernetesFetcher', () => { { metadata: { name: 'something-else', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, }, }, ], }, ], }); - - expect(clientMock.listClusterCustomObject.mock.calls.length).toBe(3); - - expect(clientMock.listClusterCustomObject.mock.calls[0]).toEqual([ - '', - 'v1', - 'pods', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect(clientMock.listClusterCustomObject.mock.calls[1]).toEqual([ - '', - 'v1', - 'services', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect(clientMock.listClusterCustomObject.mock.calls[2]).toEqual([ - 'some-group', - 'v2', - 'things', - '', - false, - '', - '', - 'backstage.io/kubernetes-id=some-service', - ]); - - expect( - kubernetesClientProvider.getCustomObjectsClient.mock.calls.length, - ).toBe(3); }); // they're in testErrorResponse // eslint-disable-next-line jest/expect-expect @@ -385,109 +327,32 @@ describe('KubernetesFetcher', () => { { response: { statusCode: 400, - request: { - uri: { - pathname: '/some/path', - }, - }, }, }, { errorType: 'BAD_REQUEST', - resourcePath: '/some/path', + resourcePath: '/api/v1/services', statusCode: 400, }, ); }); - // they're in testErrorResponse - // eslint-disable-next-line jest/expect-expect - it('should return pods, unauthorized error', async () => { - await testErrorResponse( - { - response: { - statusCode: 401, - request: { - uri: { - pathname: '/some/path', - }, - }, - }, - }, - { - errorType: 'UNAUTHORIZED_ERROR', - resourcePath: '/some/path', - statusCode: 401, - }, + it('should return pods and unauthorized error, logging a warning', async () => { + const warn = jest.spyOn(logger, 'warn'); + worker.use( + rest.get('http://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get('http://localhost:9999/api/v1/services', (req, res, ctx) => + res(checkToken(req, ctx, 'other-token')), + ), ); - }); - // they're in testErrorResponse - // eslint-disable-next-line jest/expect-expect - it('should return pods, system error', async () => { - await testErrorResponse( - { - response: { - statusCode: 500, - request: { - uri: { - pathname: '/some/path', - }, - }, - }, - }, - { - errorType: 'SYSTEM_ERROR', - resourcePath: '/some/path', - statusCode: 500, - }, - ); - }); - // they're in testErrorResponse - // eslint-disable-next-line jest/expect-expect - it('should return pods, unknown error', async () => { - await testErrorResponse( - { - response: { - statusCode: 900, - request: { - uri: { - pathname: '/some/path', - }, - }, - }, - }, - { - errorType: 'UNKNOWN_ERROR', - resourcePath: '/some/path', - statusCode: 900, - }, - ); - }); - it('should always add a labelSelector query', async () => { - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'pod-name', - }, - }, - ], - }, - }); - clientMock.listClusterCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'service-name', - }, - }, - ], - }, - }); - - await sut.fetchObjectsForService({ + const result = await sut.fetchObjectsForService({ serviceId: 'some-service', clusterDetails: { name: 'cluster1', @@ -500,37 +365,354 @@ describe('KubernetesFetcher', () => { customResources: [], }); - const mockCall = clientMock.listClusterCustomObject.mock.calls[0]; - const actualSelector = mockCall[mockCall.length - 1]; - const expectedSelector = 'backstage.io/kubernetes-id=some-service'; - expect(actualSelector).toBe(expectedSelector); + expect(result).toStrictEqual({ + errors: [ + { + errorType: 'UNAUTHORIZED_ERROR', + resourcePath: '/api/v1/services', + statusCode: 401, + }, + ], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, + }, + }, + ], + }, + ], + }); + expect(warn).toHaveBeenCalledWith( + 'Received 401 status when fetching "/api/v1/services" from cluster "cluster1"; body=[{"kind":"Status","apiVersion":"v1","code":401}]', + ); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect + it('should return pods, system error', async () => { + await testErrorResponse( + { + response: { + statusCode: 500, + }, + }, + { + errorType: 'SYSTEM_ERROR', + resourcePath: '/api/v1/services', + statusCode: 500, + }, + ); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect + it('should return pods, unknown error', async () => { + await testErrorResponse( + { + response: { + statusCode: 900, + }, + }, + { + errorType: 'UNKNOWN_ERROR', + resourcePath: '/api/v1/services', + statusCode: 900, + }, + ); + }); + it('fails on a network error', async () => { + worker.use( + rest.get('http://badurl.does.not.exist/api/v1/pods', (_, res) => + res.networkError('getaddrinfo ENOTFOUND badurl.does.not.exist'), + ), + rest.get( + 'http://badurl.does.not.exist/api/v1/services', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'service-name' } }], + }), + ), + ), + ); + + const result = sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://badurl.does.not.exist', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: OBJECTS_TO_FETCH, + labelSelector: '', + customResources: [], + }); + + await expect(result).rejects.toThrow( + 'getaddrinfo ENOTFOUND badurl.does.not.exist', + ); + }); + it('should respect labelSelector', async () => { + worker.use( + rest.get('http://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get('http://localhost:9999/api/v1/services', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'service-name' } }], + }), + ), + ), + ); + + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: OBJECTS_TO_FETCH, + labelSelector: 'service-label=value', + customResources: [], + }); + + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + labels: { 'service-label': 'value' }, + }, + }, + ], + }, + { + type: 'services', + resources: [ + { + metadata: { + name: 'service-name', + labels: { 'service-label': 'value' }, + }, + }, + ], + }, + ], + }); + }); + describe('when server uses TLS', () => { + let httpsRequest: jest.SpyInstance; + beforeAll(() => { + httpsRequest = jest.spyOn( + // this is pretty egregious reverse engineering of msw. + // If the SetupServerApi constructor was exported, we wouldn't need + // to be quite so hacky here + (worker as any).interceptor.interceptors[0].modules.get('https'), + 'request', + ); + }); + beforeEach(() => { + httpsRequest.mockClear(); + }); + it('should trust specified caData', async () => { + worker.use( + rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + caData: 'MOCKCA', + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.ca.toString('base64')).toMatch('MOCKCA'); + }); + it('should use default chain of trust when caData is unspecified', async () => { + worker.use( + rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.ca).toBeUndefined(); + }); + describe('with a CA file on disk', () => { + afterEach(() => { + mockFs.restore(); + }); + it('should trust contents of specified caFile', async () => { + mockFs({ + '/path/to/ca.crt': 'MOCKCA', + }); + worker.use( + rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + caFile: '/path/to/ca.crt', + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.ca.toString()).toEqual('MOCKCA'); + }); + }); + it('should accept unauthorized certs when skipTLSVerify is set', async () => { + worker.use( + rest.get('https://localhost:9999/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'https://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(httpsRequest).toHaveBeenCalledTimes(1); + const [[{ agent }]] = httpsRequest.mock.calls; + expect(agent.options.rejectUnauthorized).toBe(false); + }); }); it('should use namespace if provided', async () => { - clientMock.listNamespacedCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'pod-name', - }, - }, - ], - }, - }); + worker.use( + rest.get( + 'http://localhost:9999/api/v1/namespaces/some-namespace/pods', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + rest.get( + 'http://localhost:9999/api/v1/namespaces/some-namespace/services', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'service-name' } }], + }), + ), + ), + ); - clientMock.listNamespacedCustomObject.mockResolvedValueOnce({ - body: { - items: [ - { - metadata: { - name: 'service-name', - }, - }, - ], - }, - }); - - await sut.fetchObjectsForService({ + const result = await sut.fetchObjectsForService({ serviceId: 'some-service', clusterDetails: { name: 'cluster1', @@ -544,32 +726,175 @@ describe('KubernetesFetcher', () => { customResources: [], }); - const mockCall = clientMock.listNamespacedCustomObject.mock.calls[0]; - const namespace = mockCall[2]; - expect(namespace).toBe('some-namespace'); + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, + }, + }, + ], + }, + { + type: 'services', + resources: [ + { + metadata: { + name: 'service-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, + }, + }, + ], + }, + ], + }); + }); + describe('Backstage not running on k8s', () => { + it('fails if cluster details has no token', () => { + const result = sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'unauthenticated-cluster', + url: 'http://ignored', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: OBJECTS_TO_FETCH, + labelSelector: '', + customResources: [], + }); + return expect(result).rejects.toThrow( + "no bearer token for cluster 'unauthenticated-cluster' and not running in Kubernetes", + ); + }); + }); + describe('Backstage running on k8s', () => { + const initialHost = process.env.KUBERNETES_SERVICE_HOST; + const initialPort = process.env.KUBERNETES_SERVICE_PORT; + afterEach(() => { + process.env.KUBERNETES_SERVICE_HOST = initialHost; + process.env.KUBERNETES_SERVICE_PORT = initialPort; + mockFs.restore(); + }); + it('makes in-cluster requests when cluster details has no token', async () => { + process.env.KUBERNETES_SERVICE_HOST = '10.10.10.10'; + process.env.KUBERNETES_SERVICE_PORT = '443'; + mockFs({ + '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt': '', + '/var/run/secrets/kubernetes.io/serviceaccount/token': + 'allowed-token', + }); + worker.use( + rest.get('https://10.10.10.10/api/v1/pods', (req, res, ctx) => + res( + checkToken(req, ctx, 'allowed-token'), + withLabels(req, ctx, { + items: [{ metadata: { name: 'pod-name' } }], + }), + ), + ), + ); + + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'overridden-to-in-cluster', + url: 'http://ignored', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'pods', + objectType: 'pods', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + labels: { 'backstage.io/kubernetes-id': 'some-service' }, + }, + }, + ], + }, + ], + }); + }); }); }); describe('fetchPodMetricsByNamespaces', () => { - let kubernetesClientProvider: any; let sut: KubernetesClientBasedFetcher; beforeEach(() => { - jest.resetAllMocks(); - - kubernetesClientProvider = { - getMetricsClient: jest.fn(), - getCoreClientByClusterDetails: jest.fn(), - }; - sut = new KubernetesClientBasedFetcher({ - kubernetesClientProvider, logger: getVoidLogger(), }); }); it('should return pod metrics', async () => { - (topPods as jest.Mock).mockResolvedValue(POD_METRICS_FIXTURE); + worker.use( + rest.get( + 'http://localhost:9999/api/v1/namespaces/:namespace/pods', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + spec: { + containers: [ + { + name: 'container-name', + resources: { + requests: { cpu: '500m', memory: '512M' }, + limits: { cpu: '1000m', memory: '1G' }, + }, + }, + ], + }, + }, + ], + }), + ), + ), + rest.get( + 'http://localhost:9999/apis/metrics.k8s.io/v1beta1/namespaces/:namespace/pods', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + containers: [ + { + name: 'container-name', + usage: { cpu: '0', memory: '0' }, + }, + ], + }, + ], + }), + ), + ), + ); const result = await sut.fetchPodMetricsByNamespaces( { @@ -578,36 +903,85 @@ describe('KubernetesFetcher', () => { serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['ns-a', 'ns-b']), + new Set(['ns-a']), ); - expect(result).toStrictEqual({ + expect(result).toMatchObject({ errors: [], - responses: [ - { - type: 'podstatus', - resources: POD_METRICS_FIXTURE, - }, - { - type: 'podstatus', - resources: POD_METRICS_FIXTURE, - }, - ], + responses: POD_METRICS_FIXTURE, }); }); it('should return pod metrics and error', async () => { - const topPodsMock = topPods as jest.Mock; - topPodsMock - .mockResolvedValueOnce(POD_METRICS_FIXTURE) - .mockRejectedValueOnce({ - response: { - statusCode: 404, - request: { - uri: { - pathname: '/some/path', - }, - }, - }, - }); + worker.use( + rest.get( + 'http://localhost:9999/api/v1/namespaces/ns-a/pods', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + spec: { + containers: [ + { + name: 'container-name', + resources: { + requests: { cpu: '500m', memory: '512M' }, + limits: { cpu: '1000m', memory: '1G' }, + }, + }, + ], + }, + }, + ], + }), + ), + ), + rest.get( + 'http://localhost:9999/apis/metrics.k8s.io/v1beta1/namespaces/ns-a/pods', + (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'pod-name' }, + containers: [ + { + name: 'container-name', + usage: { cpu: '0', memory: '0' }, + }, + ], + }, + ], + }), + ), + ), + rest.get( + 'http://localhost:9999/api/v1/namespaces/ns-b/pods', + (_, res, ctx) => + res( + ctx.status(404), + ctx.json({ + kind: 'Status', + apiVersion: 'v1', + code: 404, + }), + ), + ), + rest.get( + 'http://localhost:9999/apis/metrics.k8s.io/v1beta1/namespaces/ns-b/pods', + (_, res, ctx) => + res( + ctx.status(404), + ctx.json({ + kind: 'Status', + apiVersion: 'v1', + code: 404, + }), + ), + ), + ); const result = await sut.fetchPodMetricsByNamespaces( { @@ -618,21 +992,15 @@ describe('KubernetesFetcher', () => { }, new Set(['ns-a', 'ns-b']), ); - expect(result).toStrictEqual({ - errors: [ - { - errorType: 'NOT_FOUND', - resourcePath: '/some/path', - statusCode: 404, - }, - ], - responses: [ - { - type: 'podstatus', - resources: POD_METRICS_FIXTURE, - }, - ], - }); + + expect(result.errors).toStrictEqual([ + { + errorType: 'NOT_FOUND', + resourcePath: '/apis/metrics.k8s.io/v1beta1/namespaces/ns-b/pods', + statusCode: 404, + }, + ]); + expect(result.responses).toMatchObject(POD_METRICS_FIXTURE); }); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index fd6400529b..6ef4397e9c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -14,16 +14,23 @@ * limitations under the License. */ -import { topPods } from '@kubernetes/client-node'; +import { + Config, + Cluster, + CoreV1Api, + KubeConfig, + Metrics, + User, + bufferFromFileOrString, + topPods, +} from '@kubernetes/client-node'; import lodash, { Dictionary } from 'lodash'; import { Logger } from 'winston'; import { ClusterDetails, FetchResponseWrapper, KubernetesFetcher, - KubernetesObjectTypes, ObjectFetchParams, - ObjectToFetch, } from '../types/types'; import { FetchResponse, @@ -31,10 +38,11 @@ import { KubernetesErrorTypes, PodStatusFetchResponse, } from '@backstage/plugin-kubernetes-common'; -import { KubernetesClientProvider } from './KubernetesClientProvider'; +import fetch, { RequestInit, Response } from 'node-fetch'; +import * as https from 'https'; +import fs from 'fs-extra'; export interface KubernetesClientBasedFetcherOptions { - kubernetesClientProvider: KubernetesClientProvider; logger: Logger; } @@ -72,14 +80,9 @@ const statusCodeToErrorType = (statusCode: number): KubernetesErrorTypes => { }; export class KubernetesClientBasedFetcher implements KubernetesFetcher { - private readonly kubernetesClientProvider: KubernetesClientProvider; private readonly logger: Logger; - constructor({ - kubernetesClientProvider, - logger, - }: KubernetesClientBasedFetcherOptions) { - this.kubernetesClientProvider = kubernetesClientProvider; + constructor({ logger }: KubernetesClientBasedFetcherOptions) { this.logger = logger; } @@ -88,16 +91,27 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { ): Promise { const fetchResults = Array.from(params.objectTypesToFetch) .concat(params.customResources) - .map(toFetch => { - return this.fetchResource( + .map(({ objectType, group, apiVersion, plural }) => + this.fetchResource( params.clusterDetails, - toFetch, + group, + apiVersion, + plural, + params.namespace, params.labelSelector || `backstage.io/kubernetes-id=${params.serviceId}`, - toFetch.objectType, - params.namespace, - ).catch(this.captureKubernetesErrorsRethrowOthers.bind(this)); - }); + ).then( + (r: Response): Promise => + r.ok + ? r.json().then( + ({ items }): FetchResponse => ({ + type: objectType, + resources: items, + }), + ) + : this.handleUnsuccessfulResponse(params.clusterDetails.name, r), + ), + ); return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); } @@ -106,93 +120,146 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { clusterDetails: ClusterDetails, namespaces: Set, ): Promise { - const metricsClient = - this.kubernetesClientProvider.getMetricsClient(clusterDetails); - const coreApi = - this.kubernetesClientProvider.getCoreClientByClusterDetails( - clusterDetails, - ); - - const fetchResults = Array.from(namespaces).map(ns => - topPods(coreApi, metricsClient, ns) - .then(r => { - return { + const fetchResults = Array.from(namespaces).map(async ns => { + const [podMetrics, podList] = await Promise.all([ + this.fetchResource( + clusterDetails, + 'metrics.k8s.io', + 'v1beta1', + 'pods', + ns, + ), + this.fetchResource(clusterDetails, '', 'v1', 'pods', ns), + ]); + if (podMetrics.ok && podList.ok) { + return topPods( + { + listPodForAllNamespaces: () => + podList.json().then(b => ({ body: b })), + } as unknown as CoreV1Api, + { + getPodMetrics: () => podMetrics.json(), + } as unknown as Metrics, + ).then( + (resources): PodStatusFetchResponse => ({ type: 'podstatus', - resources: r, - } as PodStatusFetchResponse; - }) - .catch(this.captureKubernetesErrorsRethrowOthers.bind(this)), - ); + resources, + }), + ); + } else if (podMetrics.ok) { + return this.handleUnsuccessfulResponse(clusterDetails.name, podList); + } + return this.handleUnsuccessfulResponse(clusterDetails.name, podMetrics); + }); return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); } - private captureKubernetesErrorsRethrowOthers(e: any): KubernetesFetchError { - if (e.response && e.response.statusCode) { - this.logger.warn( - `statusCode=${e.response.statusCode} for resource ${ - e.response.request.uri.pathname - } body=[${JSON.stringify(e.response.body)}]`, - ); - return { - errorType: statusCodeToErrorType(e.response.statusCode), - statusCode: e.response.statusCode, - resourcePath: e.response.request.uri.pathname, - }; - } - throw e; + private async handleUnsuccessfulResponse( + clusterName: string, + res: Response, + ): Promise { + const resourcePath = new URL(res.url).pathname; + this.logger.warn( + `Received ${ + res.status + } status when fetching "${resourcePath}" from cluster "${clusterName}"; body=[${await res.text()}]`, + ); + return { + errorType: statusCodeToErrorType(res.status), + statusCode: res.status, + resourcePath, + }; } private fetchResource( clusterDetails: ClusterDetails, - resource: ObjectToFetch, - labelSelector: string, - objectType: KubernetesObjectTypes, + group: string, + apiVersion: string, + plural: string, namespace?: string, - ): Promise { - const customObjects = - this.kubernetesClientProvider.getCustomObjectsClient(clusterDetails); - - customObjects.addInterceptor((requestOptions: any) => { - requestOptions.uri = requestOptions.uri.replace('/apis//v1/', '/api/v1/'); - }); - + labelSelector?: string, + ): Promise { + const encode = (s: string) => encodeURIComponent(s); + let resourcePath = group + ? `/apis/${encode(group)}/${encode(apiVersion)}` + : `/api/${encode(apiVersion)}`; if (namespace) { - return customObjects - .listNamespacedCustomObject( - resource.group, - resource.apiVersion, - namespace, - resource.plural, - '', - false, - '', - '', - labelSelector, - ) - .then(r => { - return { - type: objectType, - resources: (r.body as any).items, - }; - }); + resourcePath += `/namespaces/${encode(namespace)}`; } - return customObjects - .listClusterCustomObject( - resource.group, - resource.apiVersion, - resource.plural, - '', - false, - '', - '', - labelSelector, - ) - .then(r => { - return { - type: objectType, - resources: (r.body as any).items, - }; + resourcePath += `/${encode(plural)}`; + + let url: URL; + let requestInit: RequestInit; + if (clusterDetails.serviceAccountToken) { + [url, requestInit] = this.fetchArgsFromClusterDetails(clusterDetails); + } else if (fs.pathExistsSync(Config.SERVICEACCOUNT_TOKEN_PATH)) { + [url, requestInit] = this.fetchArgsInCluster(); + } else { + return Promise.reject( + new Error( + `no bearer token for cluster '${clusterDetails.name}' and not running in Kubernetes`, + ), + ); + } + + url.pathname = resourcePath; + if (labelSelector) { + url.search = `labelSelector=${labelSelector}`; + } + + return fetch(url, requestInit); + } + + private fetchArgsFromClusterDetails( + clusterDetails: ClusterDetails, + ): [URL, RequestInit] { + const requestInit: RequestInit = { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${clusterDetails.serviceAccountToken}`, + }, + }; + + const url: URL = new URL(clusterDetails.url); + if (url.protocol === 'https:') { + requestInit.agent = new https.Agent({ + ca: + bufferFromFileOrString( + clusterDetails.caFile, + clusterDetails.caData, + ) ?? undefined, + rejectUnauthorized: !clusterDetails.skipTLSVerify, }); + } + return [url, requestInit]; + } + private fetchArgsInCluster(): [URL, RequestInit] { + const kc = new KubeConfig(); + kc.loadFromCluster(); + // loadFromCluster is guaranteed to populate the cluster/user/context + const cluster = kc.getCurrentCluster() as Cluster; + const user = kc.getCurrentUser() as User; + + const token = fs.readFileSync(user.authProvider.config.tokenFile); + + const requestInit: RequestInit = { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }; + + const url = new URL(cluster.server); + if (url.protocol === 'https:') { + requestInit.agent = new https.Agent({ + ca: fs.readFileSync(cluster.caFile as string), + }); + } + return [url, requestInit]; } } diff --git a/plugins/kubernetes-backend/src/service/index.ts b/plugins/kubernetes-backend/src/service/index.ts index 70ff530bee..620f788ac2 100644 --- a/plugins/kubernetes-backend/src/service/index.ts +++ b/plugins/kubernetes-backend/src/service/index.ts @@ -15,7 +15,6 @@ */ export * from './KubernetesBuilder'; -export * from './KubernetesClientProvider'; export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler'; export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy'; export * from './router'; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 8509fa97cc..fe8aa19966 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -184,14 +184,7 @@ export type KubernetesErrorTypes = | 'UNKNOWN_ERROR'; // @public (undocumented) -export interface KubernetesFetchError { - // (undocumented) - errorType: KubernetesErrorTypes; - // (undocumented) - resourcePath?: string; - // (undocumented) - statusCode?: number; -} +export type KubernetesFetchError = StatusError | RawFetchError; // @public (undocumented) export interface KubernetesRequestAuth { @@ -241,6 +234,14 @@ export interface PodStatusFetchResponse { type: 'podstatus'; } +// @public (undocumented) +export interface RawFetchError { + // (undocumented) + errorType: 'FETCH_ERROR'; + // (undocumented) + message: string; +} + // @public (undocumented) export interface ReplicaSetsFetchResponse { // (undocumented) @@ -265,6 +266,16 @@ export interface StatefulSetsFetchResponse { type: 'statefulsets'; } +// @public (undocumented) +export interface StatusError { + // (undocumented) + errorType: KubernetesErrorTypes; + // (undocumented) + resourcePath?: string; + // (undocumented) + statusCode?: number; +} + // @public (undocumented) export interface WorkloadsByEntityRequest { // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 3feddd7f4c..f723a46d16 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -223,12 +223,21 @@ export interface PodStatusFetchResponse { } /** @public */ -export interface KubernetesFetchError { +export type KubernetesFetchError = StatusError | RawFetchError; + +/** @public */ +export interface StatusError { errorType: KubernetesErrorTypes; statusCode?: number; resourcePath?: string; } +/** @public */ +export interface RawFetchError { + errorType: 'FETCH_ERROR'; + message: string; +} + /** @public */ export type KubernetesErrorTypes = | 'BAD_REQUEST' diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx index 832d6eed91..0d75c09a11 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.test.tsx @@ -20,36 +20,14 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { ErrorPanel } from './ErrorPanel'; describe('ErrorPanel', () => { - it('render with error message', async () => { - const { getByText } = render( - wrapInTestApp( - , - ), - ); - - // title - expect( - getByText( - 'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.', - ), - ).toBeInTheDocument(); - - // message - expect(getByText('Errors: SOME_ERROR_MESSAGE')).toBeInTheDocument(); - }); - it('render with cluster errors', async () => { + it('displays path and status code when a cluster has an HTTP error', async () => { const { getByText } = render( wrapInTestApp( { ), ).toBeInTheDocument(); }); + it('displays message for non-HTTP-status-related fetch errors', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect( + getByText( + 'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.', + ), + ).toBeInTheDocument(); + + // message + expect(getByText('Errors:')).toBeInTheDocument(); + expect(getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument(); + expect( + getByText( + 'Error communicating with Kubernetes: FETCH_ERROR, message: description of error', + ), + ).toBeInTheDocument(); + }); + it('displays error message', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect( + getByText( + 'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.', + ), + ).toBeInTheDocument(); + + // message + expect(getByText('Errors: SOME_ERROR_MESSAGE')).toBeInTheDocument(); + }); }); diff --git a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx index fcdeeef393..d2cb88b9ca 100644 --- a/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/ErrorPanel/ErrorPanel.tsx @@ -29,7 +29,9 @@ const clustersWithErrorsToErrorMessage = ( {c.errors.map((e, j) => { return ( - {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`} + {e.errorType === 'FETCH_ERROR' + ? `Error communicating with Kubernetes: ${e.errorType}, message: ${e.message}` + : `Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`} ); })}