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(