diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts index 34788d16cb..dd77a11bae 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts @@ -43,6 +43,7 @@ describe('MultiTenantConfigClusterLocator', () => { { name: 'cluster1', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, ], }, @@ -60,6 +61,7 @@ describe('MultiTenantConfigClusterLocator', () => { name: 'cluster1', serviceAccountToken: undefined, url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, ]); }); @@ -70,13 +72,14 @@ describe('MultiTenantConfigClusterLocator', () => { clusters: [ { name: 'cluster1', - serviceAccountToken: undefined, + serviceAccountToken: 'token', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, { name: 'cluster2', - serviceAccountToken: undefined, url: 'http://localhost:8081', + authProvider: 'google', }, ], }, @@ -92,13 +95,15 @@ describe('MultiTenantConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', - serviceAccountToken: undefined, + serviceAccountToken: 'token', url: 'http://localhost:8080', + authProvider: 'serviceAccount', }, { name: 'cluster2', serviceAccountToken: undefined, url: 'http://localhost:8081', + authProvider: 'google', }, ]); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts index 3d9e2dd8b4..5a1f0f3839 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts @@ -28,12 +28,15 @@ export class MultiTenantConfigClusterLocator } static fromConfig(config: Config[]): MultiTenantConfigClusterLocator { + // TODO: Add validation that authProvider is required and serviceAccountToken + // is required if authProvider is serviceAccount return new MultiTenantConfigClusterLocator( config.map(c => { return { name: c.getString('name'), url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), + authProvider: c.getString('authProvider'), }; }), ); diff --git a/plugins/kubernetes-backend/src/cluster-locator/types.ts b/plugins/kubernetes-backend/src/cluster-locator/types.ts index 9b793da558..dae8eb6d60 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/types.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/types.ts @@ -15,3 +15,4 @@ */ export type ClusterLocatorMethod = 'configMultiTenant' | 'http'; +export type AuthProviderType = 'google' | 'serviceAccount'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..7f9b2f0bae --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class GoogleKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + const authToken: string | undefined = requestBody.auth?.google; + + if (authToken) { + clusterDetailsWithAuthToken.serviceAccountToken = authToken; + } else { + throw new Error( + 'Google token not found under auth.google in request body', + ); + } + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts new file mode 100644 index 0000000000..3b27eb012a --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +describe('getKubernetesAuthTranslatorInstance', () => { + const sut = KubernetesAuthTranslatorGenerator; + + it('can return an auth translator for google auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'google', + ); + expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); + }); + + it('can return an auth translator for serviceAccount auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'serviceAccount', + ); + expect( + authTranslator instanceof ServiceAccountKubernetesAuthTranslator, + ).toBe(true); + }); + + it('throws an error when asked for an auth translator for an unsupported auth type', () => { + expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow( + 'authProvider "linode" has no KubernetesAuthTranslator associated with it', + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts new file mode 100644 index 0000000000..f7cfc92112 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +export class KubernetesAuthTranslatorGenerator { + static getKubernetesAuthTranslatorInstance( + authProvider: String, + ): KubernetesAuthTranslator { + switch (authProvider) { + case 'google': { + return new GoogleKubernetesAuthTranslator(); + } + case 'serviceAccount': { + return new ServiceAccountKubernetesAuthTranslator(); + } + default: { + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`, + ); + } + } + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..ecf2f12b72 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class ServiceAccountKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. + // @ts-ignore-start + requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars + // @ts-ignore-end + ): Promise { + return clusterDetails; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts new file mode 100644 index 0000000000..f89e04456f --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { AuthRequestBody, ClusterDetails } from '../types/types'; + +export interface KubernetesAuthTranslator { + decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts index a3dc67716a..409d41b783 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -33,6 +33,7 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result.basePath).toBe('http://localhost:9999'); @@ -55,12 +56,14 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); const result2 = sut.getCoreClientByClusterDetails({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result1.basePath).toBe('http://localhost:9999'); @@ -89,6 +92,7 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result.basePath).toBe('http://localhost:9999'); @@ -111,12 +115,14 @@ describe('KubernetesClientProvider', () => { name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); const result2 = sut.getAppsClientByClusterDetails({ name: 'cluster-name', url: 'http://localhost:9999', serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', }); expect(result1.basePath).toBe('http://localhost:9999'); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 8f390f309b..6294b3a7ed 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -62,7 +62,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['pods', 'services']), ); @@ -124,7 +125,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['pods', 'services']), ); @@ -172,7 +174,8 @@ describe('KubernetesClientProvider', () => { { name: 'cluster1', url: 'http://localhost:9999', - serviceAccountToken: undefined, + serviceAccountToken: 'token', + authProvider: 'serviceAccount', }, new Set(['foo']), ), diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 56da3235cc..4218c64e80 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -74,6 +74,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { Promise.resolve([ { name: 'test-cluster', + authProvider: 'serviceAccount', }, ]), ); @@ -89,6 +90,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { getClusterByServiceId, }, getVoidLogger(), + {}, ); expect(getClusterByServiceId.mock.calls.length).toBe(1); @@ -142,9 +144,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => { Promise.resolve([ { name: 'test-cluster', + authProvider: 'serviceAccount', }, { name: 'other-cluster', + authProvider: 'google', }, ]), ); @@ -160,6 +164,11 @@ describe('handleGetKubernetesObjectsByServiceId', () => { getClusterByServiceId, }, getVoidLogger(), + { + auth: { + google: 'google_token_123', + }, + }, ); expect(getClusterByServiceId.mock.calls.length).toBe(1); diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index 1690b8be40..f0b21cbc0c 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -16,17 +16,22 @@ import { Logger } from 'winston'; import { + AuthRequestBody, + ClusterDetails, KubernetesClusterLocator, KubernetesFetcher, KubernetesObjectTypes, ObjectsByServiceIdResponse, -} from '..'; +} from '../types/types'; +import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; +import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; export type GetKubernetesObjectsByServiceIdHandler = ( serviceId: string, fetcher: KubernetesFetcher, clusterLocator: KubernetesClusterLocator, logger: Logger, + requestBody: AuthRequestBody, objectsToFetch?: Set, ) => Promise; @@ -46,18 +51,35 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic fetcher, clusterLocator, logger, + requestBody, objectsToFetch = DEFAULT_OBJECTS, ) => { - const clusterDetails = await clusterLocator.getClusterByServiceId(serviceId); + const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId( + serviceId, + ); + + // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them + const promises: Promise[] = clusterDetails.map(cd => { + const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + cd.authProvider, + ); + return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( + cd, + requestBody, + ); + }); + const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( + promises, + ); logger.info( - `serviceId=${serviceId} clusterDetails=[${clusterDetails + `serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth .map(c => c.name) .join(', ')}]`, ); return Promise.all( - clusterDetails.map(cd => { + clusterDetailsDecoratedForAuth.map(cd => { return fetcher .fetchObjectsByServiceId(serviceId, cd, objectsToFetch) .then(result => { diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 7731fe9abb..4e9a206eb4 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -54,8 +54,8 @@ describe('router', () => { jest.resetAllMocks(); }); - describe('GET /services/:serviceId', () => { - it('happy path: lists kubernetes objects', async () => { + describe('post /services/:serviceId', () => { + it('happy path: lists kubernetes objects without auth in request body', async () => { const result = { clusterOne: { pods: [ @@ -69,7 +69,34 @@ describe('router', () => { } as any; handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); - const response = await request(app).get('/services/test-service'); + const response = await request(app).post('/services/test-service'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(result); + }); + + it('happy path: lists kubernetes objects with auth in request body', async () => { + const result = { + clusterOne: { + pods: [ + { + metadata: { + name: 'pod1', + }, + }, + ], + }, + } as any; + handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + + const response = await request(app) + .post('/services/test-service') + .send({ + auth: { + google: 'google_token_123', + }, + }) + .set('Content-Type', 'application/json'); expect(response.status).toEqual(200); expect(response.body).toEqual(result); @@ -78,7 +105,7 @@ describe('router', () => { it('internal error: lists kubernetes objects', async () => { handleGetByServiceId.mockRejectedValue(Error('some internal error')); - const response = await request(app).get('/services/test-service'); + const response = await request(app).post('/services/test-service'); expect(response.status).toEqual(500); expect(response.body).toEqual({ error: 'some internal error' }); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index bdb14bb74d..9e9b4ab2f5 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -26,7 +26,11 @@ import { GetKubernetesObjectsByServiceIdHandler, handleGetKubernetesObjectsByServiceId, } from './getKubernetesObjectsByServiceIdHandler'; -import { KubernetesClusterLocator, KubernetesFetcher } from '..'; +import { + AuthRequestBody, + KubernetesClusterLocator, + KubernetesFetcher, +} from '../types/types'; export interface RouterOptions { logger: Logger; @@ -62,15 +66,16 @@ export const makeRouter = ( router.use(express.json()); // TODO error handling - router.get('/services/:serviceId', async (req, res) => { + router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; - + const requestBody: AuthRequestBody = req.body; try { const response = await handleGetByServiceId( serviceId, fetcher, clusterLocator, logger, + requestBody, ); res.send(response); } catch (e) { diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 0cb42eec1a..eb2817f268 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -27,8 +27,14 @@ import { export interface ClusterDetails { name: string; url: string; - // TODO this will eventually be configured by the auth translation work - serviceAccountToken: string | undefined; + authProvider: string; + serviceAccountToken?: string | undefined; +} + +export interface AuthRequestBody { + auth?: { + google?: string; + }; } export interface ClusterObjects { diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 5dc9231c2a..2adefebbd6 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", "@backstage/core": "^0.1.1-alpha.24", "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24", "@backstage/theme": "^0.1.1-alpha.24", diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index cbb3b03a38..86bb830046 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -16,7 +16,10 @@ import { DiscoveryApi } from '@backstage/core'; import { KubernetesApi } from './types'; -import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; @@ -25,9 +28,18 @@ export class KubernetesBackendClient implements KubernetesApi { this.discoveryApi = options.discoveryApi; } - private async getRequired(path: string): Promise { + private async getRequired( + path: string, + requestBody: AuthRequestBody, + ): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; - const response = await fetch(url); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); if (!response.ok) { const payload = await response.text(); @@ -40,7 +52,8 @@ export class KubernetesBackendClient implements KubernetesApi { async getObjectsByServiceId( serviceId: String, + requestBody: AuthRequestBody, ): Promise { - return await this.getRequired(`/services/${serviceId}`); + return await this.getRequired(`/services/${serviceId}`, requestBody); } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 5ec3cda6f8..8e44d58e6a 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -15,7 +15,10 @@ */ import { createApiRef } from '@backstage/core'; -import { ObjectsByServiceIdResponse } from '@backstage/plugin-kubernetes-backend'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', @@ -24,5 +27,8 @@ export const kubernetesApiRef = createApiRef({ }); export interface KubernetesApi { - getObjectsByServiceId(serviceId: String): Promise; + getObjectsByServiceId( + serviceId: String, + requestBody: AuthRequestBody, + ): Promise; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 6119884c43..013c7ad002 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -16,8 +16,10 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { Grid, TabProps } from '@material-ui/core'; +import { Config } from '@backstage/config'; import { CardTab, + configApiRef, Content, Page, pageTheme, @@ -28,10 +30,12 @@ import { import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { + AuthRequestBody, ClusterObjects, FetchResponse, ObjectsByServiceIdResponse, } from '@backstage/plugin-kubernetes-backend'; +import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; import { DeploymentTables } from '../DeploymentTables'; import { DeploymentTriple } from '../../types/types'; import { @@ -105,16 +109,40 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { >(undefined); const [error, setError] = useState(undefined); + const configApi = useApi(configApiRef); + const clusters: Config[] = configApi.getConfigArray('kubernetes.clusters'); + const allAuthProviders: string[] = clusters.map(c => + c.getString('authProvider'), + ); + const authProviders: string[] = [...new Set(allAuthProviders)]; + + const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef); + useEffect(() => { - kubernetesApi - .getObjectsByServiceId(entity.metadata.name) - .then(result => { - setKubernetesObjects(result); - }) - .catch(e => { - setError(e.message); - }); - }, [entity.metadata.name, kubernetesApi]); + (async () => { + // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider + let requestBody: AuthRequestBody = {}; + for (const authProviderStr of authProviders) { + // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously + requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + authProviderStr, + requestBody, + ); + } + + // TODO: Add validation on contents/format of requestBody + kubernetesApi + .getObjectsByServiceId(entity.metadata.name, requestBody) + .then(result => { + setKubernetesObjects(result); + }) + .catch(e => { + setError(e.message); + }); + })(); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]); + /* eslint-enable react-hooks/exhaustive-deps */ const clustersWithErrors = kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts new file mode 100644 index 0000000000..7de5c7c0a9 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { OAuthApi } from '@backstage/core'; +import { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { + authProvider: OAuthApi; + + constructor(authProvider: OAuthApi) { + this.authProvider = authProvider; + } + + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + const googleAuthToken: string = await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ); + if ('auth' in requestBody) { + requestBody.auth!.google = googleAuthToken; + } else { + requestBody.auth = { google: googleAuthToken }; + } + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts new file mode 100644 index 0000000000..ac909d6695 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { OAuthApi } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; +import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; +import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; + +export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { + private readonly kubernetesAuthProviderMap: Map< + string, + KubernetesAuthProvider + >; + + constructor(options: { googleAuthApi: OAuthApi }) { + this.kubernetesAuthProviderMap = new Map(); + this.kubernetesAuthProviderMap.set( + 'google', + new GoogleKubernetesAuthProvider(options.googleAuthApi), + ); + this.kubernetesAuthProviderMap.set( + 'serviceAccount', + new ServiceAccountKubernetesAuthProvider(), + ); + } + + async decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise { + const kubernetesAuthProvider: + | KubernetesAuthProvider + | undefined = this.kubernetesAuthProviderMap.get(authProvider); + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.decorateRequestBodyForAuth( + requestBody, + ); + } + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, + ); + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts new file mode 100644 index 0000000000..3ac5a9494b --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class ServiceAccountKubernetesAuthProvider + implements KubernetesAuthProvider { + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + // No-op, with service account for auth, cluster config/details should already have serviceAccountToken + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts new file mode 100644 index 0000000000..24fee0f94c --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createApiRef } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export interface KubernetesAuthProvider { + decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise; +} + +export const kubernetesAuthProvidersApiRef = createApiRef< + KubernetesAuthProvidersApi +>({ + id: 'plugin.kubernetes-auth-providers.service', + description: 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders', +}); + +export interface KubernetesAuthProvidersApi { + decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index b7ad9615f5..cff9e1468b 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -18,9 +18,12 @@ import { createPlugin, createRouteRef, discoveryApiRef, + googleAuthApiRef, } from '@backstage/core'; import { KubernetesBackendClient } from './api/KubernetesBackendClient'; import { kubernetesApiRef } from './api/types'; +import { kubernetesAuthProvidersApiRef } from './kubernetes-auth-provider/types'; +import { KubernetesAuthProviders } from './kubernetes-auth-provider/KubernetesAuthProviders'; export const rootCatalogKubernetesRouteRef = createRouteRef({ path: '*', @@ -36,5 +39,12 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new KubernetesBackendClient({ discoveryApi }), }), + createApiFactory({ + api: kubernetesAuthProvidersApiRef, + deps: { googleAuthApi: googleAuthApiRef }, + factory: ({ googleAuthApi }) => { + return new KubernetesAuthProviders({ googleAuthApi }); + }, + }), ], });