diff --git a/.changeset/eleven-students-brake.md b/.changeset/eleven-students-brake.md new file mode 100644 index 0000000000..b2cad0bfd6 --- /dev/null +++ b/.changeset/eleven-students-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-gcp': patch +--- + +Allow integration with kubernetes dashboard diff --git a/.changeset/fair-tools-bake.md b/.changeset/fair-tools-bake.md new file mode 100644 index 0000000000..fd7b21bbc8 --- /dev/null +++ b/.changeset/fair-tools-bake.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Allow storing dashboard parameters for kubernetes in catalog diff --git a/plugins/catalog-backend-module-gcp/package.json b/plugins/catalog-backend-module-gcp/package.json index dc3ae7395d..e9e32a07af 100644 --- a/plugins/catalog-backend-module-gcp/package.json +++ b/plugins/catalog-backend-module-gcp/package.json @@ -48,6 +48,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/backend-tasks": "workspace:^", + "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", diff --git a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts index 76df12e4e4..637963d8e3 100644 --- a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.test.ts @@ -16,11 +16,6 @@ import { GkeEntityProvider } from './GkeEntityProvider'; import { TaskRunner } from '@backstage/backend-tasks'; -import { - ANNOTATION_KUBERNETES_API_SERVER, - ANNOTATION_KUBERNETES_API_SERVER_CA, - ANNOTATION_KUBERNETES_AUTH_PROVIDER, -} from '@backstage/plugin-kubernetes-common'; import * as container from '@google-cloud/container'; import { ConfigReader } from '@backstage/config'; @@ -55,7 +50,10 @@ describe('GkeEntityProvider', () => { providers: { gcp: { gke: { - parents: ['parent1', 'parent2'], + parents: [ + 'projects/parent1/locations/-', + 'projects/parent2/locations/some-other-location', + ], schedule: { frequency: { minutes: 3, @@ -77,7 +75,7 @@ describe('GkeEntityProvider', () => { it('should return clusters as Resources', async () => { clusterManagerClientMock.listClusters.mockImplementation(req => { - if (req.parent === 'parent1') { + if (req.parent === 'projects/parent1/locations/-') { return [ { clusters: [ @@ -93,7 +91,9 @@ describe('GkeEntityProvider', () => { ], }, ]; - } else if (req.parent === 'parent2') { + } else if ( + req.parent === 'projects/parent2/locations/some-other-location' + ) { return [ { clusters: [ @@ -114,58 +114,7 @@ describe('GkeEntityProvider', () => { throw new Error(`unexpected parent ${req.parent}`); }); await gkeEntityProvider.refresh(); - expect(connectionMock.applyMutation).toHaveBeenCalledWith({ - type: 'full', - entities: [ - { - locationKey: 'gcp-gke:some-location', - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Resource', - metadata: { - annotations: { - [ANNOTATION_KUBERNETES_API_SERVER]: 'https://127.0.0.1', - [ANNOTATION_KUBERNETES_API_SERVER_CA]: 'abcdefg', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google', - 'backstage.io/managed-by-location': 'gcp-gke:some-location', - 'backstage.io/managed-by-origin-location': - 'gcp-gke:some-location', - }, - name: 'some-cluster', - namespace: 'default', - }, - spec: { - type: 'kubernetes-cluster', - owner: 'unknown', - }, - }, - }, - { - locationKey: 'gcp-gke:some-other-location', - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Resource', - metadata: { - annotations: { - [ANNOTATION_KUBERNETES_API_SERVER]: 'https://127.0.0.1', - [ANNOTATION_KUBERNETES_API_SERVER_CA]: '', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google', - 'backstage.io/managed-by-location': - 'gcp-gke:some-other-location', - 'backstage.io/managed-by-origin-location': - 'gcp-gke:some-other-location', - }, - name: 'some-other-cluster', - namespace: 'default', - }, - spec: { - type: 'kubernetes-cluster', - owner: 'unknown', - }, - }, - }, - ], - }); + expect(connectionMock.applyMutation).toMatchSnapshot(); }); const ignoredPartialClustersTests: [ @@ -223,7 +172,7 @@ describe('GkeEntityProvider', () => { 'ignore cluster - %s', async (_name, ignoredCluster) => { clusterManagerClientMock.listClusters.mockImplementation(req => { - if (req.parent === 'parent1') { + if (req.parent === 'projects/parent1/locations/-') { return [ignoredCluster]; } return [ diff --git a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts index 384cd839b2..3b2be79587 100644 --- a/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts +++ b/plugins/catalog-backend-module-gcp/src/providers/GkeEntityProvider.ts @@ -29,9 +29,15 @@ import { ANNOTATION_KUBERNETES_API_SERVER, ANNOTATION_KUBERNETES_API_SERVER_CA, ANNOTATION_KUBERNETES_AUTH_PROVIDER, + ANNOTATION_KUBERNETES_DASHBOARD_APP, + ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS, } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; import { SchedulerService } from '@backstage/backend-plugin-api'; +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, +} from '@backstage/catalog-model'; /** * Catalog provider to ingest GKE clusters @@ -120,10 +126,16 @@ export class GkeEntityProvider implements EntityProvider { private clusterToResource( cluster: container.protos.google.container.v1.ICluster, + project: string, ): DeferredEntity | undefined { const location = `${this.getProviderName()}:${cluster.location}`; - if (!cluster.name || !cluster.selfLink || !location || !cluster.endpoint) { + if ( + !cluster.name || + !cluster.selfLink || + !cluster.endpoint || + !cluster.location + ) { this.logger.warn( `ignoring partial cluster, one of name=${cluster.name}, endpoint=${cluster.endpoint}, selfLink=${cluster.selfLink} or location=${cluster.location} is missing`, ); @@ -142,8 +154,14 @@ export class GkeEntityProvider implements EntityProvider { [ANNOTATION_KUBERNETES_API_SERVER_CA]: cluster.masterAuth?.clusterCaCertificate || '', [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google', - 'backstage.io/managed-by-location': location, - 'backstage.io/managed-by-origin-location': location, + [ANNOTATION_KUBERNETES_DASHBOARD_APP]: 'gke', + [ANNOTATION_LOCATION]: location, + [ANNOTATION_ORIGIN_LOCATION]: location, + [ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS]: JSON.stringify({ + projectId: project, + region: cluster.location, + clusterName: cluster.name, + }), }, name: cluster.name, namespace: 'default', @@ -172,18 +190,22 @@ export class GkeEntityProvider implements EntityProvider { }; } - private async getClusters(): Promise< - container.protos.google.container.v1.ICluster[] - > { + private async getClusters(): Promise { const clusters = await Promise.all( this.gkeParents.map(async parent => { + const project = parent.split('/')[1]; const request = { parent: parent, }; const [response] = await this.clusterManagerClient.listClusters( request, ); - return response.clusters?.filter(this.filterOutUndefinedCluster) ?? []; + return ( + response.clusters + ?.filter(this.filterOutUndefinedCluster) + .map(c => this.clusterToResource(c, project)) + .filter(this.filterOutUndefinedDeferredEntity) ?? [] + ); }), ); return clusters.flat(); @@ -196,18 +218,14 @@ export class GkeEntityProvider implements EntityProvider { this.logger.info('Discovering GKE clusters'); - let clusters: container.protos.google.container.v1.ICluster[]; + let resources: DeferredEntity[]; try { - clusters = await this.getClusters(); + resources = await this.getClusters(); } catch (e) { this.logger.error('error fetching GKE clusters', e); return; } - const resources = - clusters - .map(c => this.clusterToResource(c)) - .filter(this.filterOutUndefinedDeferredEntity) ?? []; this.logger.info( `Ingesting GKE clusters [${resources diff --git a/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap b/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap new file mode 100644 index 0000000000..d20efb9c8d --- /dev/null +++ b/plugins/catalog-backend-module-gcp/src/providers/__snapshots__/GkeEntityProvider.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GkeEntityProvider should return clusters as Resources 1`] = ` +[MockFunction] { + "calls": [ + [ + { + "entities": [ + { + "entity": { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Resource", + "metadata": { + "annotations": { + "backstage.io/managed-by-location": "gcp-gke:some-location", + "backstage.io/managed-by-origin-location": "gcp-gke:some-location", + "kubernetes.io/api-server": "https://127.0.0.1", + "kubernetes.io/api-server-certificate-authority": "abcdefg", + "kubernetes.io/auth-provider": "google", + "kubernetes.io/dashboard-app": "gke", + "kubernetes.io/dashboard-parameters": "{"projectId":"parent1","region":"some-location","clusterName":"some-cluster"}", + }, + "name": "some-cluster", + "namespace": "default", + }, + "spec": { + "owner": "unknown", + "type": "kubernetes-cluster", + }, + }, + "locationKey": "gcp-gke:some-location", + }, + { + "entity": { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Resource", + "metadata": { + "annotations": { + "backstage.io/managed-by-location": "gcp-gke:some-other-location", + "backstage.io/managed-by-origin-location": "gcp-gke:some-other-location", + "kubernetes.io/api-server": "https://127.0.0.1", + "kubernetes.io/api-server-certificate-authority": "", + "kubernetes.io/auth-provider": "google", + "kubernetes.io/dashboard-app": "gke", + "kubernetes.io/dashboard-parameters": "{"projectId":"parent2","region":"some-other-location","clusterName":"some-other-cluster"}", + }, + "name": "some-other-cluster", + "namespace": "default", + }, + "spec": { + "owner": "unknown", + "type": "kubernetes-cluster", + }, + }, + "locationKey": "gcp-gke:some-other-location", + }, + ], + "type": "full", + }, + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts index cd8195b9dc..ad09f52f0a 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.test.ts @@ -23,7 +23,6 @@ import { } from '@backstage/plugin-kubernetes-common'; import { CatalogClusterLocator } from './CatalogClusterLocator'; import { CatalogApi } from '@backstage/catalog-client'; -import { ClusterDetails } from '../types/types'; const mockCatalogApi = { getEntityByRef: jest.fn(), @@ -93,25 +92,7 @@ describe('CatalogClusterLocator', () => { const result = await clusterSupplier.getClusters(); expect(result).toHaveLength(2); - expect(result[0]).toStrictEqual({ - name: 'owned', - url: 'https://apiserver.com', - caData: 'caData', - authMetadata: { - 'kubernetes.io/api-server': 'https://apiserver.com', - 'kubernetes.io/api-server-certificate-authority': 'caData', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'oidc', - [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', - 'kubernetes.io/skip-metrics-lookup': 'true', - 'kubernetes.io/skip-tls-verify': 'true', - 'kubernetes.io/dashboard-url': 'my-url', - 'kubernetes.io/dashboard-app': 'my-app', - }, - skipMetricsLookup: true, - skipTLSVerify: true, - dashboardUrl: 'my-url', - dashboardApp: 'my-app', - }); + expect(result[0]).toMatchSnapshot(); }); it('returns the aws cluster details provided by annotations', async () => { @@ -120,24 +101,6 @@ describe('CatalogClusterLocator', () => { const result = await clusterSupplier.getClusters(); expect(result).toHaveLength(2); - expect(result[1]).toStrictEqual({ - name: 'owned', - url: 'https://apiserver.com', - caData: 'caData', - authMetadata: { - 'kubernetes.io/api-server': 'https://apiserver.com', - 'kubernetes.io/api-server-certificate-authority': 'caData', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', - [ANNOTATION_KUBERNETES_AWS_ASSUME_ROLE]: 'my-role', - [ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID]: 'my-id', - [ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER]: 'google', - 'kubernetes.io/dashboard-url': 'my-url', - 'kubernetes.io/dashboard-app': 'my-app', - }, - skipMetricsLookup: false, - skipTLSVerify: false, - dashboardUrl: 'my-url', - dashboardApp: 'my-app', - }); + expect(result[1]).toMatchSnapshot(); }); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts index f8abd5b203..9be4cd39cc 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts @@ -24,7 +24,13 @@ import { ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY, ANNOTATION_KUBERNETES_DASHBOARD_URL, ANNOTATION_KUBERNETES_DASHBOARD_APP, + ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS, } from '@backstage/plugin-kubernetes-common'; +import { JsonObject } from '@backstage/types'; + +function isObject(obj: unknown): obj is JsonObject { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} export class CatalogClusterLocator implements KubernetesClustersSupplier { private catalogClient: CatalogApi; @@ -54,27 +60,38 @@ export class CatalogClusterLocator implements KubernetesClustersSupplier { filter: [filter], }); return clusters.items.map(entity => { + const annotations = entity.metadata.annotations!; const clusterDetails: ClusterDetails = { name: entity.metadata.name, - url: entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER]!, - authMetadata: entity.metadata.annotations!, - caData: - entity.metadata.annotations![ANNOTATION_KUBERNETES_API_SERVER_CA]!, + url: annotations[ANNOTATION_KUBERNETES_API_SERVER], + authMetadata: annotations, + caData: annotations[ANNOTATION_KUBERNETES_API_SERVER_CA], skipMetricsLookup: - entity.metadata.annotations![ - ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP - ]! === 'true', + annotations[ANNOTATION_KUBERNETES_SKIP_METRICS_LOOKUP] === 'true', skipTLSVerify: - entity.metadata.annotations![ - ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY - ]! === 'true', - dashboardUrl: - entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_URL]!, - dashboardApp: - entity.metadata.annotations![ANNOTATION_KUBERNETES_DASHBOARD_APP]!, + annotations[ANNOTATION_KUBERNETES_SKIP_TLS_VERIFY] === 'true', + dashboardUrl: annotations[ANNOTATION_KUBERNETES_DASHBOARD_URL], + dashboardApp: annotations[ANNOTATION_KUBERNETES_DASHBOARD_APP], + dashboardParameters: this.getDashboardParameters(annotations), }; return clusterDetails; }); } + + private getDashboardParameters( + annotations: Record, + ): JsonObject | undefined { + const dashboardParamsString = + annotations[ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS]; + if (dashboardParamsString) { + try { + const dashboardParams = JSON.parse(dashboardParamsString); + return isObject(dashboardParams) ? dashboardParams : undefined; + } catch { + return undefined; + } + } + return undefined; + } } diff --git a/plugins/kubernetes-backend/src/cluster-locator/__snapshots__/CatalogClusterLocator.test.ts.snap b/plugins/kubernetes-backend/src/cluster-locator/__snapshots__/CatalogClusterLocator.test.ts.snap new file mode 100644 index 0000000000..b3731f1c08 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/__snapshots__/CatalogClusterLocator.test.ts.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CatalogClusterLocator returns the aws cluster details provided by annotations 1`] = ` +{ + "authMetadata": { + "kubernetes.io/api-server": "https://apiserver.com", + "kubernetes.io/api-server-certificate-authority": "caData", + "kubernetes.io/auth-provider": "aws", + "kubernetes.io/aws-assume-role": "my-role", + "kubernetes.io/aws-external-id": "my-id", + "kubernetes.io/dashboard-app": "my-app", + "kubernetes.io/dashboard-url": "my-url", + "kubernetes.io/oidc-token-provider": "google", + }, + "caData": "caData", + "dashboardApp": "my-app", + "dashboardParameters": undefined, + "dashboardUrl": "my-url", + "name": "owned", + "skipMetricsLookup": false, + "skipTLSVerify": false, + "url": "https://apiserver.com", +} +`; + +exports[`CatalogClusterLocator returns the cluster details provided by annotations 1`] = ` +{ + "authMetadata": { + "kubernetes.io/api-server": "https://apiserver.com", + "kubernetes.io/api-server-certificate-authority": "caData", + "kubernetes.io/auth-provider": "oidc", + "kubernetes.io/dashboard-app": "my-app", + "kubernetes.io/dashboard-url": "my-url", + "kubernetes.io/oidc-token-provider": "google", + "kubernetes.io/skip-metrics-lookup": "true", + "kubernetes.io/skip-tls-verify": "true", + }, + "caData": "caData", + "dashboardApp": "my-app", + "dashboardParameters": undefined, + "dashboardUrl": "my-url", + "name": "owned", + "skipMetricsLookup": true, + "skipTLSVerify": true, + "url": "https://apiserver.com", +} +`; diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 8daeb9a875..acf2e12bb2 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -46,6 +46,10 @@ export const ANNOTATION_KUBERNETES_AWS_EXTERNAL_ID = export const ANNOTATION_KUBERNETES_DASHBOARD_APP = 'kubernetes.io/dashboard-app'; +// @public +export const ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS = + 'kubernetes.io/dashboard-parameters'; + // @public export const ANNOTATION_KUBERNETES_DASHBOARD_URL = 'kubernetes.io/dashboard-url'; diff --git a/plugins/kubernetes-common/src/catalog-entity-constants.ts b/plugins/kubernetes-common/src/catalog-entity-constants.ts index 8a826d4316..e7e5cddc25 100644 --- a/plugins/kubernetes-common/src/catalog-entity-constants.ts +++ b/plugins/kubernetes-common/src/catalog-entity-constants.ts @@ -77,6 +77,13 @@ export const ANNOTATION_KUBERNETES_DASHBOARD_URL = export const ANNOTATION_KUBERNETES_DASHBOARD_APP = 'kubernetes.io/dashboard-app'; +/** + * Annotation for specifying the dashboard app parameters for a Kubernetes cluster. + * + * @public + */ +export const ANNOTATION_KUBERNETES_DASHBOARD_PARAMETERS = + 'kubernetes.io/dashboard-parameters'; /** * Annotation for specifying the assume role use to authenticate with AWS. * diff --git a/yarn.lock b/yarn.lock index 66631f1887..91e719b59e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5432,6 +5432,7 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-tasks": "workspace:^" "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^"