From e862980c57037018ec6efc52ade31c5221930850 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Fri, 28 Oct 2022 18:58:13 -0500 Subject: [PATCH] feat: Carlos Lopez pre RFC update commit squash docs: Remove unnecessary types test: Use MSW instead of mocking fetch fix: Updated api-report.md & error stringify from @backstage/errors feat: Kubernetes builder getters/setters update fix: Error handling with fix: Error handling with`@backstage/errors` fix: Use same MSW version as other plugins fix: Sort handleProxyRequest inputs fix: Small type & comment fixes fix: Change serverIP to serverURI fix: Variable name improvements Signed-off-by: Carlos Esteban Lopez --- plugins/kubernetes-backend/api-report.md | 38 +++- plugins/kubernetes-backend/package.json | 4 +- plugins/kubernetes-backend/src/index.ts | 1 + .../src/service/KubernetesBuilder.ts | 149 ++++++++++------ .../src/service/KubernetesProxy.test.ts | 162 ++++++++---------- .../src/service/KubernetesProxy.ts | 143 ++++++++-------- plugins/kubernetes-backend/src/types/types.ts | 8 - plugins/kubernetes-common/api-report.md | 4 - plugins/kubernetes-common/src/types.ts | 1 + yarn.lock | 3 + 10 files changed, 280 insertions(+), 233 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 303aaa3017..f5b8ff85b7 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -22,6 +22,8 @@ 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 { PodStatus } from '@kubernetes/client-node/dist/top'; +import type { Request as Request_2 } from 'express'; import { TokenCredential } from '@azure/identity'; // @alpha (undocumented) @@ -188,6 +190,8 @@ export class KubernetesBuilder { options: KubernetesObjectsProviderOptions, ): KubernetesObjectsProvider; // (undocumented) + protected buildProxy(): KubernetesProxy; + // (undocumented) protected buildRouter( objectsProvider: KubernetesObjectsProvider, clusterSupplier: KubernetesClustersSupplier, @@ -207,9 +211,19 @@ export class KubernetesBuilder { clusterSupplier: KubernetesClustersSupplier, ): Promise; // (undocumented) + protected getClusterSupplier(): KubernetesClustersSupplier; + // (undocumented) + protected getFetcher(): KubernetesFetcher; + // (undocumented) + protected getObjectsProvider( + options: KubernetesObjectsProviderOptions, + ): KubernetesObjectsProvider; + // (undocumented) protected getObjectTypesToFetch(): ObjectToFetch[] | undefined; // (undocumented) - protected getProxyServices(): KubernetesProxyServices; + protected getProxy(): KubernetesProxy; + // (undocumented) + protected getServiceLocator(): KubernetesServiceLocator; // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) @@ -226,6 +240,8 @@ export class KubernetesBuilder { // (undocumented) setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this; // (undocumented) + setProxy(proxy?: KubernetesProxy): this; + // (undocumented) setServiceLocator(serviceLocator?: KubernetesServiceLocator): this; } @@ -330,9 +346,25 @@ export type KubernetesObjectTypes = | 'daemonsets'; // @alpha (undocumented) -export interface KubernetesProxyServices { +export class KubernetesProxy { + constructor(logger: Logger); // (undocumented) - kcs: KubernetesClustersSupplier; + handleProxyRequest( + req: Request_2, + clusterSupplier: KubernetesClustersSupplier, + ): Promise; + // (undocumented) + protected readonly logger: Logger; +} + +// @alpha (undocumented) +export interface KubernetesProxyResponse { + // (undocumented) + cluster?: string; + // (undocumented) + code: number; + // (undocumented) + data: any; } // @alpha diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 95b3000069..d1352a21dc 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -36,6 +36,7 @@ "dependencies": { "@azure/identity": "^2.0.4", "@backstage/backend-common": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", @@ -56,8 +57,8 @@ "helmet": "^6.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", - "node-fetch": "^2.6.0", "morgan": "^1.10.0", + "node-fetch": "^2.6.0", "stream-buffers": "^3.0.2", "winston": "^3.2.1", "yn": "^4.0.0" @@ -66,6 +67,7 @@ "@backstage/cli": "workspace:^", "@types/aws4": "^1.5.1", "aws-sdk-mock": "^5.2.1", + "msw": "^0.48.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/kubernetes-backend/src/index.ts b/plugins/kubernetes-backend/src/index.ts index e2eee3f24e..fba5c4a151 100644 --- a/plugins/kubernetes-backend/src/index.ts +++ b/plugins/kubernetes-backend/src/index.ts @@ -32,6 +32,7 @@ export * from './kubernetes-auth-translator/types'; export * from './service/router'; export * from './service/KubernetesBuilder'; export * from './service/KubernetesClientProvider'; +export * from './service/KubernetesProxy'; export * from './types/types'; diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index c7f569267e..ff79bd83de 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -13,36 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; -import { Logger } from 'winston'; import { Duration } from 'luxon'; +import { Logger } from 'winston'; + import { getCombinedClusterSupplier } from '../cluster-locator'; +import { addResourceRoutesToRouter } from '../routes/resourcesRoutes'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { - KubernetesObjectTypes, - ServiceLocatorMethod, CustomResource, - KubernetesObjectsProvider, - ObjectsByEntityRequest, KubernetesClustersSupplier, KubernetesFetcher, - KubernetesServiceLocator, + KubernetesObjectsProvider, KubernetesObjectsProviderOptions, - KubernetesProxyServices, + KubernetesObjectTypes, + KubernetesServiceLocator, + ObjectsByEntityRequest, + ServiceLocatorMethod, } from '../types/types'; import { KubernetesClientProvider } from './KubernetesClientProvider'; - -import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy'; - import { DEFAULT_OBJECTS, KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; -import { addResourceRoutesToRouter } from '../routes/resourcesRoutes'; -import { CatalogApi } from '@backstage/catalog-client'; +import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy'; /** * @@ -80,15 +78,13 @@ export class KubernetesBuilder { private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; - private proxy: KubernetesProxy; + private proxy?: KubernetesProxy; static createBuilder(env: KubernetesEnvironment) { return new KubernetesBuilder(env); } - constructor(protected readonly env: KubernetesEnvironment) { - this.proxy = new KubernetesProxy(env.logger); - } + constructor(protected readonly env: KubernetesEnvironment) {} public async build(): KubernetesBuilderReturn { const logger = this.env.logger; @@ -109,25 +105,19 @@ export class KubernetesBuilder { } const customResources = this.buildCustomResources(); - const fetcher = this.fetcher ?? this.buildFetcher(); + const fetcher = this.getFetcher(); - const clusterSupplier = - this.clusterSupplier ?? - this.buildClusterSupplier(this.defaultClusterRefreshInterval); + const clusterSupplier = this.getClusterSupplier(); - const serviceLocator = - this.serviceLocator ?? - this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); + const serviceLocator = this.getServiceLocator(); - const objectsProvider = - this.objectsProvider ?? - this.buildObjectsProvider({ - logger, - fetcher, - serviceLocator, - customResources, - objectTypesToFetch: this.getObjectTypesToFetch(), - }); + const objectsProvider = this.getObjectsProvider({ + logger, + fetcher, + serviceLocator, + customResources, + objectTypesToFetch: this.getObjectTypesToFetch(), + }); const router = this.buildRouter( objectsProvider, @@ -170,6 +160,11 @@ export class KubernetesBuilder { return this; } + public setProxy(proxy?: KubernetesProxy) { + this.proxy = proxy; + return this; + } + protected buildCustomResources() { const customResources: CustomResource[] = ( this.env.config.getOptionalConfigArray('kubernetes.customResources') ?? [] @@ -193,24 +188,29 @@ export class KubernetesBuilder { refreshInterval: Duration, ): KubernetesClustersSupplier { const config = this.env.config; - return getCombinedClusterSupplier( + this.clusterSupplier = getCombinedClusterSupplier( config, this.env.catalogApi, refreshInterval, ); + + return this.clusterSupplier; } protected buildObjectsProvider( options: KubernetesObjectsProviderOptions, ): KubernetesObjectsProvider { - return new KubernetesFanOutHandler(options); + this.objectsProvider = new KubernetesFanOutHandler(options); + return this.objectsProvider; } protected buildFetcher(): KubernetesFetcher { - return new KubernetesClientBasedFetcher({ + this.fetcher = new KubernetesClientBasedFetcher({ kubernetesClientProvider: new KubernetesClientProvider(), logger: this.env.logger, }); + + return this.fetcher; } protected buildServiceLocator( @@ -219,14 +219,19 @@ export class KubernetesBuilder { ): KubernetesServiceLocator { switch (method) { case 'multiTenant': - return this.buildMultiTenantServiceLocator(clusterSupplier); + this.serviceLocator = + this.buildMultiTenantServiceLocator(clusterSupplier); + break; case 'http': - return this.buildHttpServiceLocator(clusterSupplier); + this.serviceLocator = this.buildHttpServiceLocator(clusterSupplier); + break; default: throw new Error( `Unsupported kubernetes.clusterLocatorMethod "${method}"`, ); } + + return this.serviceLocator; } protected buildMultiTenantServiceLocator( @@ -241,6 +246,11 @@ export class KubernetesBuilder { throw new Error('not implemented'); } + protected buildProxy(): KubernetesProxy { + this.proxy = new KubernetesProxy(this.env.logger); + return this.proxy; + } + protected buildRouter( objectsProvider: KubernetesObjectsProvider, clusterSupplier: KubernetesClustersSupplier, @@ -250,6 +260,8 @@ export class KubernetesBuilder { const router = Router(); router.use(express.json()); + const proxy = this.getProxy(); + // @deprecated router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; @@ -280,11 +292,13 @@ export class KubernetesBuilder { }); }); - router.get('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); - router.post('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); - router.put('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); - router.patch('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); - router.delete('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + if (typeof proxy?.handleProxyRequest === 'function') { + router.get('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + router.post('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + router.put('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + router.patch('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + router.delete('/proxy/:encodedQuery', this.makeProxyRequest.bind(this)); + } addResourceRoutesToRouter(router, catalogApi, objectsProvider); @@ -309,6 +323,31 @@ export class KubernetesBuilder { ) as ServiceLocatorMethod; } + protected getFetcher(): KubernetesFetcher { + return this.fetcher ?? this.buildFetcher(); + } + + protected getClusterSupplier() { + return ( + this.clusterSupplier ?? + this.buildClusterSupplier(this.defaultClusterRefreshInterval) + ); + } + + protected getServiceLocator(): KubernetesServiceLocator { + return ( + this.serviceLocator ?? + this.buildServiceLocator( + this.getServiceLocatorMethod(), + this.getClusterSupplier(), + ) + ); + } + + protected getObjectsProvider(options: KubernetesObjectsProviderOptions) { + return this.objectsProvider ?? this.buildObjectsProvider(options); + } + protected getObjectTypesToFetch() { const objectTypesToFetchStrings = this.env.config.getOptionalStringArray( 'kubernetes.objectTypes', @@ -339,27 +378,27 @@ export class KubernetesBuilder { return objectTypesToFetch; } + protected getProxy() { + return this.proxy ?? this.buildProxy(); + } + protected async makeProxyRequest( req: express.Request, res: express.Response, ) { - const services = this.getProxyServices(); + const supplier = this.getClustersSupplier(); + const proxy = this.getProxy(); + const proxyResponse: KubernetesProxyResponse = - await this.proxy.handleProxyRequest(services, req); + await proxy.handleProxyRequest(req, supplier); + res.status(proxyResponse.code).json(proxyResponse.data); } - protected getProxyServices(): KubernetesProxyServices { - const kcs = + private getClustersSupplier(): KubernetesClustersSupplier { + return ( this.clusterSupplier ?? - this.buildClusterSupplier(this.defaultClusterRefreshInterval); - - if (!kcs) { - this.env.logger.error('could not find cluster supplier!'); - } - - return { - kcs, - }; + this.buildClusterSupplier(this.defaultClusterRefreshInterval) + ); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index c692355fbb..b9bb1d425f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -13,32 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { getVoidLogger } from '@backstage/backend-common'; -import { - ClusterDetails, - KubernetesClustersSupplier, - KubernetesProxyServices, -} from '../types/types'; -import { KubernetesProxy } from './KubernetesProxy'; - -import { Request } from 'express'; - import 'buffer'; -jest.mock('node-fetch'); -const { Response } = jest.requireActual('node-fetch'); +import { getVoidLogger } from '@backstage/backend-common'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { Request } from 'express'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; -import fetch from 'node-fetch'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { KubernetesProxy } from './KubernetesProxy'; +import { NotFoundError } from '@backstage/errors'; describe('KubernetesProxy', () => { - let _clientMock: any; - let sut: KubernetesProxy; + let proxy: KubernetesProxy; + const worker = setupServer(); + setupRequestMockHandlers(worker); const buildEncodedRequest = ( clustersHeader: any, query: string, - body?: any, + body?: unknown, ): Request => { const encodedQuery = encodeURIComponent(query); const encodedClusters = Buffer.from( @@ -75,41 +70,29 @@ describe('KubernetesProxy', () => { return req; }; - const buildProxyServicesWithClusters = ( + const buildClustersSupplierWithClusters = ( clusters: ClusterDetails[], - ): KubernetesProxyServices => { - const kcs: KubernetesClustersSupplier = { - getClusters: async () => { - return clusters; - }, - }; - - return { - kcs, - }; - }; - - beforeEach(() => { - jest.resetAllMocks(); - _clientMock = { - handleProxyRequest: jest.fn(), - }; - - sut = new KubernetesProxy(getVoidLogger()); + ): KubernetesClustersSupplier => ({ + getClusters: async () => { + return clusters; + }, }); - it('should return a 404 if no clusters are found', async () => { - const services = buildProxyServicesWithClusters([]); + beforeEach(() => { + proxy = new KubernetesProxy(getVoidLogger()); + }); + + it('should return a ERROR_NOT_FOUND if no clusters are found', async () => { + const clustersSupplier = buildClustersSupplierWithClusters([]); const req = buildEncodedRequest({}, 'api'); - const result = await sut.handleProxyRequest(services, req); - - expect(result.code).toEqual(404); - expect(fetch).not.toHaveBeenCalled(); + await expect( + proxy.handleProxyRequest(req, clustersSupplier), + ).rejects.toThrow(NotFoundError); }); it('should match the response code of the Kubernetes response (single cluster)', async () => { - const services = buildProxyServicesWithClusters([ + const clusters: ClusterDetails[] = [ { name: 'cluster1', url: 'http://localhost:9999', @@ -117,7 +100,9 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', skipTLSVerify: true, }, - ]); + ]; + + const clustersSupplier = buildClustersSupplierWithClusters(clusters); const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); const apiResponse = { @@ -131,21 +116,19 @@ describe('KubernetesProxy', () => { ], }; - // @ts-ignore-next-line - (fetch as jest.MockedFunction).mockResolvedValue( - new Response(JSON.stringify(apiResponse), { - status: 299, - }), + worker.use( + rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(299), ctx.body(JSON.stringify(apiResponse))), + ), ); - const result = await sut.handleProxyRequest(services, req); + const result = await proxy.handleProxyRequest(req, clustersSupplier); - expect(fetch).toBeCalledTimes(1); expect(result.code).toEqual(299); }); it('should match the response code of the best Kubernetes response (multi cluster)', async () => { - const services = buildProxyServicesWithClusters([ + const clusters: ClusterDetails[] = [ { name: 'cluster1', url: 'http://localhost:9998', @@ -160,7 +143,9 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', skipTLSVerify: true, }, - ]); + ]; + + const clustersSupplier = buildClustersSupplierWithClusters(clusters); const req = buildEncodedRequest( { cluster1: 'token', cluster2: 'token' }, 'api', @@ -187,26 +172,22 @@ describe('KubernetesProxy', () => { code: 401, }; - (fetch as jest.MockedFunction) - .mockResolvedValueOnce( - new Response(JSON.stringify(apiResponse1), { - status: 200, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(apiResponse2), { - status: 401, - }), - ); + worker.use( + rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))), + ), + rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))), + ), + ); - const result = await sut.handleProxyRequest(services, req); + const result = await proxy.handleProxyRequest(req, clustersSupplier); - expect(fetch).toBeCalledTimes(2); expect(result.code).toEqual(200); }); it('should pass the exact response data from Kubernetes (single cluster)', async () => { - const services = buildProxyServicesWithClusters([ + const clusters: ClusterDetails[] = [ { name: 'cluster1', url: 'http://localhost:9999', @@ -214,7 +195,9 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', skipTLSVerify: true, }, - ]); + ]; + + const clustersSupplier = buildClustersSupplierWithClusters(clusters); const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); const apiResponse = { @@ -228,14 +211,13 @@ describe('KubernetesProxy', () => { ], }; - // @ts-ignore-next-line - (fetch as jest.MockedFunction).mockResolvedValue( - new Response(JSON.stringify(apiResponse), { - status: 200, - }), + worker.use( + rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(200), ctx.body(JSON.stringify(apiResponse))), + ), ); - const result = await sut.handleProxyRequest(services, req); + const result = await proxy.handleProxyRequest(req, clustersSupplier); const resultString = JSON.stringify(result.data); const expectedString = JSON.stringify({ @@ -251,12 +233,11 @@ describe('KubernetesProxy', () => { }, }); - expect(fetch).toBeCalledTimes(1); expect(resultString).toEqual(expectedString); }); it('should pass the exact response data from Kubernetes (multi cluster)', async () => { - const services = buildProxyServicesWithClusters([ + const clusters: ClusterDetails[] = [ { name: 'cluster1', url: 'http://localhost:9998', @@ -271,7 +252,9 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', skipTLSVerify: true, }, - ]); + ]; + + const clustersSupplier = buildClustersSupplierWithClusters(clusters); const req = buildEncodedRequest( { cluster1: 'token', cluster2: 'token' }, 'api', @@ -298,20 +281,16 @@ describe('KubernetesProxy', () => { code: 401, }; - // @ts-ignore-next-line - (fetch as jest.MockedFunction) - .mockResolvedValueOnce( - new Response(JSON.stringify(apiResponse1), { - status: 200, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(apiResponse2), { - status: 401, - }), - ); + worker.use( + rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(200), ctx.body(JSON.stringify(apiResponse1))), + ), + rest.get(`${clusters[1].url}/${req.params.encodedQuery}`, (_, res, ctx) => + res(ctx.status(401), ctx.body(JSON.stringify(apiResponse2))), + ), + ); - const result = await sut.handleProxyRequest(services, req); + const result = await proxy.handleProxyRequest(req, clustersSupplier); const resultString = JSON.stringify(result.data); const expectedString = JSON.stringify({ @@ -336,7 +315,6 @@ describe('KubernetesProxy', () => { }, }); - expect(fetch).toBeCalledTimes(2); expect(resultString).toEqual(expectedString); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index e073ab3ec1..dea4786820 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -13,69 +13,81 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { KubeConfig, bufferFromFileOrString } from '@kubernetes/client-node'; -import { Logger } from 'winston'; -import fetch from 'node-fetch'; +import { + AuthenticationError, + ConflictError, + ForwardedError, + InputError, + NotFoundError, + stringifyError, +} from '@backstage/errors'; +import { bufferFromFileOrString, KubeConfig } from '@kubernetes/client-node'; import * as https from 'https'; +import fetch, { RequestInit } from 'node-fetch'; +import { Logger } from 'winston'; + +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import type { Request } from 'express'; -import { - ClusterDetails, - KubernetesProxyServices, - KubernetesClustersSupplier, -} from '../types/types'; - const HEADER_CONTENT_TYPE: string = 'Content-Type'; const APPLICATION_JSON: string = 'application/json'; const HEADER_KUBERNETES_CLUSTERS: string = 'X-Kubernetes-Clusters'; -const ERROR_BAD_REQUEST: number = 400; -const ERROR_NOT_FOUND: number = 404; const ERROR_INTERNAL_SERVER: number = 500; const CLUSTER_USER_NAME: string = 'backstage'; +/** + * + * @alpha + */ export interface KubernetesProxyResponse { code: number; data: any; cluster?: string; } +/** + * + * @alpha + */ interface KubernetesProxyClusters { [key: string]: string; } +/** + * + * @alpha + */ export class KubernetesProxy { constructor(protected readonly logger: Logger) {} public async handleProxyRequest( - services: KubernetesProxyServices, req: Request, + clusterSupplier: KubernetesClustersSupplier, ): Promise { - const krc = this.getKubernetesRequestedClusters(req); + const requestedClusters = this.getKubernetesRequestedClusters(req); - if (Object.keys(krc).length < 1) { - return { - code: ERROR_NOT_FOUND, - data: 'No clusters found!', - }; + if (Object.keys(requestedClusters).length < 1) { + this.logger.error(`No clusters found`); + throw new NotFoundError('No clusters found!'); } - const details = await this.getClusterDetails(services.kcs, krc); + const clusterDetails = await this.getClusterDetails( + clusterSupplier, + requestedClusters, + ); - if (details.length < 1) { - return { - code: ERROR_NOT_FOUND, - data: 'No clusters found!', - }; + if (clusterDetails.length < 1) { + this.logger.error(`No clusters found`); + throw new NotFoundError('No clusters found!'); } const responses = await Promise.all( - details.map(async d => { - const response = await this.makeRequestToCluster(d, req); + clusterDetails.map(async clusterDetail => { + const response = await this.makeRequestToCluster(clusterDetail, req); return response; }), ); @@ -116,7 +128,7 @@ export class KubernetesProxy { return clusters; } catch (e: any) { this.logger.debug( - `error with encoded cluster header: ${JSON.stringify(e)}`, + `error with encoded cluster header: ${stringifyError(e)}`, ); } return {}; @@ -124,17 +136,17 @@ export class KubernetesProxy { private async getClusterDetails( clusterSupplier: KubernetesClustersSupplier, - krc: KubernetesProxyClusters, + requestedClusters: KubernetesProxyClusters, ): Promise { const clusters = await clusterSupplier.getClusters(); - const clusterNames = Object.keys(krc); + const clusterNames = Object.keys(requestedClusters); const clusterDetails = clusters.filter(c => clusterNames.includes(c.name)); const clusterDetailsAuth = clusterDetails.map(c => { const cAuth: ClusterDetails = Object.assign(c, { - serviceAccountToken: krc[c.name], + serviceAccountToken: requestedClusters[c.name], }); return cAuth; }); @@ -151,28 +163,26 @@ export class KubernetesProxy { details: ClusterDetails, req: Request, ): Promise { - const serverIP = this.getClusterURI(details); - if (!serverIP) { - return { - code: ERROR_INTERNAL_SERVER, - data: null, - }; + const serverURI = this.getClusterURI(details); + + if (!serverURI) { + this.logger.error(`Cluster ${details.name} details IP error`); + + throw new ConflictError('Cluster detail error'); } const query = decodeURIComponent(req.params.encodedQuery) || ''; - const uri = `${serverIP}/${query}`; + const uri = `${serverURI}/${query}`; const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON; - const res = await this.sendClusterRequest( + return await this.sendClusterRequest( details, uri, req.method, contentType, req.body, ); - - return res; } private async sendClusterRequest( @@ -183,16 +193,14 @@ export class KubernetesProxy { body?: any, ): Promise { const bearerToken = details.serviceAccountToken; + if (!bearerToken) { - return { - code: ERROR_BAD_REQUEST, - data: { - error: 'Invalid service account token', - }, - }; + this.logger.error('Invalid service account token'); + + throw new AuthenticationError('Invalid service account token'); } - const reqData: any = { + const reqData: RequestInit = { method, headers: { 'Content-Type': contentType, @@ -205,13 +213,10 @@ export class KubernetesProxy { const ca = bufferFromFileOrString('', details.caData)?.toString() || ''; reqData.agent = new https.Agent({ ca }); } else { - this.logger.info('could not find CA certificate!'); - return { - code: ERROR_INTERNAL_SERVER, - data: { - error: 'Invalid CA certificate configured within Backstage', - }, - }; + this.logger.error('could not find CA certificate!'); + throw new InputError( + 'Invalid CA certificate configured within Backstage', + ); } } @@ -220,28 +225,25 @@ export class KubernetesProxy { } try { - const req = await fetch(uri, reqData); + const res = await fetch(uri, reqData); + + let data: string | any; - let res; if (contentType.includes(APPLICATION_JSON)) { - res = await req.json(); + data = await res.json(); } else { - res = await req.text(); + data = await res.text(); } const proxyResponse: KubernetesProxyResponse = { - code: req.status, - data: res, + code: res.status, + data, cluster: details.name, }; return proxyResponse; } catch (e: any) { - return { - code: ERROR_INTERNAL_SERVER, - data: e, - cluster: details.name, - }; + throw new ForwardedError(`Cluster ${details.name} request error`, e); } } @@ -264,19 +266,20 @@ export class KubernetesProxy { cluster: cluster.name, }; - const kc = new KubeConfig(); + const kubeConfig = new KubeConfig(); + if (clusterDetails.serviceAccountToken) { - kc.loadFromOptions({ + kubeConfig.loadFromOptions({ clusters: [cluster], users: [user], contexts: [context], currentContext: context.name, }); } else { - kc.loadFromDefault(); + kubeConfig.loadFromDefault(); } - return kc; + return kubeConfig; } private getBestResponseCode(codes: number[]): number { diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 0af89b4d12..ed43935202 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -280,11 +280,3 @@ export interface KubernetesObjectsProvider { customResourcesByEntity: CustomResourcesByEntity, ): Promise; } - -/** - * - * @alpha - */ -export interface KubernetesProxyServices { - kcs: KubernetesClustersSupplier; -} diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 21f8cd86f5..c504558b00 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -193,16 +193,12 @@ export interface KubernetesFetchError { statusCode?: number; } -// Warning: (ae-missing-release-tag) "KubernetesProxyClusters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface KubernetesProxyClusters { // (undocumented) [key: string]: string; } -// Warning: (ae-missing-release-tag) "KubernetesRequestAuth" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface KubernetesRequestAuth { // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index b1ed9165e2..d0bf7757fb 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -259,6 +259,7 @@ export interface ClientPodStatus { containers: ClientContainerStatus[]; } +/** @public */ export interface KubernetesProxyClusters { [key: string]: string; } diff --git a/yarn.lock b/yarn.lock index 8ca56c7fcf..ae10e493f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5964,6 +5964,7 @@ __metadata: dependencies: "@azure/identity": ^2.0.4 "@backstage/backend-common": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -5988,6 +5989,8 @@ __metadata: lodash: ^4.17.21 luxon: ^3.0.0 morgan: ^1.10.0 + msw: ^0.48.0 + node-fetch: ^2.6.0 stream-buffers: ^3.0.2 supertest: ^6.1.3 winston: ^3.2.1