From 7ac0bd2c66671d17d2323b76e38e8671110eec57 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Mon, 27 Dec 2021 00:43:43 -0500 Subject: [PATCH 1/5] implement dashboard link formatter for GKE Signed-off-by: Morgan Martinet --- .changeset/sharp-seas-remain.md | 7 + docs/features/kubernetes/configuration.md | 60 +++++- plugins/kubernetes-backend/api-report.md | 1 + .../cluster-locator/ConfigClusterLocator.ts | 4 + .../cluster-locator/GkeClusterLocator.test.ts | 46 +++++ .../src/cluster-locator/GkeClusterLocator.ts | 21 +- .../src/service/KubernetesFanOutHandler.ts | 4 + plugins/kubernetes-backend/src/types/types.ts | 7 + plugins/kubernetes-common/api-report.md | 1 + plugins/kubernetes-common/src/types.ts | 6 + .../KubernetesDrawer/KubernetesDrawer.tsx | 1 + plugins/kubernetes/src/types/types.ts | 3 +- .../utils/clusterLinks/formatClusterLink.ts | 10 +- .../utils/clusterLinks/formatters/gke.test.ts | 195 +++++++++++++++++- .../src/utils/clusterLinks/formatters/gke.ts | 58 +++++- .../clusterLinks/formatters/openshift.test.ts | 13 ++ .../clusterLinks/formatters/openshift.ts | 3 + .../clusterLinks/formatters/rancher.test.ts | 13 ++ .../utils/clusterLinks/formatters/rancher.ts | 3 + .../clusterLinks/formatters/standard.test.ts | 28 +++ .../utils/clusterLinks/formatters/standard.ts | 4 + 21 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 .changeset/sharp-seas-remain.md diff --git a/.changeset/sharp-seas-remain.md b/.changeset/sharp-seas-remain.md new file mode 100644 index 0000000000..b482587aa3 --- /dev/null +++ b/.changeset/sharp-seas-remain.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +implement dashboard link formatter for GKE diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 32ab830322..0411922a98 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -113,9 +113,13 @@ kubectl -n get secret $(kubectl -n get sa { parent: 'projects/some-project/locations/some-region', }); }); + it('expose GKE dashboard', 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', + skipMetricsLookup: true, + exposeDashboard: true, + }); + + 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', + skipTLSVerify: false, + skipMetricsLookup: true, + dashboardApp: 'gke', + dashboardParameters: { + clusterName: 'some-cluster', + projectId: 'some-project', + region: 'some-region', + }, + }, + ]); + 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 index d7f5050399..8b70db40eb 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -24,6 +24,7 @@ type GkeClusterLocatorOptions = { region?: string; skipTLSVerify?: boolean; skipMetricsLookup?: boolean; + exposeDashboard?: boolean; }; export class GkeClusterLocator implements KubernetesClustersSupplier { @@ -42,6 +43,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false, skipMetricsLookup: config.getOptionalBoolean('skipMetricsLookup') ?? false, + exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, }; return new GkeClusterLocator(options, client); } @@ -55,8 +57,13 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // TODO pass caData into the object async getClusters(): Promise { - const { projectId, region, skipTLSVerify, skipMetricsLookup } = - this.options; + const { + projectId, + region, + skipTLSVerify, + skipMetricsLookup, + exposeDashboard, + } = this.options; const request = { parent: `projects/${projectId}/locations/${region}`, }; @@ -70,6 +77,16 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { authProvider: 'google', skipTLSVerify, skipMetricsLookup, + ...(exposeDashboard + ? { + dashboardApp: 'gke', + dashboardParameters: { + projectId, + region, + clusterName: r.name, + }, + } + : {}), })); } catch (e) { throw new ForwardedError( diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index a25c220b80..b15ec1386e 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -255,6 +255,10 @@ export class KubernetesFanOutHandler { if (clusterDetailsItem.dashboardApp) { objects.cluster.dashboardApp = clusterDetailsItem.dashboardApp; } + if (clusterDetailsItem.dashboardParameters) { + objects.cluster.dashboardParameters = + clusterDetailsItem.dashboardParameters; + } return objects; }); }), diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index a0ec0832c1..6be205e4e8 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -111,6 +111,7 @@ export interface ClusterDetails { * using the dashboardApp property, in order to properly format * links to kubernetes resources, otherwise it will assume that you're running the standard one. * @see dashboardApp + * @see dashboardParameters */ dashboardUrl?: string; /** @@ -129,6 +130,12 @@ export interface ClusterDetails { * ``` */ dashboardApp?: string; + /** + * Specifies specific parameters used by some dashboard URL formatters. + * This is used by the GKE formatter which requires the project, region and cluster name. + * @see dashboardApp + */ + dashboardParameters?: any; } export interface GKEClusterDetails extends ClusterDetails {} diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index f3500aa271..c6ef21592b 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -62,6 +62,7 @@ export interface ClientPodStatus { // @public (undocumented) export interface ClusterAttributes { dashboardApp?: string; + dashboardParameters?: any; dashboardUrl?: string; name: string; } diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 0c5af00a97..ff40ee5119 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -45,6 +45,7 @@ export interface ClusterAttributes { * Note that you should specify the app used for the dashboard * using the dashboardApp property, in order to properly format * links to kubernetes resources, otherwise it will assume that you're running the standard one. + * Also, for cloud clusters such as GKE, you should provide addititonal parameters using dashboardParameters. * @see dashboardApp */ dashboardUrl?: string; @@ -64,6 +65,11 @@ export interface ClusterAttributes { * ``` */ dashboardApp?: string; + /** + * Specifies specific parameters used by some dashboard URL formatters. + * This is used by the GKE formatter which requires the project, region and cluster name. + */ + dashboardParameters?: any; } export interface ClusterObjects { diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index b4ea31dc8d..8a177cf6dd 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -155,6 +155,7 @@ const KubernetesDrawerContent = ({ const { clusterLink, errorMessage } = tryFormatClusterLink({ dashboardUrl: cluster.dashboardUrl, dashboardApp: cluster.dashboardApp, + dashboardParameters: cluster.dashboardParameters, object, kind, }); diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index d6ef8fa784..92e06e2e04 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -43,7 +43,8 @@ export interface GroupedResponses extends DeploymentResources { } export interface ClusterLinksFormatterOptions { - dashboardUrl: URL; + dashboardUrl?: URL; + dashboardParameters?: any; object: any; kind: string; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts index a8f83a9c76..90fd616737 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -19,15 +19,16 @@ import { defaultFormatterName, clusterLinksFormatters } from './formatters'; export type FormatClusterLinkOptions = { dashboardUrl?: string; dashboardApp?: string; + dashboardParameters?: any; object: any; kind: string; }; export function formatClusterLink(options: FormatClusterLinkOptions) { - if (!options.dashboardUrl) { + if (!options.dashboardUrl && !options.dashboardParameters) { return undefined; } - if (!options.object) { + if (options.dashboardUrl && !options.object) { return options.dashboardUrl; } const app = options.dashboardApp || defaultFormatterName; @@ -36,7 +37,10 @@ export function formatClusterLink(options: FormatClusterLinkOptions) { throw new Error(`Could not find Kubernetes dashboard app named '${app}'`); } const url = formatter({ - dashboardUrl: new URL(options.dashboardUrl), + dashboardUrl: options.dashboardUrl + ? new URL(options.dashboardUrl) + : undefined, + dashboardParameters: options.dashboardParameters, object: options.object, kind: options.kind, }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts index e566404daf..7560c9e353 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -16,10 +16,9 @@ import { gkeFormatter } from './gke'; describe('clusterLinks - GKE formatter', () => { - it('should return an url on the workloads when there is a namespace only', () => { + it('should provide a dashboardParameters in the options', () => { expect(() => gkeFormatter({ - dashboardUrl: new URL('https://k8s.foo.com'), object: { metadata: { name: 'foobar', @@ -28,6 +27,196 @@ describe('clusterLinks - GKE formatter', () => { }, kind: 'Deployment', }), - ).toThrowError('GKE formatter is not yet implemented. Please, contribute!'); + ).toThrowError('GKE dashboard requires a dashboardParameters option'); + }); + it('should provide a projectId in the dashboardParameters options', () => { + expect(() => + gkeFormatter({ + dashboardParameters: { + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'GKE dashboard requires a "projectId" in the dashboardParameters option', + ); + }); + it('should provide a region in the dashboardParameters options', () => { + expect(() => + gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'GKE dashboard requires a "region" in the dashboardParameters option', + ); + }); + it('should provide a clusterName in the dashboardParameters options', () => { + expect(() => + gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'GKE dashboard requires a "clusterName" in the dashboardParameters option', + ); + }); + it('should return an url on the cluster when there is a namespace only', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + namespace: 'bar', + }, + }, + kind: 'foo', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/clusters/details/us-east1-c/cluster-1/details?project=foobar-333614', + ); + }); + it('should return an url on the cluster when the kind is not recognizeed', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'UnknownKind', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/clusters/details/us-east1-c/cluster-1/details?project=foobar-333614', + ); + }); + it('should return an url on the deployment', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/deployment/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614', + ); + }); + + it('should return an url on the service', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/service/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614', + ); + }); + it('should return an url on the pod', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Pod', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/pod/us-east1-c/cluster-1/bar/foobar/details?project=foobar-333614', + ); + }); + it('should return an url on the ingress', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Ingress', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/ingress/us-east1-c/cluster-1/bar/foobar/details?project=foobar-333614', + ); + }); + it('should return an url on the deployment for a hpa', () => { + const url = gkeFormatter({ + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'HorizontalPodAutoscaler', + }); + expect(url.href).toBe( + 'https://console.cloud.google.com/kubernetes/deployment/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614', + ); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts index 30e7fa3123..bbd60bd568 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -15,6 +15,60 @@ */ import { ClusterLinksFormatterOptions } from '../../../types/types'; -export function gkeFormatter(_options: ClusterLinksFormatterOptions): URL { - throw new Error('GKE formatter is not yet implemented. Please, contribute!'); +const kindMappings: Record = { + deployment: 'deployment', + pod: 'pod', + ingress: 'ingress', + service: 'service', + horizontalpodautoscaler: 'deployment', +}; + +export function gkeFormatter(options: ClusterLinksFormatterOptions): URL { + if (!options.dashboardParameters) { + throw new Error('GKE dashboard requires a dashboardParameters option'); + } + const args = options.dashboardParameters; + if (!args.projectId) { + throw new Error( + 'GKE dashboard requires a "projectId" in the dashboardParameters option', + ); + } + if (!args.region) { + throw new Error( + 'GKE dashboard requires a "region" in the dashboardParameters option', + ); + } + if (!args.clusterName) { + throw new Error( + 'GKE dashboard requires a "clusterName" in the dashboardParameters option', + ); + } + const basePath = new URL('https://console.cloud.google.com/'); + const region = encodeURIComponent(args.region); + const clusterName = encodeURIComponent(args.clusterName); + const name = encodeURIComponent(options.object.metadata?.name ?? ''); + const namespace = encodeURIComponent( + options.object.metadata?.namespace ?? '', + ); + const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; + if (!basePath.pathname.endsWith('/')) { + // a dashboard url with a path should end with a slash otherwise + // the new combined URL will replace the last segment with the appended path! + // https://foobar.com/abc/def + k8s/cluster/projects/test --> https://foobar.com/abc/k8s/cluster/projects/test + // https://foobar.com/abc/def/ + k8s/cluster/projects/test --> https://foobar.com/abc/def/k8s/cluster/projects/test + basePath.pathname += '/'; + } + let path = ''; + if (namespace && name && validKind) { + const kindsWithDetails = ['ingress', 'pod']; + const landingPage = kindsWithDetails.includes(validKind) + ? 'details' + : 'overview'; + path = `kubernetes/${validKind}/${region}/${clusterName}/${namespace}/${name}/${landingPage}`; + } else { + path = `kubernetes/clusters/details/${region}/${clusterName}/details`; + } + const result = new URL(path, basePath); + result.searchParams.set('project', args.projectId); + return result; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts index f0b85cad49..2380d3e2d5 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -16,6 +16,19 @@ import { openshiftFormatter } from './openshift'; describe('clusterLinks - OpenShift formatter', () => { + it('should provide a dashboardUrl in the options', () => { + expect(() => + openshiftFormatter({ + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('OpenShift dashboard requires a dashboardUrl option'); + }); it('should return an url on the workloads when there is a namespace only', () => { const url = openshiftFormatter({ dashboardUrl: new URL('https://k8s.foo.com'), diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts index 6c20cd4720..c5b7313061 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts @@ -24,6 +24,9 @@ const kindMappings: Record = { }; export function openshiftFormatter(options: ClusterLinksFormatterOptions): URL { + if (!options.dashboardUrl) { + throw new Error('OpenShift dashboard requires a dashboardUrl option'); + } const basePath = new URL(options.dashboardUrl.href); const name = encodeURIComponent(options.object.metadata?.name ?? ''); const namespace = encodeURIComponent( diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts index cb200387a3..45983deab0 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts @@ -16,6 +16,19 @@ import { rancherFormatter } from './rancher'; describe('clusterLinks - rancher formatter', () => { + it('should provide a dashboardUrl in the options', () => { + expect(() => + rancherFormatter({ + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('Rancher dashboard requires a dashboardUrl option'); + }); it('should return a url on the workloads when there is a namespace only', () => { const url = rancherFormatter({ dashboardUrl: new URL('https://k8s.foo.com'), diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts index 1ace39ec59..6c72730181 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts @@ -23,6 +23,9 @@ const kindMappings: Record = { }; export function rancherFormatter(options: ClusterLinksFormatterOptions): URL { + if (!options.dashboardUrl) { + throw new Error('Rancher dashboard requires a dashboardUrl option'); + } const basePath = new URL(options.dashboardUrl.href); const name = encodeURIComponent(options.object.metadata?.name ?? ''); const namespace = encodeURIComponent( diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts index 0b3c21a627..c0ee3f534d 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts @@ -23,6 +23,19 @@ function formatUrl(url: URL) { } describe('clusterLinks - standard formatter', () => { + it('should provide a dashboardUrl in the options', () => { + expect(() => + standardFormatter({ + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('standard dashboard requires a dashboardUrl option'); + }); it('should return an url on the workloads when there is a namespace only', () => { const url = standardFormatter({ dashboardUrl: new URL('https://k8s.foo.com'), @@ -67,6 +80,21 @@ describe('clusterLinks - standard formatter', () => { 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', ); }); + it('should return an url on the pod', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Pod', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/pod/bar/foobar?namespace=bar', + ); + }); it('should return an url on the deployment with a prefix 1', () => { const url = standardFormatter({ dashboardUrl: new URL('https://k8s.foo.com/some/prefix'), diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts index e28c9fae2b..a7428a8722 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts @@ -17,12 +17,16 @@ import { ClusterLinksFormatterOptions } from '../../../types/types'; const kindMappings: Record = { deployment: 'deployment', + pod: 'pod', ingress: 'ingress', service: 'service', horizontalpodautoscaler: 'deployment', }; export function standardFormatter(options: ClusterLinksFormatterOptions) { + if (!options.dashboardUrl) { + throw new Error('standard dashboard requires a dashboardUrl option'); + } const result = new URL(options.dashboardUrl.href); const name = encodeURIComponent(options.object.metadata?.name ?? ''); const namespace = encodeURIComponent( From acce31fa2a6700865fc4557f45bdaa2f17f44a90 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Mon, 27 Dec 2021 01:10:59 -0500 Subject: [PATCH 2/5] fix documentation warnings Signed-off-by: Morgan Martinet --- docs/features/kubernetes/configuration.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 0411922a98..f06a72ca4e 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -117,8 +117,8 @@ Note that you should specify the app used for the dashboard using the resources, otherwise it will assume that you're running the standard one. Note also that this attribute is optional for some kinds of dashboards, such as -GKE, which requires additional informations specified in the -`dashboardParameters` option. +GKE, which requires additional parameters specified in the `dashboardParameters` +option. ##### `clusters.\*.dashboardApp` (optional) @@ -154,8 +154,8 @@ for real examples. Specifies additional information for the selected `dashboardApp` formatter. -Note that, eventhough `dashboardParameters` is optional for some formatters, it -is mandatory for others, such as GKE. +Note that, even though `dashboardParameters` is optional, it might be mandatory +for some dashboards, such as GKE. ###### required parameters for GKE From 4908fcf1775fdfb19f62c373d8c4d24b21e230d2 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Mon, 27 Dec 2021 10:53:33 -0500 Subject: [PATCH 3/5] force CI to execute after internal failure Signed-off-by: Morgan Martinet From 669e73b87bdcedc1e712b129f556f0e8baba6ca9 Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Mon, 27 Dec 2021 22:40:46 -0500 Subject: [PATCH 4/5] change dashboardParameters signature after code review Signed-off-by: Morgan Martinet --- docs/features/kubernetes/configuration.md | 1 + plugins/kubernetes-backend/api-report.md | 3 +- .../ConfigClusterLocator.test.ts | 72 +++++++++++++++++++ .../cluster-locator/ConfigClusterLocator.ts | 5 +- plugins/kubernetes-backend/src/types/types.ts | 3 +- plugins/kubernetes-common/api-report.md | 3 +- plugins/kubernetes-common/src/types.ts | 3 +- plugins/kubernetes/api-report.md | 1 + plugins/kubernetes/src/types/types.ts | 3 +- .../clusterLinks/formatClusterLink.test.ts | 42 +++++++++++ .../utils/clusterLinks/formatClusterLink.ts | 3 +- .../utils/clusterLinks/formatters/gke.test.ts | 6 +- .../src/utils/clusterLinks/formatters/gke.ts | 12 ++-- 13 files changed, 139 insertions(+), 18 deletions(-) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index f06a72ca4e..492cd86022 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -39,6 +39,7 @@ kubernetes: region: 'europe-west1' skipTLSVerify: true skipMetricsLookup: true + exposeDashboard: true ``` ### `serviceLocatorMethod` diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 63d0dfceb9..9b181c8514 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -6,6 +6,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; +import type { JsonObject } from '@backstage/types'; import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import type { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; @@ -31,7 +32,7 @@ export interface ClusterDetails { // (undocumented) caData?: string | undefined; dashboardApp?: string; - dashboardParameters?: any; + dashboardParameters?: JsonObject; dashboardUrl?: string; name: string; // (undocumented) diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index f5d6b56893..6ece380f28 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -176,4 +176,76 @@ describe('ConfigClusterLocator', () => { }, ]); }); + + it('one cluster with dashboardParameters', async () => { + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + dashboardApp: 'gke', + dashboardParameters: { + projectId: 'some-project', + region: 'some-region', + clusterName: 'cluster1', + }, + }, + ], + }); + + const sut = ConfigClusterLocator.fromConfig(config); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: undefined, + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + skipMetricsLookup: false, + skipTLSVerify: false, + caData: undefined, + dashboardApp: 'gke', + dashboardParameters: { + projectId: 'some-project', + region: 'some-region', + clusterName: 'cluster1', + }, + }, + ]); + }); + + it('one cluster with dashboardUrl', async () => { + const config: Config = new ConfigReader({ + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + dashboardApp: 'standard', + dashboardUrl: 'http://someurl', + }, + ], + }); + + const sut = ConfigClusterLocator.fromConfig(config); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: undefined, + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + skipMetricsLookup: false, + skipTLSVerify: false, + caData: undefined, + dashboardApp: 'standard', + dashboardUrl: 'http://someurl', + }, + ]); + }); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 50ddb906a9..d8436b6c24 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -47,9 +47,8 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { if (dashboardApp) { clusterDetails.dashboardApp = dashboardApp; } - const dashboardParameters = c.getOptionalString('dashboardParameters'); - if (dashboardParameters) { - clusterDetails.dashboardParameters = dashboardParameters; + if (c.has('dashboardParameters')) { + clusterDetails.dashboardParameters = c.get('dashboardParameters'); } switch (authProvider) { diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 6be205e4e8..4f25805063 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -15,6 +15,7 @@ */ import { Logger } from 'winston'; +import type { JsonObject } from '@backstage/types'; import type { FetchResponse, KubernetesFetchError, @@ -135,7 +136,7 @@ export interface ClusterDetails { * This is used by the GKE formatter which requires the project, region and cluster name. * @see dashboardApp */ - dashboardParameters?: any; + dashboardParameters?: JsonObject; } export interface GKEClusterDetails extends ClusterDetails {} diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index c6ef21592b..15019d71c3 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -4,6 +4,7 @@ ```ts import { Entity } from '@backstage/catalog-model'; +import type { JsonObject } from '@backstage/types'; import { V1ConfigMap } from '@kubernetes/client-node'; import { V1CronJob } from '@kubernetes/client-node'; import { V1Deployment } from '@kubernetes/client-node'; @@ -62,7 +63,7 @@ export interface ClientPodStatus { // @public (undocumented) export interface ClusterAttributes { dashboardApp?: string; - dashboardParameters?: any; + dashboardParameters?: JsonObject; dashboardUrl?: string; name: string; } diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index ff40ee5119..afda9b5299 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { JsonObject } from '@backstage/types'; import { V1ConfigMap, V1CronJob, @@ -69,7 +70,7 @@ export interface ClusterAttributes { * Specifies specific parameters used by some dashboard URL formatters. * This is used by the GKE formatter which requires the project, region and cluster name. */ - dashboardParameters?: any; + dashboardParameters?: JsonObject; } export interface ClusterObjects { diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 33ac98fb76..8dc0114738 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -10,6 +10,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { IdentityApi } from '@backstage/core-plugin-api'; +import type { JsonObject } from '@backstage/types'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OAuthApi } from '@backstage/core-plugin-api'; import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 92e06e2e04..1bdfa181d9 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { JsonObject } from '@backstage/types'; import { V1Deployment, V1Pod, @@ -44,7 +45,7 @@ export interface GroupedResponses extends DeploymentResources { export interface ClusterLinksFormatterOptions { dashboardUrl?: URL; - dashboardParameters?: any; + dashboardParameters?: JsonObject; object: any; kind: string; } diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts index 31e07a0f14..b767d496a2 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts @@ -114,5 +114,47 @@ describe('clusterLinks', () => { ); }); }); + describe('GKE app', () => { + it('should return an url on the deployment', () => { + const url = formatClusterLink({ + dashboardApp: 'gke', + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://console.cloud.google.com/kubernetes/deployment/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614', + ); + }); + it('should return an url on the service', () => { + const url = formatClusterLink({ + dashboardApp: 'gke', + dashboardParameters: { + projectId: 'foobar-333614', + region: 'us-east1-c', + clusterName: 'cluster-1', + }, + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url).toBe( + 'https://console.cloud.google.com/kubernetes/service/us-east1-c/cluster-1/bar/foobar/overview?project=foobar-333614', + ); + }); + }); }); }); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts index 90fd616737..6a998bbce2 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -14,12 +14,13 @@ * limitations under the License. */ +import type { JsonObject } from '@backstage/types'; import { defaultFormatterName, clusterLinksFormatters } from './formatters'; export type FormatClusterLinkOptions = { dashboardUrl?: string; dashboardApp?: string; - dashboardParameters?: any; + dashboardParameters?: JsonObject; object: any; kind: string; }; diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts index 7560c9e353..20541f216d 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -45,7 +45,7 @@ describe('clusterLinks - GKE formatter', () => { kind: 'Deployment', }), ).toThrowError( - 'GKE dashboard requires a "projectId" in the dashboardParameters option', + 'GKE dashboard requires a "projectId" of type string in the dashboardParameters option', ); }); it('should provide a region in the dashboardParameters options', () => { @@ -64,7 +64,7 @@ describe('clusterLinks - GKE formatter', () => { kind: 'Deployment', }), ).toThrowError( - 'GKE dashboard requires a "region" in the dashboardParameters option', + 'GKE dashboard requires a "region" of type string in the dashboardParameters option', ); }); it('should provide a clusterName in the dashboardParameters options', () => { @@ -83,7 +83,7 @@ describe('clusterLinks - GKE formatter', () => { kind: 'Deployment', }), ).toThrowError( - 'GKE dashboard requires a "clusterName" in the dashboardParameters option', + 'GKE dashboard requires a "clusterName" of type string in the dashboardParameters option', ); }); it('should return an url on the cluster when there is a namespace only', () => { diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts index bbd60bd568..1dbeb2872e 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -28,19 +28,19 @@ export function gkeFormatter(options: ClusterLinksFormatterOptions): URL { throw new Error('GKE dashboard requires a dashboardParameters option'); } const args = options.dashboardParameters; - if (!args.projectId) { + if (typeof args.projectId !== 'string') { throw new Error( - 'GKE dashboard requires a "projectId" in the dashboardParameters option', + 'GKE dashboard requires a "projectId" of type string in the dashboardParameters option', ); } - if (!args.region) { + if (typeof args.region !== 'string') { throw new Error( - 'GKE dashboard requires a "region" in the dashboardParameters option', + 'GKE dashboard requires a "region" of type string in the dashboardParameters option', ); } - if (!args.clusterName) { + if (typeof args.clusterName !== 'string') { throw new Error( - 'GKE dashboard requires a "clusterName" in the dashboardParameters option', + 'GKE dashboard requires a "clusterName" of type string in the dashboardParameters option', ); } const basePath = new URL('https://console.cloud.google.com/'); From eb2a9ff02645931c714b34773a6e05ba16a609eb Mon Sep 17 00:00:00 2001 From: Morgan Martinet Date: Tue, 28 Dec 2021 10:27:21 -0500 Subject: [PATCH 5/5] remove unnecessary code after code review Signed-off-by: Morgan Martinet --- .../kubernetes/src/utils/clusterLinks/formatters/gke.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts index 1dbeb2872e..957032e6e2 100644 --- a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -51,13 +51,6 @@ export function gkeFormatter(options: ClusterLinksFormatterOptions): URL { options.object.metadata?.namespace ?? '', ); const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; - if (!basePath.pathname.endsWith('/')) { - // a dashboard url with a path should end with a slash otherwise - // the new combined URL will replace the last segment with the appended path! - // https://foobar.com/abc/def + k8s/cluster/projects/test --> https://foobar.com/abc/k8s/cluster/projects/test - // https://foobar.com/abc/def/ + k8s/cluster/projects/test --> https://foobar.com/abc/def/k8s/cluster/projects/test - basePath.pathname += '/'; - } let path = ''; if (namespace && name && validKind) { const kindsWithDetails = ['ingress', 'pod'];