From 9581ff0b4f216c58d3277939f34d5e4f0144fe20 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 8 Mar 2021 10:27:09 +0000 Subject: [PATCH] Kubernetes: lookup cluster config from google api when using GKE (#4844) * Restructure config; add GKE cluster locator Signed-off-by: mclarke * PR feedback Signed-off-by: mclarke * fix region typo Signed-off-by: mclarke * replace config api call with clusters endpoint Signed-off-by: mclarke * missed tsc error Signed-off-by: mclarke * doc tweak Signed-off-by: mclarke --- .changeset/shaggy-islands-mix.md | 48 ++++ app-config.yaml | 7 +- docs/features/kubernetes/configuration.md | 79 +++++-- plugins/kubernetes-backend/package.json | 1 + plugins/kubernetes-backend/schema.d.ts | 15 +- .../ConfigClusterLocator.test.ts | 12 +- .../cluster-locator/ConfigClusterLocator.ts | 4 +- .../cluster-locator/GkeClusterLocator.test.ts | 221 ++++++++++++++++++ .../src/cluster-locator/GkeClusterLocator.ts | 72 ++++++ .../src/cluster-locator/index.test.ts | 60 +++-- .../src/cluster-locator/index.ts | 37 +-- .../src/service/router.test.ts | 33 ++- .../kubernetes-backend/src/service/router.ts | 27 ++- plugins/kubernetes-backend/src/types/types.ts | 45 +++- plugins/kubernetes/schema.d.ts | 30 +-- .../src/api/KubernetesBackendClient.ts | 52 +++-- plugins/kubernetes/src/api/types.ts | 3 +- .../KubernetesContent/KubernetesContent.tsx | 11 +- plugins/kubernetes/src/plugin.ts | 6 +- yarn.lock | 89 ++++++- 20 files changed, 710 insertions(+), 142 deletions(-) create mode 100644 .changeset/shaggy-islands-mix.md create mode 100644 plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts create mode 100644 plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts diff --git a/.changeset/shaggy-islands-mix.md b/.changeset/shaggy-islands-mix.md new file mode 100644 index 0000000000000..a8f3ac608880c --- /dev/null +++ b/.changeset/shaggy-islands-mix.md @@ -0,0 +1,48 @@ +--- +'@backstage/plugin-kubernetes': minor +'@backstage/plugin-kubernetes-backend': minor +--- + +Restructure configuration; Add GKE cluster locator + +Config migration + +1. `kubernetes.clusters` is now at `kubernetes.clusterLocatorMethods[].clusters` when the `clusterLocatorMethod` is of `type: 'config''` +2. `kubernetes.serviceLocatorMethod` is now an object. `multiTenant` is the only valid `type` currently + +Old config example: + +```yaml +kubernetes: + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: aws-cluster-1 + authProvider: 'aws' +``` + +New config example: + +```yaml +kubernetes: + serviceLocatorMethod: + type: 'multiTenant' + clusterLocatorMethods: + - type: 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: aws-cluster-1 + authProvider: 'aws' +``` diff --git a/app-config.yaml b/app-config.yaml index f963296bb8df6..97c22090cd6e6 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -108,10 +108,11 @@ lighthouse: baseUrl: http://localhost:3003 kubernetes: - serviceLocatorMethod: 'multiTenant' + serviceLocatorMethod: + type: 'multiTenant' clusterLocatorMethods: - - 'config' - clusters: [] + - type: 'config' + clusters: [] kafka: clientId: backstage diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index f146eebe55468..ab48456a3bcc5 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -17,18 +17,22 @@ The following is a full example entry in `app-config.yaml`: ```yaml kubernetes: - serviceLocatorMethod: 'multiTenant' + serviceLocatorMethod: + type: 'multiTenant' clusterLocatorMethods: - - 'config' - clusters: - - url: http://127.0.0.1:9999 - name: minikube - authProvider: 'serviceAccount' - serviceAccountToken: - $env: K8S_MINIKUBE_TOKEN - - url: http://127.0.0.2:9999 - name: gke-cluster-1 - authProvider: 'google' + - type: 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: aws-cluster-1 + authProvider: 'aws' + - type: 'gke' + projectId: 'gke-clusters' + region: 'europe-west1' ``` ### `serviceLocatorMethod` @@ -44,26 +48,28 @@ Currently, the only valid value is: This is an array used to determine where to retrieve cluster configuration from. -Currently, the only valid cluster locator method is: +Valid cluster locator methods are: -- `config` - This cluster locator method will read cluster information from your - app-config (see below). +#### `config` -### `clusters` +This cluster locator method will read cluster information from your app-config +(see below). + +##### `clusters` Used by the `config` cluster locator method to construct Kubernetes clients. -### `clusters.\*.url` +##### `clusters.\*.url` The base URL to the Kubernetes control plane. Can be found by using the "Kubernetes master" result from running the `kubectl cluster-info` command. -### `clusters.\*.name` +##### `clusters.\*.name` A name to represent this cluster, this must be unique within the `clusters` array. Users will see this value in the Service Catalog Kubernetes plugin. -### `clusters.\*.authProvider` +##### `clusters.\*.authProvider` This determines how the Kubernetes client authenticates with the Kubernetes cluster. Valid values are: @@ -73,7 +79,7 @@ cluster. Valid values are: | `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | | `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | -### `clusters.\*.serviceAccountToken` (optional) +##### `clusters.\*.serviceAccountToken` (optional) The service account token to be used when using the `serviceAccount` auth provider. You could get the service account token with: @@ -85,6 +91,38 @@ kubectl -n get secret $(kubectl -n get sa { clusters: [], }); - const sut = ConfigClusterLocator.fromConfig( - config.getConfigArray('clusters'), - ); + const sut = ConfigClusterLocator.fromConfig(config); const result = await sut.getClusters(); @@ -44,9 +42,7 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig( - config.getConfigArray('clusters'), - ); + const sut = ConfigClusterLocator.fromConfig(config); const result = await sut.getClusters(); @@ -77,9 +73,7 @@ describe('ConfigClusterLocator', () => { ], }); - const sut = ConfigClusterLocator.fromConfig( - config.getConfigArray('clusters'), - ); + const sut = ConfigClusterLocator.fromConfig(config); const result = await sut.getClusters(); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 5ff4581f719bd..6b925aca8d02b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -24,11 +24,11 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { this.clusterDetails = clusterDetails; } - static fromConfig(config: Config[]): ConfigClusterLocator { + static fromConfig(config: Config): ConfigClusterLocator { // TODO: Add validation that authProvider is required and serviceAccountToken // is required if authProvider is serviceAccount return new ConfigClusterLocator( - config.map(c => { + config.getConfigArray('clusters').map(c => { return { name: c.getString('name'), url: c.getString('url'), diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts new file mode 100644 index 0000000000000..cf555bd8c9960 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -0,0 +1,221 @@ +/* + * 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 '@backstage/backend-common'; +import { ConfigReader, Config } from '@backstage/config'; +import { GkeClusterLocator } from './GkeClusterLocator'; + +const mockedListClusters = jest.fn(); + +describe('GkeClusterLocator', () => { + beforeEach(() => { + mockedListClusters.mockRestore(); + }); + describe('config-parsing', () => { + it('should accept missing region', async () => { + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + }); + + GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + expect(mockedListClusters).toBeCalledTimes(0); + }); + it('should not accept missing projectId', async () => { + const config: Config = new ConfigReader({ + type: 'gke', + }); + + expect(() => + GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any), + ).toThrow("Missing required config value at 'projectId'"); + + expect(mockedListClusters).toBeCalledTimes(0); + }); + }); + describe('listClusters', () => { + it('empty clusters returns empty cluster details', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + }); + + const sut = GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([]); + expect(mockedListClusters).toBeCalledTimes(1); + expect(mockedListClusters).toHaveBeenCalledWith({ + parent: 'projects/some-project/locations/some-region', + }); + }); + it('1 cluster returns 1 cluster details', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + }); + + const sut = GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + authProvider: 'google', + name: 'some-cluster', + url: 'https://1.2.3.4', + }, + ]); + expect(mockedListClusters).toBeCalledTimes(1); + expect(mockedListClusters).toHaveBeenCalledWith({ + parent: 'projects/some-project/locations/some-region', + }); + }); + it('use region wildcard when no region provided', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + }); + + const sut = GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + authProvider: 'google', + name: 'some-cluster', + url: 'https://1.2.3.4', + }, + ]); + expect(mockedListClusters).toBeCalledTimes(1); + expect(mockedListClusters).toHaveBeenCalledWith({ + parent: 'projects/some-project/locations/-', + }); + }); + it('2 cluster returns 2 cluster details', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + }, + { + name: 'some-other-cluster', + endpoint: '6.7.8.9', + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + }); + + const sut = GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + authProvider: 'google', + name: 'some-cluster', + url: 'https://1.2.3.4', + }, + { + authProvider: 'google', + name: 'some-other-cluster', + url: 'https://6.7.8.9', + }, + ]); + expect(mockedListClusters).toBeCalledTimes(1); + expect(mockedListClusters).toHaveBeenCalledWith({ + parent: 'projects/some-project/locations/some-region', + }); + }); + it('Handle errors gracefully', async () => { + mockedListClusters.mockImplementation(() => { + throw new Error('some error'); + }); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + }); + + const sut = GkeClusterLocator.fromConfigWithClient(config, { + listClusters: mockedListClusters, + } as any); + + await expect(sut.getClusters()).rejects.toThrow( + 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region : [some error]', + ); + + expect(mockedListClusters).toBeCalledTimes(1); + expect(mockedListClusters).toHaveBeenCalledWith({ + parent: 'projects/some-project/locations/some-region', + }); + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts new file mode 100644 index 0000000000000..68042d6cf4516 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2021 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 { ClusterDetails, KubernetesClustersSupplier } from '..'; +import { Config } from '@backstage/config'; +import * as container from '@google-cloud/container'; + +export class GkeClusterLocator implements KubernetesClustersSupplier { + private readonly projectId: string; + private readonly region: string | undefined; + private readonly client: container.v1.ClusterManagerClient; + + constructor( + projectId: string, + client: container.v1.ClusterManagerClient, + region?: string, + ) { + this.projectId = projectId; + this.region = region; + this.client = client; + } + + static fromConfigWithClient( + config: Config, + client: container.v1.ClusterManagerClient, + ): GkeClusterLocator { + const projectId = config.getString('projectId'); + const region = config.getOptionalString('region'); + return new GkeClusterLocator(projectId, client, region); + } + + static fromConfig(config: Config): GkeClusterLocator { + return GkeClusterLocator.fromConfigWithClient( + config, + new container.v1.ClusterManagerClient(), + ); + } + + async getClusters(): Promise { + const region = this.region ?? '-'; + const request = { + parent: `projects/${this.projectId}/locations/${region}`, + }; + + try { + const [response] = await this.client.listClusters(request); + return (response.clusters ?? []).map(r => ({ + // TODO filter out clusters which don't have name or endpoint + name: r.name ?? 'unknown', + url: `https://${r.endpoint ?? ''}`, + authProvider: 'google', + })); + } catch (e) { + throw new Error( + `There was an error retrieving clusters from GKE for projectId=${this.projectId} region=${region} : [${e.message}]`, + ); + } + } +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 63018cd338ac1..d586f9015160c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -22,17 +22,22 @@ describe('getCombinedClusterDetails', () => { const config: Config = new ConfigReader( { kubernetes: { - clusters: [ + clusterLocatorMethods: [ { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', + type: 'config', + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], }, ], }, @@ -40,7 +45,7 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - const result = await getCombinedClusterDetails(['config'], config); + const result = await getCombinedClusterDetails(config); expect(result).toStrictEqual([ { @@ -59,9 +64,36 @@ describe('getCombinedClusterDetails', () => { }); it('throws an error when using an unsupported cluster locator', async () => { - await expect( - getCombinedClusterDetails(['magic' as any], new ConfigReader({})), - ).rejects.toStrictEqual( + const config: Config = new ConfigReader( + { + kubernetes: { + clusterLocatorMethods: [ + { + type: 'config', + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], + }, + { + type: 'magic', + }, + ], + }, + }, + 'ctx', + ); + + await expect(getCombinedClusterDetails(config)).rejects.toStrictEqual( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index 9e6558510c392..712fa768fdee8 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -14,29 +14,34 @@ * limitations under the License. */ -import { ClusterDetails, ClusterLocatorMethod } from '..'; +import { ClusterDetails } from '..'; import { Config } from '@backstage/config'; import { ConfigClusterLocator } from './ConfigClusterLocator'; - -export { ConfigClusterLocator } from './ConfigClusterLocator'; +import { GkeClusterLocator } from './GkeClusterLocator'; export const getCombinedClusterDetails = async ( - clusterLocatorMethods: ClusterLocatorMethod[], rootConfig: Config, ): Promise => { return Promise.all( - clusterLocatorMethods.map(clusterLocatorMethod => { - switch (clusterLocatorMethod) { - case 'config': - return ConfigClusterLocator.fromConfig( - rootConfig.getConfigArray('kubernetes.clusters'), - ).getClusters(); - default: - throw new Error( - `Unsupported kubernetes.clusterLocatorMethods: "${clusterLocatorMethod}"`, - ); - } - }), + rootConfig + .getConfigArray('kubernetes.clusterLocatorMethods') + .map(clusterLocatorMethod => { + const type = clusterLocatorMethod.getString('type'); + switch (type) { + case 'config': + return ConfigClusterLocator.fromConfig( + clusterLocatorMethod, + ).getClusters(); + case 'gke': + return GkeClusterLocator.fromConfig( + clusterLocatorMethod, + ).getClusters(); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, + ); + } + }), ) .then(res => { return res.flat(); diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index c91cae27dfb79..842c96b86f987 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -29,7 +29,19 @@ describe('router', () => { getKubernetesObjectsByEntity: jest.fn(), } as any; - const router = makeRouter(getVoidLogger(), kubernetesFanOutHandler); + const router = makeRouter(getVoidLogger(), kubernetesFanOutHandler, [ + { + name: 'some-cluster', + authProvider: 'serviceAccount', + url: 'https://localhost:1234', + serviceAccountToken: 'someToken', + }, + { + name: 'some-other-cluster', + url: 'https://localhost:1235', + authProvider: 'google', + }, + ]); app = express().use(router); }); @@ -37,6 +49,25 @@ describe('router', () => { jest.resetAllMocks(); }); + describe('get /clusters', () => { + it('happy path: lists clusters', async () => { + const response = await request(app).get('/clusters'); + + expect(response.status).toEqual(200); + expect(response.body).toStrictEqual({ + items: [ + { + name: 'some-cluster', + authProvider: 'serviceAccount', + }, + { + name: 'some-other-cluster', + authProvider: 'google', + }, + ], + }); + }); + }); describe('post /services/:serviceId', () => { it('happy path: lists kubernetes objects without auth in request body', async () => { const result = { diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 90bea82077f74..ac98e571e0dc4 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -25,7 +25,6 @@ import { KubernetesRequestBody, KubernetesServiceLocator, ServiceLocatorMethod, - ClusterLocatorMethod, ClusterDetails, KubernetesClustersSupplier, } from '..'; @@ -43,7 +42,7 @@ const getServiceLocator = ( clusterDetails: ClusterDetails[], ): KubernetesServiceLocator => { const serviceLocatorMethod = config.getString( - 'kubernetes.serviceLocatorMethod', + 'kubernetes.serviceLocatorMethod.type', ) as ServiceLocatorMethod; switch (serviceLocatorMethod) { @@ -61,6 +60,7 @@ const getServiceLocator = ( export const makeRouter = ( logger: Logger, kubernetesFanOutHandler: KubernetesFanOutHandler, + clusterDetails: ClusterDetails[], ): express.Router => { const router = Router(); router.use(express.json()); @@ -81,6 +81,14 @@ export const makeRouter = ( } }); + router.get('/clusters', async (_, res) => { + res.json({ + items: clusterDetails.map(cd => ({ + name: cd.name, + authProvider: cd.authProvider, + })), + }); + }); return router; }; @@ -96,21 +104,18 @@ export async function createRouter( logger, }); - const clusterLocatorMethods = options.config.getStringArray( - 'kubernetes.clusterLocatorMethods', - ) as ClusterLocatorMethod[]; - let clusterDetails: ClusterDetails[]; if (options.clusterSupplier) { clusterDetails = await options.clusterSupplier.getClusters(); } else { - clusterDetails = await getCombinedClusterDetails( - clusterLocatorMethods, - options.config, - ); + clusterDetails = await getCombinedClusterDetails(options.config); } + logger.info( + `action=loadClusterDetails numOfClustersLoaded=${clusterDetails.length}`, + ); + const serviceLocator = getServiceLocator(options.config, clusterDetails); const kubernetesFanOutHandler = new KubernetesFanOutHandler( @@ -119,5 +124,5 @@ export async function createRouter( serviceLocator, ); - return makeRouter(logger, kubernetesFanOutHandler); + return makeRouter(logger, kubernetesFanOutHandler, clusterDetails); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 86191c6d72c92..4309bc90753a3 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -146,6 +146,49 @@ export interface KubernetesFetchError { resourcePath?: string; } +export interface ConfigClusterLocatorMethod { + /** + * @visibility frontend + */ + type: 'config'; + clusters: { + /** + * @visibility frontend + */ + url: string; + /** + * @visibility frontend + */ + name: string; + /** + * @visibility secret + */ + serviceAccountToken: string | undefined; + /** + * @visibility frontend + */ + authProvider: 'aws' | 'google' | 'serviceAccount'; + }[]; +} + +export interface GKEClusterLocatorMethod { + /** + * @visibility frontend + */ + type: 'gke'; + /** + * @visibility frontend + */ + projectId: string; + /** + * @visibility frontend + */ + region?: string; +} + +export type ClusterLocatorMethod = + | ConfigClusterLocatorMethod + | GKEClusterLocatorMethod; + export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http -export type ClusterLocatorMethod = 'config'; export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; diff --git a/plugins/kubernetes/schema.d.ts b/plugins/kubernetes/schema.d.ts index 39598edb39d4b..ce8b94e917e06 100644 --- a/plugins/kubernetes/schema.d.ts +++ b/plugins/kubernetes/schema.d.ts @@ -13,33 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { ClusterLocatorMethod } from '@backstage/plugin-kubernetes-backend'; + export interface Config { kubernetes?: { /** * @visibility frontend */ - serviceLocatorMethod: 'multiTenant'; - /** - * @visibility frontend - */ - clusterLocatorMethods: 'config'[]; - clusters: { - /** - * @visibility frontend - */ - url: string; - /** - * @visibility frontend - */ - name: string; - /** - * @visibility secret - */ - serviceAccountToken: string | undefined; + serviceLocatorMethod: { /** * @visibility frontend */ - authProvider: 'aws' | 'google' | 'serviceAccount'; - }[]; + type: 'multiTenant'; + }; + /** + * @visibility frontend + */ + clusterLocatorMethods: ClusterLocatorMethod[]; }; } diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 353f5e880058c..1eadec3fb0973 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -14,44 +14,26 @@ * limitations under the License. */ -import { ConfigApi, DiscoveryApi, IdentityApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { KubernetesApi } from './types'; import { KubernetesRequestBody, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; -import { Config } from '@backstage/config'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; - private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; - configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; - this.configApi = options.configApi; } - private async getRequired( - path: string, - requestBody: KubernetesRequestBody, - ): Promise { - const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; - const idToken = await this.identityApi.getIdToken(); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(idToken && { Authorization: `Bearer ${idToken}` }), - }, - body: JSON.stringify(requestBody), - }); - + private async handleResponse(response: Response): Promise { if (!response.ok) { const payload = await response.text(); let message; @@ -69,16 +51,40 @@ export class KubernetesBackendClient implements KubernetesApi { return await response.json(); } + private async postRequired( + path: string, + requestBody: KubernetesRequestBody, + ): Promise { + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; + const idToken = await this.identityApi.getIdToken(); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + body: JSON.stringify(requestBody), + }); + + return this.handleResponse(response); + } + async getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise { - return await this.getRequired( + return await this.postRequired( `/services/${requestBody.entity.metadata.name}`, requestBody, ); } - getClusters(): Config[] { - return this.configApi.getConfigArray('kubernetes.clusters'); + async getClusters(): Promise<{ name: string; authProvider: string }[]> { + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; + + const response = await fetch(url, { + method: 'GET', + }); + + return (await this.handleResponse(response)).items; } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index d9ed79e720899..da1b36989a76d 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; import { createApiRef } from '@backstage/core'; import { KubernetesRequestBody, @@ -31,5 +30,5 @@ export interface KubernetesApi { getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise; - getClusters(): Config[]; + getClusters(): Promise<{ name: string; authProvider: string }[]>; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index ec0826757960b..c61676fc06c10 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -23,7 +23,6 @@ import { Grid, Typography, } from '@material-ui/core'; -import { Config } from '@backstage/config'; import { Content, Page, @@ -168,16 +167,14 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { >(undefined); const [error, setError] = useState(undefined); - const clusters: Config[] = kubernetesApi.getClusters(); - const allAuthProviders: string[] = clusters.map(c => - c.getString('authProvider'), - ); - const authProviders: string[] = [...new Set(allAuthProviders)]; - const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef); useEffect(() => { (async () => { + const clusters = await kubernetesApi.getClusters(); + const authProviders: string[] = [ + ...new Set(clusters.map(c => c.authProvider)), + ]; // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider let requestBody: KubernetesRequestBody = { entity, diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index 6da1abb4b693a..278bd95e85152 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -21,7 +21,6 @@ import { identityApiRef, googleAuthApiRef, createRoutableExtension, - configApiRef, } from '@backstage/core'; import { KubernetesBackendClient } from './api/KubernetesBackendClient'; import { kubernetesApiRef } from './api/types'; @@ -41,10 +40,9 @@ export const kubernetesPlugin = createPlugin({ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef, - configApi: configApiRef, }, - factory: ({ discoveryApi, identityApi, configApi }) => - new KubernetesBackendClient({ discoveryApi, identityApi, configApi }), + factory: ({ discoveryApi, identityApi }) => + new KubernetesBackendClient({ discoveryApi, identityApi }), }), createApiFactory({ api: kubernetesAuthProvidersApiRef, diff --git a/yarn.lock b/yarn.lock index 1be89458ee171..11d31182b9c49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2450,6 +2450,13 @@ retry-request "^4.1.1" teeny-request "^7.0.0" +"@google-cloud/container@^2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@google-cloud/container/-/container-2.2.0.tgz#e97ae1cee9040b6af09cc8199ed0aa2d4ae6238e" + integrity sha512-O9xoAGo2qwBezcpIdKcp/zvSX2jUuTRISwXp26dOS+2jeJKpC8jWYU7aRUC8DGxvkEMK9wWESMe44XEBWnHq2Q== + dependencies: + google-gax "^2.9.2" + "@google-cloud/paginator@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-0.2.0.tgz#eab2e6aa4b81df7418f6c51e2071f64dab2c2fa5" @@ -2970,6 +2977,23 @@ is-promise "4.0.0" tslib "~2.0.1" +"@grpc/grpc-js@~1.2.0": + version "1.2.10" + resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.2.10.tgz#f316d29a45fcc324e923d593cb849d292b1ed598" + integrity sha512-wj6GkNiorWYaPiIZ767xImmw7avMMVUweTvPFg4mJWOxz2180DKwfuxhJJZ7rpc1+7D3mX/v8vJdxTuIo71Ieg== + dependencies: + "@types/node" ">=12.12.47" + google-auth-library "^6.1.1" + semver "^6.2.0" + +"@grpc/proto-loader@^0.5.1": + version "0.5.6" + resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.5.6.tgz#1dea4b8a6412b05e2d58514d507137b63a52a98d" + integrity sha512-DT14xgw3PSzPxwS13auTEwxhMMOoz33DPUKNtmYK/QYbBSpLXJy78FGGs5yVoxVobEqPm4iW9MOIoz0A3bLTRQ== + dependencies: + lodash.camelcase "^4.3.0" + protobufjs "^6.8.6" + "@hapi/hoek@^9.0.0": version "9.0.4" resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz#e80ad4e8e8d2adc6c77d985f698447e8628b6010" @@ -6415,7 +6439,7 @@ resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== -"@types/long@^4.0.0": +"@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.1" resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== @@ -6527,6 +6551,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA== +"@types/node@>=12.12.47": + version "14.14.31" + resolved "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055" + integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g== + "@types/node@^10.1.0", "@types/node@^10.12.0": version "10.17.35" resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56" @@ -6537,6 +6566,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c" integrity sha512-Be46CNIHWAagEfINOjmriSxuv7IVcqbGe+sDSg2SYCEz/0CRBy7LRASGfRbD8KZkqoePU73Wsx3UvOSFcq/9hA== +"@types/node@^13.7.0": + version "13.13.45" + resolved "https://registry.npmjs.org/@types/node/-/node-13.13.45.tgz#e6676bcca092bae5751d015f074a234d5a82eb63" + integrity sha512-703YTEp8AwQeapI0PTXDOj+Bs/mtdV/k9VcTP7z/de+lx6XjFMKdB+JhKnK+6PZ5za7omgZ3V6qm/dNkMj/Zow== + "@types/node@^13.7.2": version "13.13.15" resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" @@ -13112,7 +13146,7 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== -fast-text-encoding@^1.0.0: +fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== @@ -14203,6 +14237,38 @@ google-auth-library@^6.0.0, google-auth-library@^6.1.1: jws "^4.0.0" lru-cache "^6.0.0" +google-auth-library@^7.0.2: + version "7.0.2" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.0.2.tgz#cab6fc7f94ebecc97be6133d6519d9946ccf3e9d" + integrity sha512-vjyNZR3pDLC0u7GHLfj+Hw9tGprrJwoMwkYGqURCXYITjCrP9HprOyxVV+KekdLgATtWGuDkQG2MTh0qpUPUgg== + dependencies: + arrify "^2.0.0" + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + fast-text-encoding "^1.0.0" + gaxios "^4.0.0" + gcp-metadata "^4.2.0" + gtoken "^5.0.4" + jws "^4.0.0" + lru-cache "^6.0.0" + +google-gax@^2.9.2: + version "2.10.3" + resolved "https://registry.npmjs.org/google-gax/-/google-gax-2.10.3.tgz#53278bb5cc4b654876ade774a249f0241fdb15cc" + integrity sha512-jESs/ME9WgMzfGQKJDu9ea2mEKjznKByRL+5xb8mKfHlbUfS/LxNLNCg/35RgXwVXcNSCqkEY90z8wHxvgdd/Q== + dependencies: + "@grpc/grpc-js" "~1.2.0" + "@grpc/proto-loader" "^0.5.1" + "@types/long" "^4.0.0" + abort-controller "^3.0.0" + duplexify "^4.0.0" + fast-text-encoding "^1.0.3" + google-auth-library "^7.0.2" + is-stream-ended "^0.1.4" + node-fetch "^2.6.1" + protobufjs "^6.10.2" + retry-request "^4.0.0" + google-p12-pem@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-1.0.4.tgz#b77fb833a2eb9f7f3c689e2e54f095276f777605" @@ -21310,6 +21376,25 @@ proto-list@~1.2.1: resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= +protobufjs@^6.10.2, protobufjs@^6.8.6: + version "6.10.2" + resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.2.tgz#b9cb6bd8ec8f87514592ba3fdfd28e93f33a469b" + integrity sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/long" "^4.0.1" + "@types/node" "^13.7.0" + long "^4.0.0" + protocols@^1.1.0, protocols@^1.4.0: version "1.4.7" resolved "https://registry.npmjs.org/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32"