diff --git a/.changeset/tiny-berries-battle.md b/.changeset/tiny-berries-battle.md new file mode 100644 index 0000000000..fdcb612d61 --- /dev/null +++ b/.changeset/tiny-berries-battle.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +Provide access to the Kubernetes dashboard when viewing a specific resource diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 068a17249c..f48faa202d 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -27,6 +27,8 @@ kubernetes: authProvider: 'serviceAccount' skipTLSVerify: false serviceAccountToken: ${K8S_MINIKUBE_TOKEN} + dashboardUrl: http://127.0.0.1:64713 # url copied from running the command: minikube service kubernetes-dashboard -n kubernetes-dashboard + dashboardApp: standard - url: http://127.0.0.2:9999 name: aws-cluster-1 authProvider: 'aws' @@ -98,6 +100,41 @@ kubectl -n get secret $(kubectl -n get sa ...; +``` + +See also +https://github.com/backstage/backstage/tree/master/plugins/kubernetes/src/utils/clusterLinks/formatters +for real examples. + #### `gke` This cluster locator is designed to work with Kubernetes clusters running in diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 60e8444003..3d4c275731 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -3,10 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; import { Config } from '@backstage/config'; import express from 'express'; -import { FetchResponse } from '@backstage/plugin-kubernetes-common'; -import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; +import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; +import type { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { Logger as Logger_2 } from 'winston'; @@ -26,7 +27,8 @@ export interface AWSClusterDetails extends ClusterDetails { export interface ClusterDetails { // (undocumented) authProvider: string; - // (undocumented) + dashboardApp?: string; + dashboardUrl?: string; name: string; // (undocumented) serviceAccountToken?: string | undefined; diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index dac96e4cef..6d1506c4c7 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -66,6 +66,7 @@ describe('ConfigClusterLocator', () => { url: 'http://localhost:8080', authProvider: 'serviceAccount', skipTLSVerify: false, + dashboardUrl: 'https://k8s.foo.com', }, { name: 'cluster2', @@ -83,6 +84,7 @@ describe('ConfigClusterLocator', () => { expect(result).toStrictEqual([ { name: 'cluster1', + dashboardUrl: 'https://k8s.foo.com', serviceAccountToken: 'token', url: 'http://localhost:8080', authProvider: 'serviceAccount', diff --git a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 8352db6754..53d3a1d025 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -30,13 +30,21 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier { return new ConfigClusterLocator( config.getConfigArray('clusters').map(c => { const authProvider = c.getString('authProvider'); - const clusterDetails = { + const clusterDetails: ClusterDetails = { name: c.getString('name'), url: c.getString('url'), serviceAccountToken: c.getOptionalString('serviceAccountToken'), skipTLSVerify: c.getOptionalBoolean('skipTLSVerify') ?? false, authProvider: authProvider, }; + const dashboardUrl = c.getOptionalString('dashboardUrl'); + if (dashboardUrl) { + clusterDetails.dashboardUrl = dashboardUrl; + } + const dashboardApp = c.getOptionalString('dashboardApp'); + if (dashboardApp) { + clusterDetails.dashboardApp = dashboardApp; + } switch (authProvider) { case 'google': { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index a867efaaee..7973448467 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -211,6 +211,7 @@ describe('handleGetKubernetesObjectsForService', () => { { name: 'test-cluster', authProvider: 'serviceAccount', + dashboardUrl: 'https://k8s.foo.coom', }, { name: 'other-cluster', @@ -260,6 +261,7 @@ describe('handleGetKubernetesObjectsForService', () => { items: [ { cluster: { + dashboardUrl: 'https://k8s.foo.coom', name: 'test-cluster', }, errors: [], diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index fb1876733d..fd403f49d1 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -22,7 +22,10 @@ import { KubernetesObjectTypes, KubernetesServiceLocator, } from '../types/types'; -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + ClusterObjects, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; @@ -111,13 +114,20 @@ export class KubernetesFanOutHandler { customResources: this.customResources, }) .then(result => { - return { + const objects: ClusterObjects = { cluster: { name: clusterDetailsItem.name, }, resources: result.responses, errors: result.errors, }; + if (clusterDetailsItem.dashboardUrl) { + objects.cluster.dashboardUrl = clusterDetailsItem.dashboardUrl; + } + if (clusterDetailsItem.dashboardApp) { + objects.cluster.dashboardApp = clusterDetailsItem.dashboardApp; + } + return objects; }); }), ).then(r => ({ diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 4792ae708f..d8f7c787d9 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -86,6 +86,7 @@ export const makeRouter = ( res.json({ items: clusterDetails.map(cd => ({ name: cd.name, + dashboardUrl: cd.dashboardUrl, authProvider: cd.authProvider, })), }); diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index f4bb019108..eec1243b19 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -75,11 +75,39 @@ export interface KubernetesServiceLocator { export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http export interface ClusterDetails { + /** + * Specifies the name of the Kubernetes cluster. + */ name: string; url: string; authProvider: string; serviceAccountToken?: string | undefined; skipTLSVerify?: boolean; + /** + * Specifies the link to the Kubernetes dashboard managing this cluster. + * @remarks + * 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. + * @see dashboardApp + */ + dashboardUrl?: string; + /** + * Specifies the app that provides the Kubernetes dashboard. + * This will be used for formatting links to kubernetes objects inside the dashboard. + * @remarks + * The existing apps are: standard, rancher, openshift, gke, aks, eks + * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). + * Note that you can add your own formatter by registering it to the clusterLinksFormatters dictionary. + * @defaultValue standard + * @see dashboardUrl + * @example + * ```ts + * import { clusterLinksFormatters } from '@backstage/plugin-kubernetes'; + * clusterLinksFormatters.myDashboard = (options) => ...; + * ``` + */ + dashboardApp?: string; } export interface GKEClusterDetails extends ClusterDetails {} diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 82504d4e16..9f9669275f 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -17,14 +17,21 @@ import { V1Service } from '@kubernetes/client-node'; // @public (undocumented) export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; +// Warning: (ae-missing-release-tag) "ClusterAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ClusterAttributes { + dashboardApp?: string; + dashboardUrl?: string; + name: string; +} + // Warning: (ae-missing-release-tag) "ClusterObjects" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ClusterObjects { // (undocumented) - cluster: { - name: string; - }; + cluster: ClusterAttributes; // (undocumented) errors: KubernetesFetchError[]; // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index c94a71a184..add661a5f8 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -32,8 +32,40 @@ export interface KubernetesRequestBody { entity: Entity; } +export interface ClusterAttributes { + /** + * Specifies the name of the Kubernetes cluster. + */ + name: string; + /** + * Specifies the link to the Kubernetes dashboard managing this cluster. + * @remarks + * 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. + * @see dashboardApp + */ + dashboardUrl?: string; + /** + * Specifies the app that provides the Kubernetes dashboard. + * This will be used for formatting links to kubernetes objects inside the dashboard. + * @remarks + * The supported dashboards are: standard, rancher, openshift, gke, aks, eks + * Note that it will default to the regular dashboard provided by the Kubernetes project (standard). + * Note that you can add your own formatter by registering it to the clusterLinksFormatters dictionary. + * @defaultValue standard + * @see dashboardUrl + * @example + * ```ts + * import { clusterLinksFormatters } from '@backstage/plugin-kubernetes'; + * clusterLinksFormatters.myDashboard = (options) => ...; + * ``` + */ + dashboardApp?: string; +} + export interface ClusterObjects { - cluster: { name: string }; + cluster: ClusterAttributes; resources: FetchResponse[]; errors: KubernetesFetchError[]; } diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index e3f00c49eb..fc1127595d 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -12,6 +12,12 @@ import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OAuthApi } from '@backstage/core-plugin-api'; import { RouteRef } from '@backstage/core-plugin-api'; +// Warning: (ae-forgotten-export) The symbol "ClusterLinksFormatter" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "clusterLinksFormatters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const clusterLinksFormatters: Record; + // Warning: (ae-missing-release-tag) "EntityKubernetesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -19,6 +25,14 @@ export const EntityKubernetesContent: (_props: { entity?: Entity | undefined; }) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "FormatClusterLinkOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "formatClusterLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function formatClusterLink( + options: FormatClusterLinkOptions, +): string | undefined; + // Warning: (ae-forgotten-export) The symbol "KubernetesAuthProvidersApi" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "KubernetesAuthProviders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index c0dd6d5003..260c77ed9e 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -36,6 +36,7 @@ import { ServicesAccordions } from '../ServicesAccordions'; import { CustomResources } from '../CustomResources'; import EmptyStateImage from '../../assets/emptystate.svg'; import { + ClusterContext, GroupedResponsesContext, PodNamesWithErrorsContext, useKubernetesObjects, @@ -119,35 +120,37 @@ type ClusterProps = { const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { const groupedResponses = groupResponses(clusterObjects.resources); return ( - - - - }> - - - - - - + + + + + }> + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + ); }; diff --git a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx index bf714530ce..b4ea31dc8d 100644 --- a/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ChangeEvent, useState } from 'react'; +import React, { ChangeEvent, useContext, useState } from 'react'; import { Button, Typography, @@ -28,13 +28,20 @@ import { Grid, } from '@material-ui/core'; import Close from '@material-ui/icons/Close'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import { V1ObjectMeta } from '@kubernetes/client-node'; import { withStyles } from '@material-ui/core/styles'; import jsYaml from 'js-yaml'; import { + Button as BackstageButton, CodeSnippet, StructuredMetadataTable, + WarningPanel, } from '@backstage/core-components'; +import { ClusterContext } from '../../hooks'; +import { formatClusterLink } from '../../utils/clusterLinks'; +import { ClusterAttributes } from '@backstage/plugin-kubernetes-common'; +import { FormatClusterLinkOptions } from '../../utils/clusterLinks/formatClusterLink'; const useDrawerStyles = makeStyles((theme: Theme) => createStyles({ @@ -53,10 +60,14 @@ const useDrawerContentStyles = makeStyles((_: Theme) => flexDirection: 'row', justifyContent: 'space-between', }, + errorMessage: { + marginTop: '1em', + marginBottom: '1em', + }, options: { display: 'flex', flexDirection: 'row', - justifyContent: 'flex-end', + justifyContent: 'space-between', }, icon: { fontSize: 20, @@ -76,6 +87,27 @@ const PodDrawerButton = withStyles({ }, })(Button); +type ErrorPanelProps = { + cluster: ClusterAttributes; + errorMessage?: string; + children?: React.ReactNode; +}; + +export const ErrorPanel = ({ cluster, errorMessage }: ErrorPanelProps) => ( + + {errorMessage && ( + Errors: {errorMessage} + )} + +); + interface KubernetesDrawerable { metadata?: V1ObjectMeta; } @@ -96,6 +128,20 @@ function replaceNullsWithUndefined(someObj: any) { return JSON.parse(JSON.stringify(someObj, replacer)); } +function tryFormatClusterLink(options: FormatClusterLinkOptions) { + try { + return { + clusterLink: formatClusterLink(options), + errorMessage: '', + }; + } catch (err) { + return { + clusterLink: '', + errorMessage: err.message || err.toString(), + }; + } +} + const KubernetesDrawerContent = ({ toggleDrawer, object, @@ -105,6 +151,13 @@ const KubernetesDrawerContent = ({ const [isYaml, setIsYaml] = useState(false); const classes = useDrawerContentStyles(); + const cluster = useContext(ClusterContext); + const { clusterLink, errorMessage } = tryFormatClusterLink({ + dashboardUrl: cluster.dashboardUrl, + dashboardApp: cluster.dashboardApp, + object, + kind, + }); return ( <> @@ -135,7 +188,25 @@ const KubernetesDrawerContent = ({ + {errorMessage && ( +
+ +
+ )}
+
+ {clusterLink && ( + } + > + Open Kubernetes Dashboard + + )} +
({ + name: '', +}); diff --git a/plugins/kubernetes/src/hooks/index.ts b/plugins/kubernetes/src/hooks/index.ts index e25903ad3a..88db2d8197 100644 --- a/plugins/kubernetes/src/hooks/index.ts +++ b/plugins/kubernetes/src/hooks/index.ts @@ -17,3 +17,4 @@ export * from './useKubernetesObjects'; export * from './PodNamesWithErrors'; export * from './GroupedResponses'; +export * from './Cluster'; diff --git a/plugins/kubernetes/src/index.ts b/plugins/kubernetes/src/index.ts index 6751594b30..63f68f0af1 100644 --- a/plugins/kubernetes/src/index.ts +++ b/plugins/kubernetes/src/index.ts @@ -27,3 +27,4 @@ export { } from './plugin'; export { Router } from './Router'; export * from './kubernetes-auth-provider'; +export * from './utils/clusterLinks'; diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 0817c90f97..05337086be 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -37,3 +37,13 @@ export interface GroupedResponses extends DeploymentResources { ingresses: ExtensionsV1beta1Ingress[]; customResources: any[]; } + +export interface ClusterLinksFormatterOptions { + dashboardUrl: URL; + object: any; + kind: string; +} + +export type ClusterLinksFormatter = ( + options: ClusterLinksFormatterOptions, +) => URL; diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts new file mode 100644 index 0000000000..afc53d9509 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { formatClusterLink } from './formatClusterLink'; + +describe('clusterLinks', () => { + describe('formatClusterLink', () => { + it('should not return an url when there is no dashboard url', () => { + const url = formatClusterLink({ object: {}, kind: 'foo' }); + expect(url).toBeUndefined(); + }); + it('should return an url even when there is no object', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + object: undefined, + kind: 'foo', + }); + expect(url).toBe('https://k8s.foo.com'); + }); + it('should throw when the app is not recognized', () => { + expect(() => + formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com', + dashboardApp: 'unknownapp', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + "Could not find Kubernetes dashboard app named 'unknownapp'", + ); + }); + + describe('default app', () => { + it('should return an url on the deployment', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment properly url encoded', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + object: { + metadata: { + name: 'foobar', + namespace: 'bar bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar%20bar/foobar?namespace=bar+bar', + ); + }); + }); + + describe('standard app', () => { + it('should return an url on the deployment', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + dashboardApp: 'standard', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = formatClusterLink({ + dashboardUrl: 'https://k8s.foo.com/', + dashboardApp: 'standard', + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(url).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + }); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts new file mode 100644 index 0000000000..83e970248b --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatClusterLink.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { defaultFormatterName, clusterLinksFormatters } from './formatters'; + +export type FormatClusterLinkOptions = { + dashboardUrl?: string; + dashboardApp?: string; + object: any; + kind: string; +}; + +export function formatClusterLink(options: FormatClusterLinkOptions) { + if (!options.dashboardUrl) { + return undefined; + } + if (!options.object) { + return options.dashboardUrl; + } + const app = options.dashboardApp || defaultFormatterName; + const formatter = clusterLinksFormatters[app]; + if (!formatter) { + throw new Error(`Could not find Kubernetes dashboard app named '${app}'`); + } + const url = formatter({ + dashboardUrl: new URL(options.dashboardUrl), + object: options.object, + kind: options.kind, + }); + // Note that we can't rely on 'url.href' since it will put the search before the hash + // and this won't be properly recognized by SPAs such as Angular in the standard dashboard. + // Note also that pathname, hash and search will be properly url encoded. + return `${url.origin}${url.pathname}${url.hash}${url.search}`; +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts new file mode 100644 index 0000000000..320c06e54c --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { aksFormatter } from './aks'; + +describe('clusterLinks - AKS formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + aksFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('AKS formatter is not yet implemented. Please, contribute!'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts new file mode 100644 index 0000000000..d6f39ab72c --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/aks.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function aksFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('AKS formatter is not yet implemented. Please, contribute!'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts new file mode 100644 index 0000000000..5998bfde5e --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { eksFormatter } from './eks'; + +describe('clusterLinks - EKS formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + eksFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('EKS formatter is not yet implemented. Please, contribute!'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts new file mode 100644 index 0000000000..975c39797c --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/eks.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function eksFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('EKS formatter is not yet implemented. Please, contribute!'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts new file mode 100644 index 0000000000..e566404daf --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { gkeFormatter } from './gke'; + +describe('clusterLinks - GKE formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + gkeFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError('GKE formatter is not yet implemented. Please, contribute!'); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts new file mode 100644 index 0000000000..30e7fa3123 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/gke.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function gkeFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error('GKE formatter is not yet implemented. Please, contribute!'); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts new file mode 100644 index 0000000000..a2dea97555 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatter } from '../../../types/types'; +import { standardFormatter } from './standard'; +import { rancherFormatter } from './rancher'; +import { openshiftFormatter } from './openshift'; +import { aksFormatter } from './aks'; +import { eksFormatter } from './eks'; +import { gkeFormatter } from './gke'; + +export const clusterLinksFormatters: Record = { + standard: standardFormatter, + rancher: rancherFormatter, + openshift: openshiftFormatter, + aks: aksFormatter, + eks: eksFormatter, + gke: gkeFormatter, +}; +export const defaultFormatterName = 'standard'; diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts new file mode 100644 index 0000000000..df2529a8ac --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { openshiftFormatter } from './openshift'; + +describe('clusterLinks - OpenShift formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + openshiftFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'OpenShift formatter is not yet implemented. Please, contribute!', + ); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts new file mode 100644 index 0000000000..bacb747ebb --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/openshift.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function openshiftFormatter( + _options: ClusterLinksFormatterOptions, +): URL { + throw new Error( + 'OpenShift formatter is not yet implemented. Please, contribute!', + ); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts new file mode 100644 index 0000000000..754647d5e9 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { rancherFormatter } from './rancher'; + +describe('clusterLinks - Rancher formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + expect(() => + rancherFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }), + ).toThrowError( + 'Rancher formatter is not yet implemented. Please, contribute!', + ); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts new file mode 100644 index 0000000000..491ca032a9 --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/rancher.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +export function rancherFormatter(_options: ClusterLinksFormatterOptions): URL { + throw new Error( + 'Rancher formatter is not yet implemented. Please, contribute!', + ); +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts new file mode 100644 index 0000000000..76bbf5643d --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { standardFormatter } from './standard'; + +function formatUrl(url: URL) { + // Note that we can't rely on 'url.href' since it will put the search before the hash + // and this won't be properly recognized by SPAs such as Angular in the standard dashboard. + // Note also that pathname, hash and search will be properly url encoded. + return `${url.origin}${url.pathname}${url.hash}${url.search}`; +} + +describe('clusterLinks - standard formatter', () => { + it('should return an url on the workloads when there is a namespace only', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + namespace: 'bar', + }, + }, + kind: 'foo', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/workloads?namespace=bar', + ); + }); + it('should return an url on the workloads when the kind is not recognizeed', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'UnknownKind', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/workloads?namespace=bar', + ); + }); + it('should return an url on the deployment', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Deployment', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the service', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Service', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/service/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the ingress', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'Ingress', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/ingress/bar/foobar?namespace=bar', + ); + }); + it('should return an url on the deployment for a hpa', () => { + const url = standardFormatter({ + dashboardUrl: new URL('https://k8s.foo.com/'), + object: { + metadata: { + name: 'foobar', + namespace: 'bar', + }, + }, + kind: 'HorizontalPodAutoscaler', + }); + expect(formatUrl(url)).toBe( + 'https://k8s.foo.com/#/deployment/bar/foobar?namespace=bar', + ); + }); +}); diff --git a/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts new file mode 100644 index 0000000000..fb26cf220d --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/formatters/standard.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ClusterLinksFormatterOptions } from '../../../types/types'; + +const kindMappings: Record = { + deployment: 'deployment', + ingress: 'ingress', + service: 'service', + horizontalpodautoscaler: 'deployment', +}; + +export function standardFormatter(options: ClusterLinksFormatterOptions) { + const result = new URL(options.dashboardUrl.href); + const name = options.object.metadata?.name; + const namespace = options.object.metadata?.namespace; + const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')]; + if (namespace) { + result.searchParams.set('namespace', namespace); + } + if (validKind && name && namespace) { + result.hash = `/${validKind}/${namespace}/${name}`; + } else if (namespace) { + result.hash = '/workloads'; + } + return result; +} diff --git a/plugins/kubernetes/src/utils/clusterLinks/index.ts b/plugins/kubernetes/src/utils/clusterLinks/index.ts new file mode 100644 index 0000000000..0a2a32cdbf --- /dev/null +++ b/plugins/kubernetes/src/utils/clusterLinks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +export { formatClusterLink } from './formatClusterLink'; +export { clusterLinksFormatters } from './formatters'; diff --git a/plugins/kubernetes/src/utils/index.ts b/plugins/kubernetes/src/utils/index.ts new file mode 100644 index 0000000000..d47afaf668 --- /dev/null +++ b/plugins/kubernetes/src/utils/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ +export * from './clusterLinks';