diff --git a/.changeset/chilly-trams-cheer.md b/.changeset/chilly-trams-cheer.md new file mode 100644 index 0000000000..0e14423ef8 --- /dev/null +++ b/.changeset/chilly-trams-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-react': patch +--- + +Add headlamp formatter diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index c323d5118a..a22a7d94d9 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -355,8 +355,7 @@ Specifies the app that provides the Kubernetes dashboard. This will be used for formatting links to kubernetes objects inside the dashboard. -The supported dashboards are: `standard`, `rancher`, `openshift`, `gke`, `aks`, -`eks`. However, not all of them are implemented yet, so please contribute! +The supported dashboards are: `aks`, `eks`, `gke`, `headlamp`, `openshift`, `rancher`, `standard`. However, not all of them are implemented yet, so please contribute! Note that it will default to the regular dashboard provided by the Kubernetes project (`standard`), that can run in any Kubernetes cluster. @@ -448,6 +447,41 @@ cluster locator method can be configured in this way. Configures which [custom resources][3] to look for when returning an entity's Kubernetes resources belonging to the cluster. Same specification as [`customResources`](#customresources-optional) +#### `headlamp` + +When using `headlamp` as your dashboard, you have two configuration options: + +1. External Headlamp instance: + +```yaml +kubernetes: + clusterLocatorMethods: + - type: 'config' + clusters: + - url: http://127.0.0.1:9999 + name: my-cluster + dashboardUrl: http://headlamp.example.com # Your Headlamp instance URL + dashboardApp: 'headlamp' + dashboardParameters: + clusterName: 'my-cluster' # Optional, defaults to 'default' +``` + +2. Internal Headlamp (When using the Headlamp plugin for Backstage): + +```yaml +kubernetes: + clusterLocatorMethods: + - type: 'config' + clusters: + - url: http://127.0.0.1:9999 + name: my-cluster + dashboardApp: 'headlamp' + dashboardParameters: + internal: true + headlampRoute: '/headlamp' # Optional, defaults to '/headlamp' + clusterName: 'my-cluster' # Optional, defaults to 'default' +``` + #### `gke` This cluster locator is designed to work with Kubernetes clusters running in diff --git a/plugins/kubernetes-react/report.api.md b/plugins/kubernetes-react/report.api.md index cd71b70f70..4bf48ecd92 100644 --- a/plugins/kubernetes-react/report.api.md +++ b/plugins/kubernetes-react/report.api.md @@ -283,6 +283,12 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { // @public (undocumented) export const GroupedResponsesContext: Context; +// @public (undocumented) +export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter { + // (undocumented) + formatClusterLink(options: ClusterLinksFormatterOptions): Promise; +} + // @public (undocumented) export const HorizontalPodAutoscalerDrawer: (props: { hpa: V2HorizontalPodAutoscaler; diff --git a/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts new file mode 100644 index 0000000000..533e966137 --- /dev/null +++ b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter'; +import { ClusterLinksFormatterOptions } from '../../types'; + +describe('HeadlampClusterLinksFormatter', () => { + let formatter: HeadlampClusterLinksFormatter; + + beforeEach(() => { + formatter = new HeadlampClusterLinksFormatter(); + // Mock window.location.origin + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:3000' }, + writable: true, + }); + }); + + it('formats internal dashboard link correctly', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardParameters: { + internal: true, + headlampRoute: '/headlamp', + clusterName: 'test-cluster', + }, + object: { metadata: { name: 'test-pod', namespace: 'default' } }, + kind: 'Pod', + }; + + const result = await formatter.formatClusterLink(options); + expect(result.toString()).toBe( + 'http://localhost:3000/headlamp?to=%2Fc%2Ftest-cluster%2Fpods%2Fdefault%2Ftest-pod', + ); + }); + + it('formats external dashboard link correctly', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardUrl: new URL('https://headlamp.com'), + object: { metadata: { name: 'test-deployment', namespace: 'default' } }, + kind: 'Deployment', + }; + + const result = await formatter.formatClusterLink(options); + expect(result.toString()).toBe( + 'https://headlamp.com/?to=%2Fc%2Fdefault%2Fdeployments%2Fdefault%2Ftest-deployment', + ); + }); + + it('throws error when dashboard URL is missing for external dashboard', async () => { + const options: ClusterLinksFormatterOptions = { + object: { metadata: { name: 'test-service', namespace: 'default' } }, + kind: 'Service', + }; + + await expect(formatter.formatClusterLink(options)).rejects.toThrow( + 'Dashboard URL is required or dashboardInternal must be true', + ); + }); + + it('throws error for unsupported kind', async () => { + const options: ClusterLinksFormatterOptions = { + dashboardParameters: { internal: true }, + object: { metadata: { name: 'test-unknown', namespace: 'default' } }, + kind: 'UnknownKind', + }; + + await expect(formatter.formatClusterLink(options)).rejects.toThrow( + 'Could not find path for kind: UnknownKind', + ); + }); +}); diff --git a/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts new file mode 100644 index 0000000000..c48566dd2b --- /dev/null +++ b/plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts @@ -0,0 +1,136 @@ +/* + * 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, + ClusterLinksFormatterOptions, +} from '../../types'; + +/** @public */ +export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter { + async formatClusterLink(options: ClusterLinksFormatterOptions): Promise { + const { dashboardUrl, dashboardParameters, object, kind } = options; + + if (!dashboardUrl && !dashboardParameters?.internal) { + throw new Error( + 'Dashboard URL is required or dashboardInternal must be true', + ); + } + + const clusterName = + (dashboardParameters?.clusterName as string) ?? 'default'; + const path = this.getHeadlampPath(kind, object, clusterName); + if (!path) { + throw new Error(`Could not find path for kind: ${kind}`); + } + + let baseUrl: URL; + + if (dashboardParameters?.internal) { + baseUrl = new URL(window.location.origin); + baseUrl.pathname = + (dashboardParameters.headlampRoute as string) || '/headlamp'; + } else { + if (!dashboardUrl?.href) { + throw new Error( + 'Dashboard URL is required when not using internal dashboard', + ); + } + baseUrl = new URL(dashboardUrl.href); + } + + baseUrl.searchParams.set('to', path); + return baseUrl; + } + + private readonly NAMESPACED_RESOURCES = new Set([ + 'pod', + 'deployment', + 'replicaset', + 'statefulset', + 'daemonset', + 'job', + 'cronjob', + 'service', + 'ingress', + 'configmap', + 'secret', + 'serviceaccount', + 'role', + 'rolebinding', + 'networkpolicy', + 'horizontalpodautoscaler', + 'poddisruptionbudget', + 'persistentvolumeclaim', + ]); + + private getHeadlampPath( + kind: string, + object: { + metadata?: { + name?: string; + namespace?: string; + }; + }, + clusterName: string, + ): string { + const lowercaseKind = kind.toLocaleLowerCase('en-US'); + const { name } = object.metadata ?? {}; + let { namespace } = object.metadata ?? {}; + + if (!name) { + throw new Error(`Resource name is required for kind: ${kind}`); + } + + // Add namespace validation + if (this.NAMESPACED_RESOURCES.has(lowercaseKind) && !namespace) { + throw new Error(`Namespace is required for namespaced resource: ${kind}`); + } + if (!namespace) { + namespace = 'default'; + } + + const pathMap: Record = { + namespace: `/c/${clusterName}/namespaces/${name}`, + node: `/c/${clusterName}/nodes/${name}`, + persistentvolume: `/c/${clusterName}/storage/persistentvolumes/${name}`, + persistentvolumeclaim: `/c/${clusterName}/storage/persistentvolumeclaims/${namespace}/${name}`, + pod: `/c/${clusterName}/pods/${namespace}/${name}`, + deployment: `/c/${clusterName}/deployments/${namespace}/${name}`, + replicaset: `/c/${clusterName}/replicasets/${namespace}/${name}`, + statefulset: `/c/${clusterName}/statefulsets/${namespace}/${name}`, + daemonset: `/c/${clusterName}/daemonsets/${namespace}/${name}`, + job: `/c/${clusterName}/jobs/${namespace}/${name}`, + cronjob: `/c/${clusterName}/cronjobs/${namespace}/${name}`, + service: `/c/${clusterName}/services/${namespace}/${name}`, + ingress: `/c/${clusterName}/ingresses/${namespace}/${name}`, + configmap: `/c/${clusterName}/configmaps/${namespace}/${name}`, + secret: `/c/${clusterName}/secrets/${namespace}/${name}`, + serviceaccount: `/c/${clusterName}/serviceaccounts/${namespace}/${name}`, + role: `/c/${clusterName}/roles/${namespace}/${name}`, + rolebinding: `/c/${clusterName}/rolebindings/${namespace}/${name}`, + clusterrole: `/c/${clusterName}/clusterroles/${name}`, + clusterrolebinding: `/c/${clusterName}/clusterrolebindings/${name}`, + storageclass: `/c/${clusterName}/storage/storageclasses/${name}`, + networkpolicy: `/c/${clusterName}/networkpolicies/${namespace}/${name}`, + horizontalpodautoscaler: `/c/${clusterName}/horizontalpodautoscalers/${namespace}/${name}`, + poddisruptionbudget: `/c/${clusterName}/poddisruptionbudgets/${namespace}/${name}`, + customresourcedefinition: `/c/${clusterName}/customresourcedefinitions/${name}`, + }; + + return pathMap[lowercaseKind] ?? ''; + } +} diff --git a/plugins/kubernetes-react/src/api/formatters/index.ts b/plugins/kubernetes-react/src/api/formatters/index.ts index c4bf52d181..76cff5a48f 100644 --- a/plugins/kubernetes-react/src/api/formatters/index.ts +++ b/plugins/kubernetes-react/src/api/formatters/index.ts @@ -21,6 +21,7 @@ import { GkeClusterLinksFormatter } from './GkeClusterLinksFormatter'; import { StandardClusterLinksFormatter } from './StandardClusterLinksFormatter'; import { OpenshiftClusterLinksFormatter } from './OpenshiftClusterLinksFormatter'; import { RancherClusterLinksFormatter } from './RancherClusterLinksFormatter'; +import { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; export { @@ -30,6 +31,7 @@ export { GkeClusterLinksFormatter, OpenshiftClusterLinksFormatter, RancherClusterLinksFormatter, + HeadlampClusterLinksFormatter, }; /** @public */ @@ -46,5 +48,6 @@ export function getDefaultFormatters(deps: { gke: new GkeClusterLinksFormatter(deps.googleAuthApi), openshift: new OpenshiftClusterLinksFormatter(), rancher: new RancherClusterLinksFormatter(), + headlamp: new HeadlampClusterLinksFormatter(), }; }