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; +}