From b5851797705f31d9d1343195839b4841a19ea2d2 Mon Sep 17 00:00:00 2001 From: Liam Rathke Date: Thu, 11 Aug 2022 15:48:18 -0700 Subject: [PATCH 01/16] feat: Liam Rathke pre RFC update commit squash Signed-off-by: Liam Rathke Copy of proxy plugin work Signed-off-by: Liam Rathke Adds B64 encoding, TLS verify check Signed-off-by: Liam Rathke Sets up test scaffolding Signed-off-by: Liam Rathke Finishes proxy implementation tests Signed-off-by: Liam Rathke Removes deprecated buffer method Signed-off-by: Liam Rathke Adds additional HTTP verbs Signed-off-by: Liam Rathke Removes fallback Signed-off-by: Liam Rathke Adds some content-agnosticness, removes nitpick Signed-off-by: Liam Rathke Adds changeset Signed-off-by: Liam Rathke Fixes TSC errors? Signed-off-by: Liam Rathke Adds API Report docs Signed-off-by: Liam Rathke Removes unnecessary KubernetesRequestAuth parameter Signed-off-by: Liam Rathke Somehow adds new docs??? Signed-off-by: Liam Rathke Fixes some code inline with review comments Signed-off-by: Liam Rathke Updates API report Signed-off-by: Liam Rathke Fixes tests Signed-off-by: Liam Rathke --- .changeset/rich-garlics-play.md | 6 + plugins/kubernetes-backend/api-report.md | 13 + plugins/kubernetes-backend/package.json | 1 + .../src/service/KubernetesBuilder.ts | 39 +- .../src/service/KubernetesProxy.test.ts | 342 ++++++++++++++++++ .../src/service/KubernetesProxy.ts | 286 +++++++++++++++ plugins/kubernetes-backend/src/types/types.ts | 8 + plugins/kubernetes-common/api-report.md | 10 + plugins/kubernetes-common/src/types.ts | 4 + 9 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 .changeset/rich-garlics-play.md create mode 100644 plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts create mode 100644 plugins/kubernetes-backend/src/service/KubernetesProxy.ts diff --git a/.changeset/rich-garlics-play.md b/.changeset/rich-garlics-play.md new file mode 100644 index 0000000000..5589a70e35 --- /dev/null +++ b/.changeset/rich-garlics-play.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Added Kubernetes proxy API route to backend Kubernetes plugin, allowing Backstage plugin developers to read/write new information from Kubernetes (if proper credentials are provided). diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index d51ec4c211..303aaa3017 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -209,8 +209,15 @@ export class KubernetesBuilder { // (undocumented) protected getObjectTypesToFetch(): ObjectToFetch[] | undefined; // (undocumented) + protected getProxyServices(): KubernetesProxyServices; + // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) + protected makeProxyRequest( + req: express.Request, + res: express.Response, + ): Promise; + // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) setDefaultClusterRefreshInterval(refreshInterval: Duration): this; @@ -322,6 +329,12 @@ export type KubernetesObjectTypes = | 'statefulsets' | 'daemonsets'; +// @alpha (undocumented) +export interface KubernetesProxyServices { + // (undocumented) + kcs: KubernetesClustersSupplier; +} + // @alpha export interface KubernetesServiceLocator { // (undocumented) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index acf28eeb36..95b3000069 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -56,6 +56,7 @@ "helmet": "^6.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", + "node-fetch": "^2.6.0", "morgan": "^1.10.0", "stream-buffers": "^3.0.2", "winston": "^3.2.1", diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 304885f3e2..c7f569267e 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -30,8 +30,12 @@ import { KubernetesFetcher, KubernetesServiceLocator, KubernetesObjectsProviderOptions, + KubernetesProxyServices, } from '../types/types'; import { KubernetesClientProvider } from './KubernetesClientProvider'; + +import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy'; + import { DEFAULT_OBJECTS, KubernetesFanOutHandler, @@ -76,12 +80,15 @@ export class KubernetesBuilder { private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; + private proxy: KubernetesProxy; static createBuilder(env: KubernetesEnvironment) { return new KubernetesBuilder(env); } - constructor(protected readonly env: KubernetesEnvironment) {} + constructor(protected readonly env: KubernetesEnvironment) { + this.proxy = new KubernetesProxy(env.logger); + } public async build(): KubernetesBuilderReturn { const logger = this.env.logger; @@ -273,6 +280,12 @@ 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)); + addResourceRoutesToRouter(router, catalogApi, objectsProvider); return router; @@ -325,4 +338,28 @@ export class KubernetesBuilder { return objectTypesToFetch; } + + protected async makeProxyRequest( + req: express.Request, + res: express.Response, + ) { + const services = this.getProxyServices(); + const proxyResponse: KubernetesProxyResponse = + await this.proxy.handleProxyRequest(services, req); + res.status(proxyResponse.code).json(proxyResponse.data); + } + + protected getProxyServices(): KubernetesProxyServices { + const kcs = + this.clusterSupplier ?? + this.buildClusterSupplier(this.defaultClusterRefreshInterval); + + if (!kcs) { + this.env.logger.error('could not find cluster supplier!'); + } + + return { + kcs, + }; + } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts new file mode 100644 index 0000000000..c692355fbb --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -0,0 +1,342 @@ +/* + * Copyright 2022 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 { 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 fetch from 'node-fetch'; + +describe('KubernetesProxy', () => { + let _clientMock: any; + let sut: KubernetesProxy; + + const buildEncodedRequest = ( + clustersHeader: any, + query: string, + body?: any, + ): Request => { + const encodedQuery = encodeURIComponent(query); + const encodedClusters = Buffer.from( + JSON.stringify(clustersHeader), + ).toString('base64'); + + const req = { + params: { + encodedQuery, + }, + header: (key: string) => { + let value: string = ''; + switch (key) { + case 'Content-Type': { + value = 'application/json'; + break; + } + case 'X-Kubernetes-Clusters': { + value = encodedClusters; + break; + } + default: { + break; + } + } + return value; + }, + } as unknown as Request; + + if (body) { + req.body = body; + } + + return req; + }; + + const buildProxyServicesWithClusters = ( + clusters: ClusterDetails[], + ): KubernetesProxyServices => { + const kcs: KubernetesClustersSupplier = { + getClusters: async () => { + return clusters; + }, + }; + + return { + kcs, + }; + }; + + beforeEach(() => { + jest.resetAllMocks(); + _clientMock = { + handleProxyRequest: jest.fn(), + }; + + sut = new KubernetesProxy(getVoidLogger()); + }); + + it('should return a 404 if no clusters are found', async () => { + const services = buildProxyServicesWithClusters([]); + const req = buildEncodedRequest({}, 'api'); + + const result = await sut.handleProxyRequest(services, req); + + expect(result.code).toEqual(404); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should match the response code of the Kubernetes response (single cluster)', async () => { + const services = buildProxyServicesWithClusters([ + { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + ]); + const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); + + const apiResponse = { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }; + + // @ts-ignore-next-line + (fetch as jest.MockedFunction).mockResolvedValue( + new Response(JSON.stringify(apiResponse), { + status: 299, + }), + ); + + const result = await sut.handleProxyRequest(services, req); + + 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([ + { + name: 'cluster1', + url: 'http://localhost:9998', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + { + name: 'cluster2', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + ]); + const req = buildEncodedRequest( + { cluster1: 'token', cluster2: 'token' }, + 'api', + ); + + const apiResponse1 = { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }; + + const apiResponse2 = { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Unauthorized', + reason: 'Unauthorized', + code: 401, + }; + + (fetch as jest.MockedFunction) + .mockResolvedValueOnce( + new Response(JSON.stringify(apiResponse1), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(apiResponse2), { + status: 401, + }), + ); + + const result = await sut.handleProxyRequest(services, req); + + expect(fetch).toBeCalledTimes(2); + expect(result.code).toEqual(200); + }); + + it('should pass the exact response data from Kubernetes (single cluster)', async () => { + const services = buildProxyServicesWithClusters([ + { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + ]); + const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); + + const apiResponse = { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }; + + // @ts-ignore-next-line + (fetch as jest.MockedFunction).mockResolvedValue( + new Response(JSON.stringify(apiResponse), { + status: 200, + }), + ); + + const result = await sut.handleProxyRequest(services, req); + + const resultString = JSON.stringify(result.data); + const expectedString = JSON.stringify({ + cluster1: { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }, + }); + + expect(fetch).toBeCalledTimes(1); + expect(resultString).toEqual(expectedString); + }); + + it('should pass the exact response data from Kubernetes (multi cluster)', async () => { + const services = buildProxyServicesWithClusters([ + { + name: 'cluster1', + url: 'http://localhost:9998', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + { + name: 'cluster2', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + skipTLSVerify: true, + }, + ]); + const req = buildEncodedRequest( + { cluster1: 'token', cluster2: 'token' }, + 'api', + ); + + const apiResponse1 = { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }; + + const apiResponse2 = { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Unauthorized', + reason: 'Unauthorized', + 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, + }), + ); + + const result = await sut.handleProxyRequest(services, req); + + const resultString = JSON.stringify(result.data); + const expectedString = JSON.stringify({ + cluster1: { + kind: 'APIVersions', + versions: ['v1'], + serverAddressByClientCIDRs: [ + { + clientCIDR: '0.0.0.0/0', + serverAddress: '192.168.0.1:3333', + }, + ], + }, + cluster2: { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Unauthorized', + reason: 'Unauthorized', + code: 401, + }, + }); + + 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 new file mode 100644 index 0000000000..e073ab3ec1 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -0,0 +1,286 @@ +/* + * Copyright 2022 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 { KubeConfig, bufferFromFileOrString } from '@kubernetes/client-node'; +import { Logger } from 'winston'; +import fetch from 'node-fetch'; +import * as https from 'https'; + +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'; + +export interface KubernetesProxyResponse { + code: number; + data: any; + cluster?: string; +} + +interface KubernetesProxyClusters { + [key: string]: string; +} + +export class KubernetesProxy { + constructor(protected readonly logger: Logger) {} + + public async handleProxyRequest( + services: KubernetesProxyServices, + req: Request, + ): Promise { + const krc = this.getKubernetesRequestedClusters(req); + + if (Object.keys(krc).length < 1) { + return { + code: ERROR_NOT_FOUND, + data: 'No clusters found!', + }; + } + + const details = await this.getClusterDetails(services.kcs, krc); + + if (details.length < 1) { + return { + code: ERROR_NOT_FOUND, + data: 'No clusters found!', + }; + } + + const responses = await Promise.all( + details.map(async d => { + const response = await this.makeRequestToCluster(d, req); + return response; + }), + ); + + const data: { [key: string]: any } = {}; + const codes: number[] = []; + + responses.forEach(kpr => { + if (kpr.cluster) { + data[kpr.cluster] = kpr.data; + codes.push(kpr.code); + } + }); + + const code = this.getBestResponseCode(codes); + + const res: KubernetesProxyResponse = { + code, + data, + }; + + return res; + } + + private getKubernetesRequestedClusters( + req: Request, + ): KubernetesProxyClusters { + const encodedClusters: string = + req.header(HEADER_KUBERNETES_CLUSTERS) ?? ''; + + if (!encodedClusters) { + return {}; + } + + try { + const decodedClusters = Buffer.from(encodedClusters, 'base64').toString(); + const clusters: KubernetesProxyClusters = JSON.parse(decodedClusters); + return clusters; + } catch (e: any) { + this.logger.debug( + `error with encoded cluster header: ${JSON.stringify(e)}`, + ); + } + return {}; + } + + private async getClusterDetails( + clusterSupplier: KubernetesClustersSupplier, + krc: KubernetesProxyClusters, + ): Promise { + const clusters = await clusterSupplier.getClusters(); + + const clusterNames = Object.keys(krc); + + const clusterDetails = clusters.filter(c => clusterNames.includes(c.name)); + + const clusterDetailsAuth = clusterDetails.map(c => { + const cAuth: ClusterDetails = Object.assign(c, { + serviceAccountToken: krc[c.name], + }); + return cAuth; + }); + + return clusterDetailsAuth; + } + + private getClusterURI(details: ClusterDetails): string { + const client = this.getKubeConfig(details); + return client.getCurrentCluster()?.server || ''; + } + + private async makeRequestToCluster( + details: ClusterDetails, + req: Request, + ): Promise { + const serverIP = this.getClusterURI(details); + if (!serverIP) { + return { + code: ERROR_INTERNAL_SERVER, + data: null, + }; + } + + const query = decodeURIComponent(req.params.encodedQuery) || ''; + const uri = `${serverIP}/${query}`; + + const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON; + + const res = await this.sendClusterRequest( + details, + uri, + req.method, + contentType, + req.body, + ); + + return res; + } + + private async sendClusterRequest( + details: ClusterDetails, + uri: string, + method: string, + contentType: string, + body?: any, + ): Promise { + const bearerToken = details.serviceAccountToken; + if (!bearerToken) { + return { + code: ERROR_BAD_REQUEST, + data: { + error: 'Invalid service account token', + }, + }; + } + + const reqData: any = { + method, + headers: { + 'Content-Type': contentType, + Authorization: `Bearer ${bearerToken}`, + }, + }; + + if (!details.skipTLSVerify) { + if (details.caData) { + 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', + }, + }; + } + } + + if (body && Object.keys(body).length > 0) { + reqData.body = JSON.stringify(body); + } + + try { + const req = await fetch(uri, reqData); + + let res; + if (contentType.includes(APPLICATION_JSON)) { + res = await req.json(); + } else { + res = await req.text(); + } + + const proxyResponse: KubernetesProxyResponse = { + code: req.status, + data: res, + cluster: details.name, + }; + + return proxyResponse; + } catch (e: any) { + return { + code: ERROR_INTERNAL_SERVER, + data: e, + cluster: details.name, + }; + } + } + + private getKubeConfig(clusterDetails: ClusterDetails): KubeConfig { + const cluster = { + name: clusterDetails.name, + server: clusterDetails.url, + skipTLSVerify: clusterDetails.skipTLSVerify, + caData: clusterDetails.caData, + }; + + const user = { + name: CLUSTER_USER_NAME, + token: clusterDetails.serviceAccountToken, + }; + + const context = { + name: clusterDetails.name, + user: user.name, + cluster: cluster.name, + }; + + const kc = new KubeConfig(); + if (clusterDetails.serviceAccountToken) { + kc.loadFromOptions({ + clusters: [cluster], + users: [user], + contexts: [context], + currentContext: context.name, + }); + } else { + kc.loadFromDefault(); + } + + return kc; + } + + private getBestResponseCode(codes: number[]): number { + const sorted = codes.sort(); + return sorted[0] ?? ERROR_INTERNAL_SERVER; + } +} diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index ed43935202..0af89b4d12 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -280,3 +280,11 @@ 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 8509fa97cc..21f8cd86f5 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -193,6 +193,16 @@ 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 3feddd7f4c..b1ed9165e2 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -258,3 +258,7 @@ export interface ClientPodStatus { memory: ClientCurrentResourceUsage; containers: ClientContainerStatus[]; } + +export interface KubernetesProxyClusters { + [key: string]: string; +} From e862980c57037018ec6efc52ade31c5221930850 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Fri, 28 Oct 2022 18:58:13 -0500 Subject: [PATCH 02/16] 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 From 9fb0d696b88a92a8e5204d3d89291f67f460d8d7 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Tue, 1 Nov 2022 14:28:58 -0500 Subject: [PATCH 03/16] feat: Remove multi-cluster support per latest RFC version Signed-off-by: Carlos Esteban Lopez --- plugins/kubernetes-backend/api-report.md | 31 +-- plugins/kubernetes-backend/package.json | 3 +- .../src/service/KubernetesBuilder.ts | 40 +-- .../src/service/KubernetesProxy.test.ts | 241 +++--------------- .../src/service/KubernetesProxy.ts | 228 ++++++----------- yarn.lock | 12 +- 6 files changed, 159 insertions(+), 396 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index f5b8ff85b7..827ec5e74c 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -22,10 +22,12 @@ 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 type { RequestHandler } from 'express'; import { TokenCredential } from '@azure/identity'; +// @alpha (undocumented) +export const APPLICATION_JSON: string; + // @alpha (undocumented) export interface AWSClusterDetails extends ClusterDetails { // (undocumented) @@ -144,6 +146,9 @@ export class GoogleServiceAccountAuthTranslator ): Promise; } +// @alpha (undocumented) +export const HEADER_KUBERNETES_CLUSTER: string; + // @alpha (undocumented) export interface KubernetesAuthTranslator { // (undocumented) @@ -227,11 +232,6 @@ export class KubernetesBuilder { // (undocumented) protected getServiceLocatorMethod(): ServiceLocatorMethod; // (undocumented) - protected makeProxyRequest( - req: express.Request, - res: express.Response, - ): Promise; - // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) setDefaultClusterRefreshInterval(refreshInterval: Duration): this; @@ -251,6 +251,7 @@ export type KubernetesBuilderReturn = Promise<{ clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; + proxy: KubernetesProxy; objectsProvider: KubernetesObjectsProvider; serviceLocator: KubernetesServiceLocator; }>; @@ -349,22 +350,14 @@ export type KubernetesObjectTypes = export class KubernetesProxy { constructor(logger: Logger); // (undocumented) - handleProxyRequest( - req: Request_2, - clusterSupplier: KubernetesClustersSupplier, - ): Promise; + get clustersSupplier(): KubernetesClustersSupplier; + set clustersSupplier(clustersSupplier: KubernetesClustersSupplier); // (undocumented) protected readonly logger: Logger; -} - -// @alpha (undocumented) -export interface KubernetesProxyResponse { // (undocumented) - cluster?: string; + static readonly PROXY_PATH: string; // (undocumented) - code: number; - // (undocumented) - data: any; + proxyRequestHandler: RequestHandler; } // @alpha diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index d1352a21dc..d5ab468790 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -44,6 +44,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@google-cloud/container": "^4.0.0", + "@jest-mock/express": "^2.0.1", "@kubernetes/client-node": "0.17.0", "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", @@ -58,7 +59,7 @@ "lodash": "^4.17.21", "luxon": "^3.0.0", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.7", "stream-buffers": "^3.0.2", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index ff79bd83de..3ac27decc8 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -40,7 +40,7 @@ import { KubernetesFanOutHandler, } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; -import { KubernetesProxy, KubernetesProxyResponse } from './KubernetesProxy'; +import { KubernetesProxy } from './KubernetesProxy'; /** * @@ -62,6 +62,7 @@ export type KubernetesBuilderReturn = Promise<{ clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; + proxy: KubernetesProxy; objectsProvider: KubernetesObjectsProvider; serviceLocator: KubernetesServiceLocator; }>; @@ -107,8 +108,12 @@ export class KubernetesBuilder { const fetcher = this.getFetcher(); + const proxy = this.getProxy(); + const clusterSupplier = this.getClusterSupplier(); + proxy.clustersSupplier = clusterSupplier; + const serviceLocator = this.getServiceLocator(); const objectsProvider = this.getObjectsProvider({ @@ -129,6 +134,7 @@ export class KubernetesBuilder { clusterSupplier, customResources, fetcher, + proxy, objectsProvider, router, serviceLocator, @@ -292,12 +298,12 @@ export class KubernetesBuilder { }); }); - 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)); + if (typeof proxy?.proxyRequestHandler === 'function') { + router.get(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); + router.post(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); + router.put(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); + router.patch(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); + router.delete(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); } addResourceRoutesToRouter(router, catalogApi, objectsProvider); @@ -381,24 +387,4 @@ export class KubernetesBuilder { protected getProxy() { return this.proxy ?? this.buildProxy(); } - - protected async makeProxyRequest( - req: express.Request, - res: express.Response, - ) { - const supplier = this.getClustersSupplier(); - const proxy = this.getProxy(); - - const proxyResponse: KubernetesProxyResponse = - await proxy.handleProxyRequest(req, supplier); - - res.status(proxyResponse.code).json(proxyResponse.data); - } - - private getClustersSupplier(): KubernetesClustersSupplier { - return ( - this.clusterSupplier ?? - 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 b9bb1d425f..adf9357bb3 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -22,50 +22,38 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; -import { KubernetesProxy } from './KubernetesProxy'; +import { + APPLICATION_JSON, + HEADER_KUBERNETES_CLUSTER, + KubernetesProxy, +} from './KubernetesProxy'; import { NotFoundError } from '@backstage/errors'; +import { getMockReq, getMockRes } from '@jest-mock/express'; describe('KubernetesProxy', () => { let proxy: KubernetesProxy; const worker = setupServer(); setupRequestMockHandlers(worker); - const buildEncodedRequest = ( - clustersHeader: any, - query: string, - body?: unknown, - ): Request => { - const encodedQuery = encodeURIComponent(query); - const encodedClusters = Buffer.from( - JSON.stringify(clustersHeader), - ).toString('base64'); - - const req = { + const buildMockRequest = (clusterName: any, path: string): Request => { + const req = getMockReq({ params: { - encodedQuery, + path, }, - header: (key: string) => { - let value: string = ''; + header: jest.fn((key: string) => { switch (key) { case 'Content-Type': { - value = 'application/json'; - break; + return APPLICATION_JSON; } - case 'X-Kubernetes-Clusters': { - value = encodedClusters; - break; + case HEADER_KUBERNETES_CLUSTER: { + return clusterName; } default: { - break; + return ''; } } - return value; - }, - } as unknown as Request; - - if (body) { - req.body = body; - } + }), + }); return req; }; @@ -83,15 +71,17 @@ describe('KubernetesProxy', () => { }); it('should return a ERROR_NOT_FOUND if no clusters are found', async () => { - const clustersSupplier = buildClustersSupplierWithClusters([]); - const req = buildEncodedRequest({}, 'api'); + proxy.clustersSupplier = buildClustersSupplierWithClusters([]); - await expect( - proxy.handleProxyRequest(req, clustersSupplier), - ).rejects.toThrow(NotFoundError); + const req = buildMockRequest('test', 'api'); + const { res, next } = getMockRes(); + + await expect(proxy.proxyRequestHandler(req, res, next)).rejects.toThrow( + NotFoundError, + ); }); - it('should match the response code of the Kubernetes response (single cluster)', async () => { + it('should match the response code of the Kubernetes response', async () => { const clusters: ClusterDetails[] = [ { name: 'cluster1', @@ -102,8 +92,10 @@ describe('KubernetesProxy', () => { }, ]; - const clustersSupplier = buildClustersSupplierWithClusters(clusters); - const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); + proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters); + + const req = buildMockRequest('cluster1', 'api'); + const { res: response, next } = getMockRes(); const apiResponse = { kind: 'APIVersions', @@ -117,76 +109,18 @@ describe('KubernetesProxy', () => { }; worker.use( - rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) => res(ctx.status(299), ctx.body(JSON.stringify(apiResponse))), ), ); - const result = await proxy.handleProxyRequest(req, clustersSupplier); + await proxy.proxyRequestHandler(req, response, next); - expect(result.code).toEqual(299); + expect(response.status).toHaveBeenCalledWith(299); + expect(response.json).toHaveBeenCalledWith(apiResponse); }); - it('should match the response code of the best Kubernetes response (multi cluster)', async () => { - const clusters: ClusterDetails[] = [ - { - name: 'cluster1', - url: 'http://localhost:9998', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', - skipTLSVerify: true, - }, - { - name: 'cluster2', - url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', - skipTLSVerify: true, - }, - ]; - - const clustersSupplier = buildClustersSupplierWithClusters(clusters); - const req = buildEncodedRequest( - { cluster1: 'token', cluster2: 'token' }, - 'api', - ); - - const apiResponse1 = { - kind: 'APIVersions', - versions: ['v1'], - serverAddressByClientCIDRs: [ - { - clientCIDR: '0.0.0.0/0', - serverAddress: '192.168.0.1:3333', - }, - ], - }; - - const apiResponse2 = { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'Unauthorized', - reason: 'Unauthorized', - code: 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 proxy.handleProxyRequest(req, clustersSupplier); - - expect(result.code).toEqual(200); - }); - - it('should pass the exact response data from Kubernetes (single cluster)', async () => { + it('should pass the exact response data from Kubernetes', async () => { const clusters: ClusterDetails[] = [ { name: 'cluster1', @@ -197,8 +131,10 @@ describe('KubernetesProxy', () => { }, ]; - const clustersSupplier = buildClustersSupplierWithClusters(clusters); - const req = buildEncodedRequest({ cluster1: 'token' }, 'api'); + proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters); + + const req = buildMockRequest('cluster1', 'api'); + const { res: response, next } = getMockRes(); const apiResponse = { kind: 'APIVersions', @@ -212,109 +148,14 @@ describe('KubernetesProxy', () => { }; worker.use( - rest.get(`${clusters[0].url}/${req.params.encodedQuery}`, (_, res, ctx) => + rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) => res(ctx.status(200), ctx.body(JSON.stringify(apiResponse))), ), ); - const result = await proxy.handleProxyRequest(req, clustersSupplier); + await proxy.proxyRequestHandler(req, response, next); - const resultString = JSON.stringify(result.data); - const expectedString = JSON.stringify({ - cluster1: { - kind: 'APIVersions', - versions: ['v1'], - serverAddressByClientCIDRs: [ - { - clientCIDR: '0.0.0.0/0', - serverAddress: '192.168.0.1:3333', - }, - ], - }, - }); - - expect(resultString).toEqual(expectedString); - }); - - it('should pass the exact response data from Kubernetes (multi cluster)', async () => { - const clusters: ClusterDetails[] = [ - { - name: 'cluster1', - url: 'http://localhost:9998', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', - skipTLSVerify: true, - }, - { - name: 'cluster2', - url: 'http://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', - skipTLSVerify: true, - }, - ]; - - const clustersSupplier = buildClustersSupplierWithClusters(clusters); - const req = buildEncodedRequest( - { cluster1: 'token', cluster2: 'token' }, - 'api', - ); - - const apiResponse1 = { - kind: 'APIVersions', - versions: ['v1'], - serverAddressByClientCIDRs: [ - { - clientCIDR: '0.0.0.0/0', - serverAddress: '192.168.0.1:3333', - }, - ], - }; - - const apiResponse2 = { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'Unauthorized', - reason: 'Unauthorized', - code: 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 proxy.handleProxyRequest(req, clustersSupplier); - - const resultString = JSON.stringify(result.data); - const expectedString = JSON.stringify({ - cluster1: { - kind: 'APIVersions', - versions: ['v1'], - serverAddressByClientCIDRs: [ - { - clientCIDR: '0.0.0.0/0', - serverAddress: '192.168.0.1:3333', - }, - ], - }, - cluster2: { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'Unauthorized', - reason: 'Unauthorized', - code: 401, - }, - }); - - expect(resultString).toEqual(expectedString); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith(apiResponse); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index dea4786820..d1a34cb2cf 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -19,151 +19,105 @@ import { 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 fetch, { RequestInit, Response } from 'node-fetch'; import { Logger } from 'winston'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; -import type { Request } from 'express'; +import type { Request as ExpressRequest, RequestHandler } from 'express'; + +/** + * + * @alpha + */ +export const APPLICATION_JSON: string = 'application/json'; + +/** + * + * @alpha + */ +export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; const HEADER_CONTENT_TYPE: string = 'Content-Type'; -const APPLICATION_JSON: string = 'application/json'; - -const HEADER_KUBERNETES_CLUSTERS: string = 'X-Kubernetes-Clusters'; - -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 { + private _clustersSupplier?: KubernetesClustersSupplier; + + static readonly PROXY_PATH: string = '/proxy/:path(*)'; + constructor(protected readonly logger: Logger) {} - public async handleProxyRequest( - req: Request, - clusterSupplier: KubernetesClustersSupplier, - ): Promise { - const requestedClusters = this.getKubernetesRequestedClusters(req); + public proxyRequestHandler: RequestHandler = async (req, res) => { + const requestedCluster = this.getKubernetesRequestedCluster(req); - if (Object.keys(requestedClusters).length < 1) { - this.logger.error(`No clusters found`); - throw new NotFoundError('No clusters found!'); + const clusterDetails = await this.getClusterDetails(requestedCluster); + + const response = await this.makeRequestToCluster(clusterDetails, req); + + const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON; + + const data = contentType.includes(APPLICATION_JSON) + ? await response.json() + : await response.text(); + + res.status(response.status).json(data); + }; + + public get clustersSupplier(): KubernetesClustersSupplier { + if (this._clustersSupplier ? false : this._clustersSupplier ?? true) { + throw new ConflictError("Missing Proxy's Clusters Supplier"); } - const clusterDetails = await this.getClusterDetails( - clusterSupplier, - requestedClusters, - ); - - if (clusterDetails.length < 1) { - this.logger.error(`No clusters found`); - throw new NotFoundError('No clusters found!'); - } - - const responses = await Promise.all( - clusterDetails.map(async clusterDetail => { - const response = await this.makeRequestToCluster(clusterDetail, req); - return response; - }), - ); - - const data: { [key: string]: any } = {}; - const codes: number[] = []; - - responses.forEach(kpr => { - if (kpr.cluster) { - data[kpr.cluster] = kpr.data; - codes.push(kpr.code); - } - }); - - const code = this.getBestResponseCode(codes); - - const res: KubernetesProxyResponse = { - code, - data, - }; - - return res; + return this._clustersSupplier as KubernetesClustersSupplier; } - private getKubernetesRequestedClusters( - req: Request, - ): KubernetesProxyClusters { - const encodedClusters: string = - req.header(HEADER_KUBERNETES_CLUSTERS) ?? ''; + public set clustersSupplier(clustersSupplier) { + this._clustersSupplier = clustersSupplier; + } - if (!encodedClusters) { - return {}; + private getKubernetesRequestedCluster(req: ExpressRequest): string { + const requestedClusterName: string = + req.header(HEADER_KUBERNETES_CLUSTER) ?? ''; + + if (!requestedClusterName) { + this.logger.error(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`); + throw new InputError(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`); } - try { - const decodedClusters = Buffer.from(encodedClusters, 'base64').toString(); - const clusters: KubernetesProxyClusters = JSON.parse(decodedClusters); - return clusters; - } catch (e: any) { - this.logger.debug( - `error with encoded cluster header: ${stringifyError(e)}`, - ); - } - return {}; + return requestedClusterName; } private async getClusterDetails( - clusterSupplier: KubernetesClustersSupplier, - requestedClusters: KubernetesProxyClusters, - ): Promise { - const clusters = await clusterSupplier.getClusters(); + requestedCluster: string, + ): Promise { + const clusters = await this.clustersSupplier.getClusters(); - const clusterNames = Object.keys(requestedClusters); + const clusterDetail = clusters.find(cluster => + requestedCluster.includes(cluster.name), + ); - const clusterDetails = clusters.filter(c => clusterNames.includes(c.name)); + if (clusterDetail ? false : clusterDetail ?? true) { + this.logger.error( + `Cluster ${requestedCluster} details not found in config`, + ); - const clusterDetailsAuth = clusterDetails.map(c => { - const cAuth: ClusterDetails = Object.assign(c, { - serviceAccountToken: requestedClusters[c.name], - }); - return cAuth; - }); + throw new NotFoundError("Cluster's detail not found"); + } - return clusterDetailsAuth; + return clusterDetail as ClusterDetails; } private getClusterURI(details: ClusterDetails): string { - const client = this.getKubeConfig(details); - return client.getCurrentCluster()?.server || ''; - } - - private async makeRequestToCluster( - details: ClusterDetails, - req: Request, - ): Promise { - const serverURI = this.getClusterURI(details); + const serverURI = this.getKubeConfig(details)?.getCurrentCluster()?.server; if (!serverURI) { this.logger.error(`Cluster ${details.name} details IP error`); @@ -171,27 +125,26 @@ export class KubernetesProxy { throw new ConflictError('Cluster detail error'); } - const query = decodeURIComponent(req.params.encodedQuery) || ''; - const uri = `${serverURI}/${query}`; + return serverURI; + } - const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON; + private async makeRequestToCluster( + details: ClusterDetails, + req: ExpressRequest, + ): Promise { + const serverURI = this.getClusterURI(details); - return await this.sendClusterRequest( - details, - uri, - req.method, - contentType, - req.body, - ); + const path = decodeURIComponent(req.params.path) || ''; + const uri = `${serverURI}/${path}`; + + return await this.sendClusterRequest(details, uri, req); } private async sendClusterRequest( details: ClusterDetails, uri: string, - method: string, - contentType: string, - body?: any, - ): Promise { + req: ExpressRequest, + ): Promise { const bearerToken = details.serviceAccountToken; if (!bearerToken) { @@ -200,12 +153,11 @@ export class KubernetesProxy { throw new AuthenticationError('Invalid service account token'); } + const { method, headers, body } = req; + const reqData: RequestInit = { method, - headers: { - 'Content-Type': contentType, - Authorization: `Bearer ${bearerToken}`, - }, + headers: headers as { [key: string]: string }, }; if (!details.skipTLSVerify) { @@ -214,6 +166,7 @@ export class KubernetesProxy { reqData.agent = new https.Agent({ ca }); } else { this.logger.error('could not find CA certificate!'); + throw new InputError( 'Invalid CA certificate configured within Backstage', ); @@ -225,23 +178,7 @@ export class KubernetesProxy { } try { - const res = await fetch(uri, reqData); - - let data: string | any; - - if (contentType.includes(APPLICATION_JSON)) { - data = await res.json(); - } else { - data = await res.text(); - } - - const proxyResponse: KubernetesProxyResponse = { - code: res.status, - data, - cluster: details.name, - }; - - return proxyResponse; + return fetch(uri, reqData); } catch (e: any) { throw new ForwardedError(`Cluster ${details.name} request error`, e); } @@ -281,9 +218,4 @@ export class KubernetesProxy { return kubeConfig; } - - private getBestResponseCode(codes: number[]): number { - const sorted = codes.sort(); - return sorted[0] ?? ERROR_INTERNAL_SERVER; - } } diff --git a/yarn.lock b/yarn.lock index ae10e493f5..653eeec7ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5973,6 +5973,7 @@ __metadata: "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" "@google-cloud/container": ^4.0.0 + "@jest-mock/express": ^2.0.1 "@kubernetes/client-node": 0.17.0 "@types/aws4": ^1.5.1 "@types/express": ^4.17.6 @@ -5990,7 +5991,7 @@ __metadata: luxon: ^3.0.0 morgan: ^1.10.0 msw: ^0.48.0 - node-fetch: ^2.6.0 + node-fetch: ^2.6.7 stream-buffers: ^3.0.2 supertest: ^6.1.3 winston: ^3.2.1 @@ -9250,6 +9251,15 @@ __metadata: languageName: node linkType: hard +"@jest-mock/express@npm:^2.0.1": + version: 2.0.1 + resolution: "@jest-mock/express@npm:2.0.1" + dependencies: + "@types/express": ^4.17.13 + checksum: 999ea0a953b3e911d0b8ecc4cb3b78ac252b3354832db9659be6754094410b10508f1688f3e623ab13fe40c28d89fd5a81699e6acd148dd8128abec4e81f3f14 + languageName: node + linkType: hard + "@jest/console@npm:^29.0.3": version: 29.0.3 resolution: "@jest/console@npm:29.0.3" From b372602d8c55abd3eafe0340b1fbc0c6a0971dd7 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Tue, 1 Nov 2022 14:39:58 -0500 Subject: [PATCH 04/16] fix: Remove unused type in @backstage/kubernetes-common Signed-off-by: Carlos Esteban Lopez --- .../kubernetes-backend/src/service/KubernetesProxy.test.ts | 4 ++-- plugins/kubernetes-common/api-report.md | 6 ------ plugins/kubernetes-common/src/types.ts | 5 ----- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index adf9357bb3..530cba2573 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -17,6 +17,8 @@ import 'buffer'; import { getVoidLogger } from '@backstage/backend-common'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { NotFoundError } from '@backstage/errors'; +import { getMockReq, getMockRes } from '@jest-mock/express'; import { Request } from 'express'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -27,8 +29,6 @@ import { HEADER_KUBERNETES_CLUSTER, KubernetesProxy, } from './KubernetesProxy'; -import { NotFoundError } from '@backstage/errors'; -import { getMockReq, getMockRes } from '@jest-mock/express'; describe('KubernetesProxy', () => { let proxy: KubernetesProxy; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index c504558b00..8509fa97cc 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -193,12 +193,6 @@ export interface KubernetesFetchError { statusCode?: number; } -// @public (undocumented) -export interface KubernetesProxyClusters { - // (undocumented) - [key: string]: string; -} - // @public (undocumented) export interface KubernetesRequestAuth { // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index d0bf7757fb..3feddd7f4c 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -258,8 +258,3 @@ export interface ClientPodStatus { memory: ClientCurrentResourceUsage; containers: ClientContainerStatus[]; } - -/** @public */ -export interface KubernetesProxyClusters { - [key: string]: string; -} From 718f66f235b86537d2cd29342d9342efbc6739be Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Wed, 9 Nov 2022 16:49:26 -0500 Subject: [PATCH 05/16] fix: Handle skipTLSVerify properly Signed-off-by: Carlos Esteban Lopez --- .../src/service/KubernetesProxy.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index d1a34cb2cf..9da0068072 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -160,17 +160,17 @@ export class KubernetesProxy { headers: headers as { [key: string]: string }, }; - if (!details.skipTLSVerify) { - if (details.caData) { - const ca = bufferFromFileOrString('', details.caData)?.toString() || ''; - reqData.agent = new https.Agent({ ca }); - } else { - this.logger.error('could not find CA certificate!'); + if (details.skipTLSVerify) { + reqData.agent = new https.Agent({ rejectUnauthorized: false }); + } else if (details.caData) { + const ca = bufferFromFileOrString('', details.caData)?.toString() || ''; + reqData.agent = new https.Agent({ ca }); + } else { + this.logger.error('could not find CA certificate!'); - throw new InputError( - 'Invalid CA certificate configured within Backstage', - ); - } + throw new InputError( + 'Invalid CA certificate configured within Backstage', + ); } if (body && Object.keys(body).length > 0) { From 033a717b0abb86781361e23b4bfc8384263e1ef8 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Wed, 9 Nov 2022 18:43:18 -0500 Subject: [PATCH 06/16] fix: Update test urls to https Signed-off-by: Carlos Esteban Lopez --- .../kubernetes-backend/src/service/KubernetesProxy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 530cba2573..a72681fc73 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -85,7 +85,7 @@ describe('KubernetesProxy', () => { const clusters: ClusterDetails[] = [ { name: 'cluster1', - url: 'http://localhost:9999', + url: 'https://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', skipTLSVerify: true, @@ -124,7 +124,7 @@ describe('KubernetesProxy', () => { const clusters: ClusterDetails[] = [ { name: 'cluster1', - url: 'http://localhost:9999', + url: 'https://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', skipTLSVerify: true, From a05aa6cc69dbd3dec86e24ae63a43e08179c4af4 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Tue, 15 Nov 2022 12:16:00 -0500 Subject: [PATCH 07/16] Update plugins/kubernetes-backend/src/service/KubernetesProxy.ts Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- plugins/kubernetes-backend/src/service/KubernetesProxy.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 9da0068072..b41c421673 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -145,14 +145,6 @@ export class KubernetesProxy { uri: string, req: ExpressRequest, ): Promise { - const bearerToken = details.serviceAccountToken; - - if (!bearerToken) { - this.logger.error('Invalid service account token'); - - throw new AuthenticationError('Invalid service account token'); - } - const { method, headers, body } = req; const reqData: RequestInit = { From e4af45dc470674260f4963ec4c9f41497ebead01 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Tue, 15 Nov 2022 12:17:44 -0500 Subject: [PATCH 08/16] Update plugins/kubernetes-backend/src/service/KubernetesProxy.ts Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- plugins/kubernetes-backend/src/service/KubernetesProxy.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index b41c421673..6cd6bd507e 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -157,12 +157,6 @@ export class KubernetesProxy { } else if (details.caData) { const ca = bufferFromFileOrString('', details.caData)?.toString() || ''; reqData.agent = new https.Agent({ ca }); - } else { - this.logger.error('could not find CA certificate!'); - - throw new InputError( - 'Invalid CA certificate configured within Backstage', - ); } if (body && Object.keys(body).length > 0) { From d936b2fecad0a42834f85e61c0e2ce65fe7bb33f Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Tue, 15 Nov 2022 12:19:35 -0500 Subject: [PATCH 09/16] Update plugins/kubernetes-backend/src/service/KubernetesProxy.ts Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- .../src/service/KubernetesProxy.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 6cd6bd507e..83e460f6e6 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -116,23 +116,11 @@ export class KubernetesProxy { return clusterDetail as ClusterDetails; } - private getClusterURI(details: ClusterDetails): string { - const serverURI = this.getKubeConfig(details)?.getCurrentCluster()?.server; - - if (!serverURI) { - this.logger.error(`Cluster ${details.name} details IP error`); - - throw new ConflictError('Cluster detail error'); - } - - return serverURI; - } - private async makeRequestToCluster( details: ClusterDetails, req: ExpressRequest, ): Promise { - const serverURI = this.getClusterURI(details); + const serverURI = details.url; const path = decodeURIComponent(req.params.path) || ''; const uri = `${serverURI}/${path}`; From edc9ed6952481fea82f9c5e7bf8c16f97df633c7 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Tue, 15 Nov 2022 12:21:09 -0500 Subject: [PATCH 10/16] Update plugins/kubernetes-backend/src/service/KubernetesProxy.ts Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- .../src/service/KubernetesProxy.ts | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 83e460f6e6..416b3a047b 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -158,38 +158,3 @@ export class KubernetesProxy { } } - private getKubeConfig(clusterDetails: ClusterDetails): KubeConfig { - const cluster = { - name: clusterDetails.name, - server: clusterDetails.url, - skipTLSVerify: clusterDetails.skipTLSVerify, - caData: clusterDetails.caData, - }; - - const user = { - name: CLUSTER_USER_NAME, - token: clusterDetails.serviceAccountToken, - }; - - const context = { - name: clusterDetails.name, - user: user.name, - cluster: cluster.name, - }; - - const kubeConfig = new KubeConfig(); - - if (clusterDetails.serviceAccountToken) { - kubeConfig.loadFromOptions({ - clusters: [cluster], - users: [user], - contexts: [context], - currentContext: context.name, - }); - } else { - kubeConfig.loadFromDefault(); - } - - return kubeConfig; - } -} From cead48f628a5c476acea0f48b94fa04d059dd289 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Tue, 15 Nov 2022 12:22:02 -0500 Subject: [PATCH 11/16] Update plugins/kubernetes-backend/src/service/KubernetesProxy.ts Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- plugins/kubernetes-backend/src/service/KubernetesProxy.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 416b3a047b..92f3c0ea6c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -63,13 +63,9 @@ export class KubernetesProxy { const response = await this.makeRequestToCluster(clusterDetails, req); - const contentType = req.header(HEADER_CONTENT_TYPE) || APPLICATION_JSON; + const data = await response.text(); - const data = contentType.includes(APPLICATION_JSON) - ? await response.json() - : await response.text(); - - res.status(response.status).json(data); + res.status(response.status).send(data); }; public get clustersSupplier(): KubernetesClustersSupplier { From 5f0b3a1d4f551acd587b1603a4c0598b057fb6f3 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Thu, 17 Nov 2022 16:55:51 -0500 Subject: [PATCH 12/16] refactor to use http-proxy-middleware Signed-off-by: Jamie Klassen --- plugins/kubernetes-backend/api-report.md | 22 +-- plugins/kubernetes-backend/package.json | 2 + .../src/service/KubernetesBuilder.ts | 30 ++-- .../src/service/KubernetesProxy.test.ts | 91 +++-------- .../src/service/KubernetesProxy.ts | 154 +++++++----------- yarn.lock | 4 +- 6 files changed, 115 insertions(+), 188 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 827ec5e74c..6e0c253f79 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -22,7 +22,7 @@ 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'; +import { RequestHandler } from 'http-proxy-middleware'; import { TokenCredential } from '@azure/identity'; // @alpha (undocumented) @@ -195,12 +195,16 @@ export class KubernetesBuilder { options: KubernetesObjectsProviderOptions, ): KubernetesObjectsProvider; // (undocumented) - protected buildProxy(): KubernetesProxy; + protected buildProxy( + logger: Logger, + clusterSupplier: KubernetesClustersSupplier, + ): KubernetesProxy; // (undocumented) protected buildRouter( objectsProvider: KubernetesObjectsProvider, clusterSupplier: KubernetesClustersSupplier, catalogApi: CatalogApi, + proxy: KubernetesProxy, ): express.Router; // (undocumented) protected buildServiceLocator( @@ -226,7 +230,10 @@ export class KubernetesBuilder { // (undocumented) protected getObjectTypesToFetch(): ObjectToFetch[] | undefined; // (undocumented) - protected getProxy(): KubernetesProxy; + protected getProxy( + logger: Logger, + clusterSupplier: KubernetesClustersSupplier, + ): KubernetesProxy; // (undocumented) protected getServiceLocator(): KubernetesServiceLocator; // (undocumented) @@ -348,14 +355,7 @@ export type KubernetesObjectTypes = // @alpha (undocumented) export class KubernetesProxy { - constructor(logger: Logger); - // (undocumented) - get clustersSupplier(): KubernetesClustersSupplier; - set clustersSupplier(clustersSupplier: KubernetesClustersSupplier); - // (undocumented) - protected readonly logger: Logger; - // (undocumented) - static readonly PROXY_PATH: string; + constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier); // (undocumented) proxyRequestHandler: RequestHandler; } diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index d5ab468790..9c6cfd0563 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -56,6 +56,7 @@ "express-promise-router": "^4.1.0", "fs-extra": "10.1.0", "helmet": "^6.0.0", + "http-proxy-middleware": "^2.0.6", "lodash": "^4.17.21", "luxon": "^3.0.0", "morgan": "^1.10.0", @@ -67,6 +68,7 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@types/aws4": "^1.5.1", + "@types/http-proxy-middleware": "^0.19.3", "aws-sdk-mock": "^5.2.1", "msw": "^0.48.0", "supertest": "^6.1.3" diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 3ac27decc8..a5e67ebe2a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -108,11 +108,9 @@ export class KubernetesBuilder { const fetcher = this.getFetcher(); - const proxy = this.getProxy(); - const clusterSupplier = this.getClusterSupplier(); - proxy.clustersSupplier = clusterSupplier; + const proxy = this.getProxy(logger, clusterSupplier); const serviceLocator = this.getServiceLocator(); @@ -128,6 +126,7 @@ export class KubernetesBuilder { objectsProvider, clusterSupplier, this.env.catalogApi, + proxy, ); return { @@ -252,8 +251,11 @@ export class KubernetesBuilder { throw new Error('not implemented'); } - protected buildProxy(): KubernetesProxy { - this.proxy = new KubernetesProxy(this.env.logger); + protected buildProxy( + logger: Logger, + clusterSupplier: KubernetesClustersSupplier, + ): KubernetesProxy { + this.proxy = new KubernetesProxy(logger, clusterSupplier); return this.proxy; } @@ -261,13 +263,12 @@ export class KubernetesBuilder { objectsProvider: KubernetesObjectsProvider, clusterSupplier: KubernetesClustersSupplier, catalogApi: CatalogApi, + proxy: KubernetesProxy, ): express.Router { const logger = this.env.logger; const router = Router(); router.use(express.json()); - const proxy = this.getProxy(); - // @deprecated router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; @@ -298,13 +299,7 @@ export class KubernetesBuilder { }); }); - if (typeof proxy?.proxyRequestHandler === 'function') { - router.get(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); - router.post(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); - router.put(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); - router.patch(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); - router.delete(KubernetesProxy.PROXY_PATH, proxy.proxyRequestHandler); - } + router.use('/proxy', proxy.proxyRequestHandler); addResourceRoutesToRouter(router, catalogApi, objectsProvider); @@ -384,7 +379,10 @@ export class KubernetesBuilder { return objectTypesToFetch; } - protected getProxy() { - return this.proxy ?? this.buildProxy(); + protected getProxy( + logger: Logger, + clusterSupplier: KubernetesClustersSupplier, + ) { + return this.proxy ?? this.buildProxy(logger, clusterSupplier); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index a72681fc73..5d4eaa795b 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -16,12 +16,14 @@ import 'buffer'; import { getVoidLogger } from '@backstage/backend-common'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { NotFoundError } from '@backstage/errors'; import { getMockReq, getMockRes } from '@jest-mock/express'; -import { Request } from 'express'; +import type { Request } from 'express'; +import express from 'express'; +import request from 'supertest'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { @@ -33,6 +35,7 @@ import { describe('KubernetesProxy', () => { let proxy: KubernetesProxy; const worker = setupServer(); + setupRequestMockHandlers(worker); const buildMockRequest = (clusterName: any, path: string): Request => { @@ -58,20 +61,17 @@ describe('KubernetesProxy', () => { return req; }; - const buildClustersSupplierWithClusters = ( - clusters: ClusterDetails[], - ): KubernetesClustersSupplier => ({ - getClusters: async () => { - return clusters; - }, - }); + const clusterSupplier: jest.Mocked = { + getClusters: jest.fn(), + }; beforeEach(() => { - proxy = new KubernetesProxy(getVoidLogger()); + jest.resetAllMocks(); + proxy = new KubernetesProxy(getVoidLogger(), clusterSupplier); }); it('should return a ERROR_NOT_FOUND if no clusters are found', async () => { - proxy.clustersSupplier = buildClustersSupplierWithClusters([]); + clusterSupplier.getClusters.mockResolvedValue([]); const req = buildMockRequest('test', 'api'); const { res, next } = getMockRes(); @@ -81,22 +81,7 @@ describe('KubernetesProxy', () => { ); }); - it('should match the response code of the Kubernetes response', async () => { - const clusters: ClusterDetails[] = [ - { - name: 'cluster1', - url: 'https://localhost:9999', - serviceAccountToken: 'token', - authProvider: 'serviceAccount', - skipTLSVerify: true, - }, - ]; - - proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters); - - const req = buildMockRequest('cluster1', 'api'); - const { res: response, next } = getMockRes(); - + it('should pass the exact response from Kubernetes', async () => { const apiResponse = { kind: 'APIVersions', versions: ['v1'], @@ -108,54 +93,28 @@ describe('KubernetesProxy', () => { ], }; - worker.use( - rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) => - res(ctx.status(299), ctx.body(JSON.stringify(apiResponse))), - ), - ); - - await proxy.proxyRequestHandler(req, response, next); - - expect(response.status).toHaveBeenCalledWith(299); - expect(response.json).toHaveBeenCalledWith(apiResponse); - }); - - it('should pass the exact response data from Kubernetes', async () => { - const clusters: ClusterDetails[] = [ + clusterSupplier.getClusters.mockResolvedValue([ { name: 'cluster1', url: 'https://localhost:9999', - serviceAccountToken: 'token', + serviceAccountToken: '', authProvider: 'serviceAccount', - skipTLSVerify: true, }, - ]; - - proxy.clustersSupplier = buildClustersSupplierWithClusters(clusters); - - const req = buildMockRequest('cluster1', 'api'); - const { res: response, next } = getMockRes(); - - const apiResponse = { - kind: 'APIVersions', - versions: ['v1'], - serverAddressByClientCIDRs: [ - { - clientCIDR: '0.0.0.0/0', - serverAddress: '192.168.0.1:3333', - }, - ], - }; - + ] as ClusterDetails[]); + const app = express().use('/mountpath', proxy.proxyRequestHandler); + const requestPromise = request(app) + .get('/mountpath/api') + .set(HEADER_KUBERNETES_CLUSTER, 'cluster1'); worker.use( - rest.get(`${clusters[0].url}/${req.params.path}`, (_, res, ctx) => - res(ctx.status(200), ctx.body(JSON.stringify(apiResponse))), + rest.get('https://localhost:9999/api', (_, res, ctx) => + res(ctx.status(299), ctx.json(apiResponse)), ), + rest.all(requestPromise.url, (req, _res, _ctx) => req.passthrough()), ); - await proxy.proxyRequestHandler(req, response, next); + const response = await requestPromise; - expect(response.status).toHaveBeenCalledWith(200); - expect(response.json).toHaveBeenCalledWith(apiResponse); + expect(response.status).toEqual(299); + expect(response.body).toStrictEqual(apiResponse); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 92f3c0ea6c..7cc3a97416 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -13,21 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - AuthenticationError, - ConflictError, - ForwardedError, - InputError, - NotFoundError, -} from '@backstage/errors'; -import { bufferFromFileOrString, KubeConfig } from '@kubernetes/client-node'; -import * as https from 'https'; -import fetch, { RequestInit, Response } from 'node-fetch'; +import { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; +import { bufferFromFileOrString } from '@kubernetes/client-node'; import { Logger } from 'winston'; +import { ErrorResponseBody, serializeError } from '@backstage/errors'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; -import type { Request as ExpressRequest, RequestHandler } from 'express'; +import type { Request } from 'express'; +import { + RequestHandler, + Options, + createProxyMiddleware, +} from 'http-proxy-middleware'; /** * @@ -41,52 +39,66 @@ export const APPLICATION_JSON: string = 'application/json'; */ export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; -const HEADER_CONTENT_TYPE: string = 'Content-Type'; - -const CLUSTER_USER_NAME: string = 'backstage'; - /** * * @alpha */ export class KubernetesProxy { - private _clustersSupplier?: KubernetesClustersSupplier; + constructor( + private readonly logger: Logger, + private readonly clusterSupplier: KubernetesClustersSupplier, + ) {} - static readonly PROXY_PATH: string = '/proxy/:path(*)'; - - constructor(protected readonly logger: Logger) {} - - public proxyRequestHandler: RequestHandler = async (req, res) => { + public proxyRequestHandler: RequestHandler = async (req, res, next) => { const requestedCluster = this.getKubernetesRequestedCluster(req); const clusterDetails = await this.getClusterDetails(requestedCluster); - const response = await this.makeRequestToCluster(clusterDetails, req); + const clusterUrl = new URL(clusterDetails.url); + const options = { + logProvider: () => this.logger, + secure: !clusterDetails.skipTLSVerify, + target: { + protocol: clusterUrl.protocol, + host: clusterUrl.hostname, + port: clusterUrl.port, + ca: bufferFromFileOrString('', clusterDetails.caData)?.toString(), + }, + pathRewrite: { [`^${req.baseUrl}`]: '' }, + onError: (error: Error) => { + const wrappedError = new ForwardedError( + `Cluster '${requestedCluster}' request error`, + error, + ); - const data = await response.text(); + this.logger.error(wrappedError); - res.status(response.status).send(data); + const body: ErrorResponseBody = { + error: serializeError(wrappedError, { + includeStack: process.env.NODE_ENV === 'development', + }), + request: { method: req.method, url: req.originalUrl }, + response: { statusCode: 500 }, + }; + + res.status(500).json(body); + }, + } as Options; + + // Probably too risky without permissions protecting this endpoint + // if (clusterDetails.serviceAccountToken) { + // options.headers = { + // Authorization: `Bearer ${clusterDetails.serviceAccountToken}`, + // }; + // } + createProxyMiddleware(options)(req, res, next); }; - public get clustersSupplier(): KubernetesClustersSupplier { - if (this._clustersSupplier ? false : this._clustersSupplier ?? true) { - throw new ConflictError("Missing Proxy's Clusters Supplier"); - } - - return this._clustersSupplier as KubernetesClustersSupplier; - } - - public set clustersSupplier(clustersSupplier) { - this._clustersSupplier = clustersSupplier; - } - - private getKubernetesRequestedCluster(req: ExpressRequest): string { - const requestedClusterName: string = - req.header(HEADER_KUBERNETES_CLUSTER) ?? ''; + private getKubernetesRequestedCluster(req: Request): string { + const requestedClusterName = req.header(HEADER_KUBERNETES_CLUSTER); if (!requestedClusterName) { - this.logger.error(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`); - throw new InputError(`Malformed ${HEADER_KUBERNETES_CLUSTER} header.`); + throw new InputError(`Missing '${HEADER_KUBERNETES_CLUSTER}' header.`); } return requestedClusterName; @@ -95,62 +107,16 @@ export class KubernetesProxy { private async getClusterDetails( requestedCluster: string, ): Promise { - const clusters = await this.clustersSupplier.getClusters(); + const clusters = await this.clusterSupplier.getClusters(); - const clusterDetail = clusters.find(cluster => - requestedCluster.includes(cluster.name), + const clusterDetail = clusters.find( + cluster => cluster.name === requestedCluster, ); - if (clusterDetail ? false : clusterDetail ?? true) { - this.logger.error( - `Cluster ${requestedCluster} details not found in config`, - ); - - throw new NotFoundError("Cluster's detail not found"); + if (!clusterDetail) { + throw new NotFoundError(`Cluster '${requestedCluster}' not found`); } - return clusterDetail as ClusterDetails; + return clusterDetail; } - - private async makeRequestToCluster( - details: ClusterDetails, - req: ExpressRequest, - ): Promise { - const serverURI = details.url; - - const path = decodeURIComponent(req.params.path) || ''; - const uri = `${serverURI}/${path}`; - - return await this.sendClusterRequest(details, uri, req); - } - - private async sendClusterRequest( - details: ClusterDetails, - uri: string, - req: ExpressRequest, - ): Promise { - const { method, headers, body } = req; - - const reqData: RequestInit = { - method, - headers: headers as { [key: string]: string }, - }; - - if (details.skipTLSVerify) { - reqData.agent = new https.Agent({ rejectUnauthorized: false }); - } else if (details.caData) { - const ca = bufferFromFileOrString('', details.caData)?.toString() || ''; - reqData.agent = new https.Agent({ ca }); - } - - if (body && Object.keys(body).length > 0) { - reqData.body = JSON.stringify(body); - } - - try { - return fetch(uri, reqData); - } catch (e: any) { - throw new ForwardedError(`Cluster ${details.name} request error`, e); - } - } - +} diff --git a/yarn.lock b/yarn.lock index 653eeec7ce..b049053f9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5977,6 +5977,7 @@ __metadata: "@kubernetes/client-node": 0.17.0 "@types/aws4": ^1.5.1 "@types/express": ^4.17.6 + "@types/http-proxy-middleware": ^0.19.3 "@types/luxon": ^3.0.0 aws-sdk: ^2.840.0 aws-sdk-mock: ^5.2.1 @@ -5987,6 +5988,7 @@ __metadata: express-promise-router: ^4.1.0 fs-extra: 10.1.0 helmet: ^6.0.0 + http-proxy-middleware: ^2.0.6 lodash: ^4.17.21 luxon: ^3.0.0 morgan: ^1.10.0 @@ -23361,7 +23363,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3": +"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6": version: 2.0.6 resolution: "http-proxy-middleware@npm:2.0.6" dependencies: From d050ff1b6d2c59c02fc96e7339a5a881eb1fa093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Nov 2022 10:16:26 +0100 Subject: [PATCH 13/16] sort out the index files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/kubernetes-backend/src/index.ts | 20 +++------------- .../src/kubernetes-auth-translator/index.ts | 24 +++++++++++++++++++ .../kubernetes-backend/src/service/index.ts | 21 ++++++++++++++++ plugins/kubernetes-backend/src/types/index.ts | 17 +++++++++++++ 4 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts create mode 100644 plugins/kubernetes-backend/src/service/index.ts create mode 100644 plugins/kubernetes-backend/src/types/index.ts diff --git a/plugins/kubernetes-backend/src/index.ts b/plugins/kubernetes-backend/src/index.ts index fba5c4a151..67aefbd974 100644 --- a/plugins/kubernetes-backend/src/index.ts +++ b/plugins/kubernetes-backend/src/index.ts @@ -20,20 +20,6 @@ * @packageDocumentation */ -export * from './kubernetes-auth-translator/AwsIamKubernetesAuthTranslator'; -export * from './kubernetes-auth-translator/AzureIdentityKubernetesAuthTranslator'; -export * from './kubernetes-auth-translator/GoogleKubernetesAuthTranslator'; -export * from './kubernetes-auth-translator/GoogleServiceAccountAuthProvider'; -export * from './kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; -export * from './kubernetes-auth-translator/NoopKubernetesAuthTranslator'; -export * from './kubernetes-auth-translator/OidcKubernetesAuthTranslator'; -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'; - -export { DEFAULT_OBJECTS } from './service/KubernetesFanOutHandler'; +export * from './kubernetes-auth-translator'; +export * from './service'; +export * from './types'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts new file mode 100644 index 0000000000..19f6dd80eb --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 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. + */ + +export * from './AwsIamKubernetesAuthTranslator'; +export * from './AzureIdentityKubernetesAuthTranslator'; +export * from './GoogleKubernetesAuthTranslator'; +export * from './GoogleServiceAccountAuthProvider'; +export * from './KubernetesAuthTranslatorGenerator'; +export * from './NoopKubernetesAuthTranslator'; +export * from './OidcKubernetesAuthTranslator'; +export * from './types'; diff --git a/plugins/kubernetes-backend/src/service/index.ts b/plugins/kubernetes-backend/src/service/index.ts new file mode 100644 index 0000000000..2b7d576b5c --- /dev/null +++ b/plugins/kubernetes-backend/src/service/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2022 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. + */ + +export * from './KubernetesBuilder'; +export * from './KubernetesClientProvider'; +export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler'; +export * from './KubernetesProxy'; +export * from './router'; diff --git a/plugins/kubernetes-backend/src/types/index.ts b/plugins/kubernetes-backend/src/types/index.ts new file mode 100644 index 0000000000..db229eae34 --- /dev/null +++ b/plugins/kubernetes-backend/src/types/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 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. + */ + +export * from './types'; From e4aca04d5c6b866a9cfc67c07f5ddacd05e271ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Nov 2022 11:54:47 +0100 Subject: [PATCH 14/16] export as a regular express router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/kubernetes-backend/api-report.md | 9 ++--- .../src/service/KubernetesProxy.ts | 35 ++++++++++--------- .../kubernetes-backend/src/service/index.ts | 2 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 6e0c253f79..6fd5675a8d 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -22,12 +22,9 @@ 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 { RequestHandler } from 'http-proxy-middleware'; +import type { RequestHandler } from 'express'; import { TokenCredential } from '@azure/identity'; -// @alpha (undocumented) -export const APPLICATION_JSON: string; - // @alpha (undocumented) export interface AWSClusterDetails extends ClusterDetails { // (undocumented) @@ -146,7 +143,7 @@ export class GoogleServiceAccountAuthTranslator ): Promise; } -// @alpha (undocumented) +// @alpha export const HEADER_KUBERNETES_CLUSTER: string; // @alpha (undocumented) @@ -353,7 +350,7 @@ export type KubernetesObjectTypes = | 'statefulsets' | 'daemonsets'; -// @alpha (undocumented) +// @alpha export class KubernetesProxy { constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier); // (undocumented) diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index 7cc3a97416..ace07247b1 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -13,33 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; -import { bufferFromFileOrString } from '@kubernetes/client-node'; -import { Logger } from 'winston'; -import { ErrorResponseBody, serializeError } from '@backstage/errors'; +import { + ErrorResponseBody, + ForwardedError, + InputError, + NotFoundError, + serializeError, +} from '@backstage/errors'; +import { bufferFromFileOrString } from '@kubernetes/client-node'; +import type { Request, RequestHandler } from 'express'; +import { + createProxyMiddleware, + Options as ProxyMiddlewareOptions, +} from 'http-proxy-middleware'; +import { Logger } from 'winston'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; -import type { Request } from 'express'; -import { - RequestHandler, - Options, - createProxyMiddleware, -} from 'http-proxy-middleware'; - -/** - * - * @alpha - */ export const APPLICATION_JSON: string = 'application/json'; /** + * The header that is used to specify the cluster name. * * @alpha */ export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; /** + * A proxy that routes requests to the Kubernetes API. * * @alpha */ @@ -55,7 +56,7 @@ export class KubernetesProxy { const clusterDetails = await this.getClusterDetails(requestedCluster); const clusterUrl = new URL(clusterDetails.url); - const options = { + const options: ProxyMiddlewareOptions = { logProvider: () => this.logger, secure: !clusterDetails.skipTLSVerify, target: { @@ -83,7 +84,7 @@ export class KubernetesProxy { res.status(500).json(body); }, - } as Options; + }; // Probably too risky without permissions protecting this endpoint // if (clusterDetails.serviceAccountToken) { diff --git a/plugins/kubernetes-backend/src/service/index.ts b/plugins/kubernetes-backend/src/service/index.ts index 2b7d576b5c..70ff530bee 100644 --- a/plugins/kubernetes-backend/src/service/index.ts +++ b/plugins/kubernetes-backend/src/service/index.ts @@ -17,5 +17,5 @@ export * from './KubernetesBuilder'; export * from './KubernetesClientProvider'; export { DEFAULT_OBJECTS } from './KubernetesFanOutHandler'; -export * from './KubernetesProxy'; +export { HEADER_KUBERNETES_CLUSTER, KubernetesProxy } from './KubernetesProxy'; export * from './router'; From 2a1eb5549e0a46d9888d0a5c37c21ca5bf061a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Nov 2022 15:03:16 +0100 Subject: [PATCH 15/16] memoize middlewares so we don't churn resources excessively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/kubernetes-backend/api-report.md | 2 +- .../src/service/KubernetesBuilder.ts | 2 +- .../src/service/KubernetesProxy.test.ts | 7 +- .../src/service/KubernetesProxy.ts | 135 +++++++++--------- 4 files changed, 76 insertions(+), 70 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 6fd5675a8d..b88b3f2a02 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -354,7 +354,7 @@ export type KubernetesObjectTypes = export class KubernetesProxy { constructor(logger: Logger, clusterSupplier: KubernetesClustersSupplier); // (undocumented) - proxyRequestHandler: RequestHandler; + createRequestHandler(): RequestHandler; } // @alpha diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index a5e67ebe2a..c8ff98f592 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -299,7 +299,7 @@ export class KubernetesBuilder { }); }); - router.use('/proxy', proxy.proxyRequestHandler); + router.use('/proxy', proxy.createRequestHandler()); addResourceRoutesToRouter(router, catalogApi, objectsProvider); diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts index 5d4eaa795b..4254bb33da 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import 'buffer'; +import 'buffer'; import { getVoidLogger } from '@backstage/backend-common'; import { NotFoundError } from '@backstage/errors'; import { getMockReq, getMockRes } from '@jest-mock/express'; @@ -24,7 +24,6 @@ import request from 'supertest'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; - import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { APPLICATION_JSON, @@ -76,7 +75,7 @@ describe('KubernetesProxy', () => { const req = buildMockRequest('test', 'api'); const { res, next } = getMockRes(); - await expect(proxy.proxyRequestHandler(req, res, next)).rejects.toThrow( + await expect(proxy.createRequestHandler()(req, res, next)).rejects.toThrow( NotFoundError, ); }); @@ -101,7 +100,7 @@ describe('KubernetesProxy', () => { authProvider: 'serviceAccount', }, ] as ClusterDetails[]); - const app = express().use('/mountpath', proxy.proxyRequestHandler); + const app = express().use('/mountpath', proxy.createRequestHandler()); const requestPromise = request(app) .get('/mountpath/api') .set(HEADER_KUBERNETES_CLUSTER, 'cluster1'); diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts index ace07247b1..9d88d2ab84 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts @@ -23,10 +23,7 @@ import { } from '@backstage/errors'; import { bufferFromFileOrString } from '@kubernetes/client-node'; import type { Request, RequestHandler } from 'express'; -import { - createProxyMiddleware, - Options as ProxyMiddlewareOptions, -} from 'http-proxy-middleware'; +import { createProxyMiddleware } from 'http-proxy-middleware'; import { Logger } from 'winston'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; @@ -45,79 +42,89 @@ export const HEADER_KUBERNETES_CLUSTER: string = 'X-Kubernetes-Cluster'; * @alpha */ export class KubernetesProxy { + private readonly middlewareForClusterName = new Map(); + constructor( private readonly logger: Logger, private readonly clusterSupplier: KubernetesClustersSupplier, ) {} - public proxyRequestHandler: RequestHandler = async (req, res, next) => { - const requestedCluster = this.getKubernetesRequestedCluster(req); - - const clusterDetails = await this.getClusterDetails(requestedCluster); - - const clusterUrl = new URL(clusterDetails.url); - const options: ProxyMiddlewareOptions = { - logProvider: () => this.logger, - secure: !clusterDetails.skipTLSVerify, - target: { - protocol: clusterUrl.protocol, - host: clusterUrl.hostname, - port: clusterUrl.port, - ca: bufferFromFileOrString('', clusterDetails.caData)?.toString(), - }, - pathRewrite: { [`^${req.baseUrl}`]: '' }, - onError: (error: Error) => { - const wrappedError = new ForwardedError( - `Cluster '${requestedCluster}' request error`, - error, - ); - - this.logger.error(wrappedError); - - const body: ErrorResponseBody = { - error: serializeError(wrappedError, { - includeStack: process.env.NODE_ENV === 'development', - }), - request: { method: req.method, url: req.originalUrl }, - response: { statusCode: 500 }, - }; - - res.status(500).json(body); - }, + public createRequestHandler(): RequestHandler { + return async (req, res, next) => { + const middleware = await this.getMiddleware(req); + middleware(req, res, next); }; + } - // Probably too risky without permissions protecting this endpoint - // if (clusterDetails.serviceAccountToken) { - // options.headers = { - // Authorization: `Bearer ${clusterDetails.serviceAccountToken}`, - // }; - // } - createProxyMiddleware(options)(req, res, next); - }; + // We create one middleware per remote cluster and hold on to them, because + // the secure property isn't possible to decide on a per-request basis with a + // single middleware instance - and we don't expect it to change over time. + private async getMiddleware(originalReq: Request): Promise { + const originalCluster = await this.getClusterForRequest(originalReq); + let middleware = this.middlewareForClusterName.get(originalCluster.name); + if (!middleware) { + // Probably too risky without permissions protecting this endpoint + // if (cluster.serviceAccountToken) { + // options.headers = { + // Authorization: `Bearer ${cluster.serviceAccountToken}`, + // }; + // } - private getKubernetesRequestedCluster(req: Request): string { - const requestedClusterName = req.header(HEADER_KUBERNETES_CLUSTER); + const logger = this.logger.child({ cluster: originalCluster.name }); + middleware = createProxyMiddleware({ + logProvider: () => logger, + secure: !originalCluster.skipTLSVerify, + router: async req => { + // Re-evaluate the cluster on each request, in case it has changed + const cluster = await this.getClusterForRequest(req); + const url = new URL(cluster.url); + return { + protocol: url.protocol, + host: url.hostname, + port: url.port, + ca: bufferFromFileOrString('', cluster.caData)?.toString(), + }; + }, + pathRewrite: { [`^${originalReq.baseUrl}`]: '' }, + onError: (error, req, res) => { + const wrappedError = new ForwardedError( + `Cluster '${originalCluster.name}' request error`, + error, + ); - if (!requestedClusterName) { + logger.error(wrappedError); + + const body: ErrorResponseBody = { + error: serializeError(wrappedError, { + includeStack: process.env.NODE_ENV === 'development', + }), + request: { method: req.method, url: req.originalUrl }, + response: { statusCode: 500 }, + }; + + res.status(500).json(body); + }, + }); + + this.middlewareForClusterName.set(originalCluster.name, middleware); + } + + return middleware; + } + + private async getClusterForRequest(req: Request): Promise { + const clusterName = req.header(HEADER_KUBERNETES_CLUSTER); + if (!clusterName) { throw new InputError(`Missing '${HEADER_KUBERNETES_CLUSTER}' header.`); } - return requestedClusterName; - } - - private async getClusterDetails( - requestedCluster: string, - ): Promise { - const clusters = await this.clusterSupplier.getClusters(); - - const clusterDetail = clusters.find( - cluster => cluster.name === requestedCluster, - ); - - if (!clusterDetail) { - throw new NotFoundError(`Cluster '${requestedCluster}' not found`); + const cluster = await this.clusterSupplier + .getClusters() + .then(clusters => clusters.find(c => c.name === clusterName)); + if (!cluster) { + throw new NotFoundError(`Cluster '${clusterName}' not found`); } - return clusterDetail; + return cluster; } } From 92e558f4cfbe19e5bba27bbe16b549028df89ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 22 Nov 2022 14:10:09 +0100 Subject: [PATCH 16/16] use the right msw version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/kubernetes-backend/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 9c6cfd0563..c0822d366a 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -70,7 +70,7 @@ "@types/aws4": "^1.5.1", "@types/http-proxy-middleware": "^0.19.3", "aws-sdk-mock": "^5.2.1", - "msw": "^0.48.0", + "msw": "^0.49.0", "supertest": "^6.1.3" }, "files": [ diff --git a/yarn.lock b/yarn.lock index b049053f9e..282fdcba98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5992,7 +5992,7 @@ __metadata: lodash: ^4.17.21 luxon: ^3.0.0 morgan: ^1.10.0 - msw: ^0.48.0 + msw: ^0.49.0 node-fetch: ^2.6.7 stream-buffers: ^3.0.2 supertest: ^6.1.3