diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 2cc2674a9e..7c150689b4 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -184,6 +184,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // Warning: (ae-missing-release-tag) "GroupedResponses" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -259,6 +261,12 @@ export interface KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-missing-release-tag) "kubernetesApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -281,6 +289,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // Warning: (ae-missing-release-tag) "KubernetesAuthProvidersApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -292,6 +302,8 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // Warning: (ae-missing-release-tag) "kubernetesAuthProvidersApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -306,6 +318,7 @@ export class KubernetesBackendClient implements KubernetesApi { constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }); // (undocumented) getClusters(): Promise< @@ -326,6 +339,12 @@ export class KubernetesBackendClient implements KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-forgotten-export) The symbol "KubernetesContentProps" needs to be exported by the entry point index.d.ts @@ -416,6 +435,8 @@ export class ServerSideKubernetesAuthProvider decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // Warning: (ae-forgotten-export) The symbol "ServicesAccordionsProps" needs to be exported by the entry point index.d.ts diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 77708f1287..5780d649fb 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -106,6 +106,16 @@ class MockKubernetesClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }]; } + + async proxy(_options: { clusterName: String; path: String }): Promise { + return { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'mock-ns', + }, + }; + } } createDevApp() diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts new file mode 100644 index 0000000000..ec18dd50bb --- /dev/null +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright 2023 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 { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider'; +import { KubernetesBackendClient } from './KubernetesBackendClient'; +import { rest } from 'msw'; +import { UrlPatternDiscovery } from '@backstage/core-app-api'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { + CustomObjectsByEntityRequest, + KubernetesRequestBody, + ObjectsByEntityResponse, + WorkloadsByEntityRequest, +} from '@backstage/plugin-kubernetes-common'; +import { NotFoundError } from '@backstage/errors'; + +describe('KubernetesBackendClient', () => { + let backendClient: KubernetesBackendClient; + const kubernetesAuthProvidersApi: jest.Mocked = { + decorateRequestBodyForAuth: jest.fn(), + getBearerToken: jest.fn(), + }; + let mockResponse: ObjectsByEntityResponse; + const worker = setupServer(); + setupRequestMockHandlers(worker); + + const identityApi = { + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + signOut: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + backendClient = new KubernetesBackendClient({ + discoveryApi: UrlPatternDiscovery.compile( + 'http://localhost:1234/api/{{ pluginId }}', + ), + identityApi, + kubernetesAuthProvidersApi, + }); + mockResponse = { + items: [ + { + cluster: { + name: 'cluster-a', + }, + resources: [{ type: 'pods', resources: [] }], + podMetrics: [ + { + pod: {}, + cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + containers: [ + { + container: 'test', + cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memoryUsage: { + currentUsage: 8, + requestTotal: 2, + limitTotal: 1, + }, + }, + ], + }, + ], + errors: [], + }, + ], + }; + }); + + it('hits the /clusters API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const clusters = await backendClient.getClusters(); + + expect(clusters).toStrictEqual([ + { name: 'cluster-a', authProvider: 'aws' }, + ]); + }); + + it('hits the /resources/custom/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const customObject: ObjectsByEntityResponse = + await backendClient.getCustomObjectsByEntity(request); + + expect(customObject).toStrictEqual(mockResponse); + }); + + it('hits the /services/{entityName} api', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const entityObject: ObjectsByEntityResponse = + await backendClient.getObjectsByEntity(request); + + expect(entityObject).toStrictEqual(mockResponse); + }); + + it('hits the /resources/workloads/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response: ObjectsByEntityResponse = + await backendClient.getWorkloadsByEntity(request); + + expect(response).toStrictEqual(mockResponse); + }); + + it('hits the /proxy API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( + 'Bearer k8-token', + ); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + expect(response).toStrictEqual(nsResponse); + }); + + it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); + }); + + it('hits /proxy api when signed in as a guest', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( + 'Bearer k8-token', + ); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + expect(response).toStrictEqual(nsResponse); + }); +}); diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index b54046fbcd..dbeb1c3035 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -74,6 +74,23 @@ export class KubernetesBackendClient implements KubernetesApi { return this.handleResponse(response); } + private async getCluster( + clusterName: string, + ): Promise<{ name: string; authProvider: string }> { + const cluster = await this.getClusters().then(clusters => + clusters.find(c => c.name === clusterName), + ); + if (!cluster) { + throw new NotFoundError(`Cluster ${clusterName} not found`); + } + + return cluster; + } + + private async getBearerToken(authProvider: string): Promise { + return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + } + async getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise { @@ -105,7 +122,6 @@ export class KubernetesBackendClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { const { token: idToken } = await this.identityApi.getCredentials(); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; - const response = await fetch(url, { method: 'GET', headers: { @@ -116,67 +132,31 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } - async proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): Promise { - const { authProvider } = await this.getCluster(clusterName); - const token = await this.getBearerToken(authProvider); + async proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise { + const { authProvider } = await this.getCluster(options.clusterName); + const k8sToken = await this.getBearerToken(authProvider); + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ + options.path + }`; + const identityResponse = await this.identityApi.getCredentials(); + const headers = identityResponse.token + ? { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + Authorization: `Bearer ${identityResponse.token}`, + } + : { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + }; + const response = await fetch(url, { ...options.init, headers }); - const url = `${await this.discoveryApi.getBaseUrl( - 'kubernetes', - )}/proxy${path}`; - const headers = { - Authorization: `Bearer ${token}`, - ...init?.headers, - [`X-Kubernetes-Cluster`]: clusterName, - }; - const response = await fetch(url, { ...init, headers }); return this.handleResponse(response); } - - private async getCluster( - clusterName: string, - ): Promise<{ name: string; authProvider: string }> { - const cluster = await this.getClusters().then(clusters => - clusters.find(c => c.name === clusterName), - ); - if (!cluster) { - throw new NotFoundError(`Cluster ${clusterName} not found`); - } - return cluster; - } - - private async getBearerToken(authProvider: string): Promise { - // const { auth } = - // await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth( - // authProvider, - // { - // entity: { - // apiVersion: 'v1', - // kind: 'Pods', - // metadata: { - // name: 'podName', - // annotations: { - // 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns', - // }, - // }, - // }, - // }, - // ); - return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); - - // if (auth) { - // if (authProvider === 'google' && auth.google) { - // return auth.google; - // } else if (authProvider.startsWith('oidc.') && auth.oidc) { - // const oidcTokenProvider = authProvider.replace(/^oidc\./, ''); - // return auth.oidc[oidcTokenProvider]; - // } else { - // throw new Error(`invalid auth provider '${authProvider}'`); - // } - // } - // return ''; - } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 22e0644461..02f2fd3521 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -43,9 +43,9 @@ export interface KubernetesApi { getCustomObjectsByEntity( request: CustomObjectsByEntityRequest, ): Promise; - proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): Promise; + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 171ebc9960..4b46884b39 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -28,9 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const googleAuthToken: string = await this.authProvider.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', - ); + const googleAuthToken: string = await this.getBearerToken(); if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 65dc45e131..45ba243d23 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider'; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index b992c71391..9d9d34fe1d 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -30,7 +30,7 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const authToken: string = await this.authProvider.getIdToken(); + const authToken: string = await this.getBearerToken(); const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index de9f91c44e..395db3c1bd 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; export interface KubernetesAuthProvider { diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts index a373310a1a..f7752030ed 100644 --- a/plugins/kubernetes/src/setupTests.ts +++ b/plugins/kubernetes/src/setupTests.ts @@ -16,6 +16,7 @@ import '@testing-library/jest-dom'; // eslint-disable-next-line no-restricted-imports import { TextDecoder, TextEncoder } from 'util'; +import 'cross-fetch/polyfill'; // These are missing from jest-node, so not available on global. Object.defineProperty(global, 'TextEncoder', {