From 18e84c98224f46668ca3e082e39d06bc0dcd4ce5 Mon Sep 17 00:00:00 2001 From: Fabio Valente <8777512+fabiojvalente@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:18:45 +0000 Subject: [PATCH 01/12] fix(MyGroupsSidebarItem): Return spec.profile field on getEntities Signed-off-by: Fabio Valente <8777512+fabiojvalente@users.noreply.github.com> --- .changeset/shaggy-stingrays-check.md | 5 +++++ .../MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx | 4 ++-- .../components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/shaggy-stingrays-check.md diff --git a/.changeset/shaggy-stingrays-check.md b/.changeset/shaggy-stingrays-check.md new file mode 100644 index 0000000000..74e0013224 --- /dev/null +++ b/.changeset/shaggy-stingrays-check.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Fixed missing spec.profile field on MyGroupsSidebarItem.tsx so the group spec.profile.displayName is shown on the sidebar" diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx index c870d58cc1..33a436b65c 100644 --- a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx @@ -256,7 +256,7 @@ describe('MyGroupsSidebarItem Test', () => { 'relations.hasMember': 'user:default/guest', }, ], - fields: ['metadata', 'kind'], + fields: ['metadata', 'kind', 'spec.profile'], }); }); }); @@ -301,7 +301,7 @@ describe('MyGroupsSidebarItem Test', () => { 'spec.type': 'team', }, ], - fields: ['metadata', 'kind'], + fields: ['metadata', 'kind', 'spec.profile'], }); }); }); diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx index 8292455115..21d3608f93 100644 --- a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx @@ -69,7 +69,7 @@ export const MyGroupsSidebarItem = (props: { ...(filter ?? {}), }, ], - fields: ['metadata', 'kind'], + fields: ['metadata', 'kind', 'spec.profile'], }); return response.items; }, []); From 14916902b991c206815c23d79fb148ebc07ea78f Mon Sep 17 00:00:00 2001 From: Fabio Valente <8777512+fabiojvalente@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:06:51 +0000 Subject: [PATCH 02/12] fix(MyGroupsSidebarItem): Filter fields on test to mock api behaviour Signed-off-by: Fabio Valente <8777512+fabiojvalente@users.noreply.github.com> --- .../MyGroupsSidebarItem.test.tsx | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx index 33a436b65c..a1e4d71911 100644 --- a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx @@ -33,6 +33,7 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; import userEvent from '@testing-library/user-event'; import { screen } from '@testing-library/react'; +import { GetEntitiesRequest } from '@backstage/catalog-client'; describe('MyGroupsSidebarItem Test', () => { describe('For guests or users with no groups', () => { @@ -131,8 +132,9 @@ describe('MyGroupsSidebarItem Test', () => { ownershipEntityRefs: ['user:default/nigel.manning'], }); const catalogApi = catalogApiMock.mock({ - getEntities: async () => ({ - items: [ + getEntities: async (request: GetEntitiesRequest = {}) => { + const { fields } = request; + const fullItems = [ { apiVersion: 'backstage.io/v1alpha1', kind: 'Group', @@ -173,8 +175,28 @@ describe('MyGroupsSidebarItem Test', () => { }, }, }, - ] as Entity[], - }), + ] as Entity[]; + + // Filter requested fields + const filteredItems = fullItems.map(item => { + const filtered: Record = { apiVersion: item.apiVersion }; + (fields || []).forEach((field: string) => { + if (field.includes('.')) { + // Handle nested fields like 'spec.profile' + const [parent, child] = field.split('.'); + if (!filtered[parent]) filtered[parent] = {}; + filtered[parent][child] = (item as Record)[parent]?.[ + child + ]; + } else { + filtered[field] = item[field as keyof Entity]; + } + }); + return filtered as Entity; + }); + + return { items: filteredItems }; + }, }); await renderInTestApp( From 195652d06db09eefa63638d0bac848eb762b54ca Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 28 Oct 2024 19:14:03 +0530 Subject: [PATCH 03/12] feat(kubernetes): add headlamp dashboard formatter this patch adds formatter for Headlamp to the kubernetes-react plugin. Signed-off-by: yolossn --- plugins/kubernetes-react/report.api.md | 6 + .../HeadlampClusterLinksFormatter.test.ts | 83 +++++++++++ .../HeadlampClusterLinksFormatter.ts | 136 ++++++++++++++++++ .../src/api/formatters/index.ts | 3 + 4 files changed, 228 insertions(+) create mode 100644 plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.test.ts create mode 100644 plugins/kubernetes-react/src/api/formatters/HeadlampClusterLinksFormatter.ts diff --git a/plugins/kubernetes-react/report.api.md b/plugins/kubernetes-react/report.api.md index 3097d6ad22..8260b61a94 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(), }; } From bb8453417c270d85f8ea79acce849764864fab53 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 14 Apr 2025 17:04:17 +0200 Subject: [PATCH 04/12] fix safari sidebar overflow Signed-off-by: JPeer264 --- .changeset/real-sheep-chew.md | 5 +++++ .../core-components/src/layout/Sidebar/Bar.tsx | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .changeset/real-sheep-chew.md diff --git a/.changeset/real-sheep-chew.md b/.changeset/real-sheep-chew.md new file mode 100644 index 0000000000..7ff56e5fc1 --- /dev/null +++ b/.changeset/real-sheep-chew.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': minor +--- + +Fix the hidden sidebar's submenu when the sidebar is scrollable diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index a73c0375cd..00fe6f96ae 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -41,15 +41,21 @@ import { coreComponentsTranslationRef } from '../../translation'; export type SidebarClassKey = 'drawer' | 'drawerOpen'; const useStyles = makeStyles( theme => ({ - drawer: { - display: 'flex', - flexFlow: 'column nowrap', - alignItems: 'flex-start', - position: 'fixed', + root: { left: 0, top: 0, bottom: 0, zIndex: theme.zIndex.appBar, + position: 'fixed', + }, + drawer: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'flex-start', + left: 0, + top: 0, + bottom: 0, + position: 'absolute', background: theme.palette.navigation.background, overflowX: 'hidden', msOverflowStyle: 'none', From e47d3cde04031885706bfe919f477c9a40b96c58 Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 14 Apr 2025 02:10:11 +0530 Subject: [PATCH 05/12] docs: Add headlamp dashboard configuration this patch updates the docs to include the headlamp dashbaord configuration Signed-off-by: yolossn --- docs/features/kubernetes/configuration.md | 38 +++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) 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 From 599c89ec43b8888751255c16ffb8f449ba051776 Mon Sep 17 00:00:00 2001 From: yolossn Date: Mon, 14 Apr 2025 02:14:43 +0530 Subject: [PATCH 06/12] changeset: added changesets for headlamp formatter Signed-off-by: yolossn --- .changeset/chilly-trams-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilly-trams-cheer.md 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 From 43557b767a4b6d478dd43e104d970f0732bf184e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 13:57:38 +0000 Subject: [PATCH 07/12] chore(deps): update github/codeql-action action to v3.28.16 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- .github/workflows/sync_snyk-monitor.yml | 2 +- .github/workflows/verify_codeql.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4865bee198..e16a481b4f 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 + uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 with: sarif_file: results.sarif diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 42fac516a2..5f0b44d8c6 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -58,6 +58,6 @@ jobs: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} NODE_OPTIONS: --max-old-space-size=7168 - name: Upload Snyk report - uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 + uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 with: sarif_file: snyk.sarif diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index b2555b64b0..5a1fb93d92 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -55,7 +55,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 + uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -66,7 +66,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 + uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -80,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 + uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 From 46ec6d7d9006d804204b16083c07640102f3d39b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 19:18:08 +0000 Subject: [PATCH 08/12] chore(deps): update dependency glob to v11.0.2 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 28f24a4177..fac71e8789 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31166,8 +31166,8 @@ __metadata: linkType: hard "glob@npm:^11.0.1": - version: 11.0.1 - resolution: "glob@npm:11.0.1" + version: 11.0.2 + resolution: "glob@npm:11.0.2" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^4.0.1" @@ -31177,7 +31177,7 @@ __metadata: path-scurry: "npm:^2.0.0" bin: glob: dist/esm/bin.mjs - checksum: 10/57b12a05cc25f1c38f3b24cf6ea7a8bacef11e782c4b9a8c5b0bef3e6c5bcb8c4548cb31eb4115592e0490a024c1bde7359c470565608dd061d3b21179740457 + checksum: 10/53501530240150fdceb9ace47ab856acd1e0d598f8101b0760b665fc11dae2160d366563b89232ae4f5df7ddba8f7c92294719268fe932bd3a32d16cc58c3d02 languageName: node linkType: hard From 76638c12f47b0a18fe5b5f764217441efd452586 Mon Sep 17 00:00:00 2001 From: Jackson Chen Date: Wed, 23 Apr 2025 14:54:25 -0400 Subject: [PATCH 09/12] notifications: use user email if slack annotation is missing Signed-off-by: Jackson Chen --- .../lib/SlackNotificationProcessor.test.ts | 175 ++++++++++++++++++ .../src/lib/SlackNotificationProcessor.ts | 50 ++++- 2 files changed, 222 insertions(+), 3 deletions(-) diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts index 4c891ed3ab..f5893ff456 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -50,6 +50,7 @@ jest.mock('@slack/web-api', () => { }, ], })), + lookupByEmail: jest.fn(), }, }; return { WebClient: jest.fn(() => mockSlack) }; @@ -81,6 +82,27 @@ const DEFAULT_ENTITIES_RESPONSE = { }, }, } as unknown as Entity, + { + kind: 'User', + metadata: { + name: 'mock-without-slack-annotation', + namespace: 'default', + annotations: {}, + }, + spec: { + profile: { + email: 'test@example.com', + }, + }, + } as unknown as Entity, + { + kind: 'Group', + metadata: { + name: 'mock-without-slack-annotation', + namespace: 'default', + annotations: {}, + }, + } as unknown as Entity, ], }; @@ -368,4 +390,157 @@ describe('SlackNotificationProcessor', () => { expect(slack.chat.postMessage).toHaveBeenCalledTimes(2); }); }); + + describe('when slack.com/bot-notify annotation is missing', () => { + it('should not send notification to a group without annotation', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { + type: 'entity', + entityRef: 'group:default/mock-without-slack-annotation', + }, + payload: { title: 'notification' }, + }); + + expect(slack.chat.postMessage).not.toHaveBeenCalled(); + }); + + it('should not send notification to a user without annotation and email', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: [DEFAULT_ENTITIES_RESPONSE.items[2]], + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock-without-slack-annotation', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { + type: 'entity', + entityRef: 'user:default/mock-without-slack-annotation', + }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.chat.postMessage).not.toHaveBeenCalled(); + }); + + it('should try to find user by email when annotation is missing', async () => { + const slack = new WebClient(); + (slack.users.lookupByEmail as jest.Mock).mockResolvedValueOnce({ + ok: true, + user: { id: 'U12345678' }, + }); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock-without-slack-annotation', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { + type: 'entity', + entityRef: 'user:default/mock-without-slack-annotation', + }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.users.lookupByEmail).toHaveBeenCalledWith({ + email: 'test@example.com', + }); + expect(slack.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'U12345678', + }), + ); + }); + + it('should log warning when email lookup fails', async () => { + const slack = new WebClient(); + (slack.users.lookupByEmail as jest.Mock).mockRejectedValueOnce( + new Error('User not found'), + ); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock-without-slack-annotation', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { + type: 'entity', + entityRef: 'user:default/mock-without-slack-annotation', + }, + payload: { title: 'notification' }, + }, + ); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to lookup Slack user by email test@example.com', + ), + ); + expect(slack.chat.postMessage).not.toHaveBeenCalled(); + }); + }); }); diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts index 3328c55408..ca51eeb068 100644 --- a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -20,7 +20,12 @@ import { LoggerService, } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; -import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { + Entity, + isUserEntity, + parseEntityRef, + UserEntity, +} from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; import { Notification } from '@backstage/plugin-notifications-common'; @@ -255,7 +260,11 @@ export class SlackNotificationProcessor implements NotificationProcessor { const response = await this.catalog.getEntitiesByRefs( { entityRefs: entityRefs.slice(), - fields: [`metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`], + fields: [ + `kind`, + `spec.profile.email`, + `metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`, + ], }, { token, @@ -278,7 +287,42 @@ export class SlackNotificationProcessor implements NotificationProcessor { throw new NotFoundError(`Entity not found: ${entityRef}`); } - return entity?.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY]; + const slackId = await this.resolveSlackId(entity); + return slackId; + } + + private async resolveSlackId(entity: Entity): Promise { + // First try to get Slack ID from annotations + const slackId = entity.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY]; + if (slackId) { + return slackId; + } + + // If no Slack ID in annotations and entity is a User, try to find by email + if (isUserEntity(entity)) { + return this.findSlackIdByEmail(entity); + } + + return undefined; + } + + private async findSlackIdByEmail( + entity: UserEntity, + ): Promise { + const email = entity.spec?.profile?.email; + if (!email) { + return undefined; + } + + try { + const user = await this.slack.users.lookupByEmail({ email }); + return user.user?.id; + } catch (error) { + this.logger.warn( + `Failed to lookup Slack user by email ${email}: ${error}`, + ); + return undefined; + } } async sendNotification(args: ChatPostMessageArguments): Promise { From a1c5bbb1e52709a239afe3b7043ca706a3ed9c55 Mon Sep 17 00:00:00 2001 From: Jackson Chen Date: Wed, 23 Apr 2025 15:39:36 -0400 Subject: [PATCH 10/12] add changeset Signed-off-by: Jackson Chen --- .changeset/spotty-doors-design.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spotty-doors-design.md diff --git a/.changeset/spotty-doors-design.md b/.changeset/spotty-doors-design.md new file mode 100644 index 0000000000..22ee61c9b0 --- /dev/null +++ b/.changeset/spotty-doors-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': patch +--- + +Added email-based Slack User ID lookup if `metadata.annotations.slack.com/bot-notify` is missing from user entity From f6f692c3b2586369d06add4da4461c23cc8fee18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Thu, 24 Apr 2025 06:44:52 +0000 Subject: [PATCH 11/12] Changed logging of cluster details to debug level to avoid cluttering the logs with unnecessary information. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- .changeset/dull-doodles-trade.md | 5 +++++ plugins/kubernetes-backend/src/service/KubernetesBuilder.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/dull-doodles-trade.md diff --git a/.changeset/dull-doodles-trade.md b/.changeset/dull-doodles-trade.md new file mode 100644 index 0000000000..eb9399fcc7 --- /dev/null +++ b/.changeset/dull-doodles-trade.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Changed logging of cluster details to debug to minimise log clutter. diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 2d2c592f53..36e814bf3c 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -488,7 +488,7 @@ export class KubernetesBuilder { ) { const clusterDetails = await clusterSupplier.getClusters(options); - this.env.logger.info( + this.env.logger.debug( `action=loadClusterDetails numOfClustersLoaded=${clusterDetails.length}`, ); From c8af9664f677eb456c5812e2065c8d97a6e29f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 24 Apr 2025 09:58:54 +0200 Subject: [PATCH 12/12] Update .changeset/real-sheep-chew.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/real-sheep-chew.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/real-sheep-chew.md b/.changeset/real-sheep-chew.md index 7ff56e5fc1..9ae5b5ae9e 100644 --- a/.changeset/real-sheep-chew.md +++ b/.changeset/real-sheep-chew.md @@ -1,5 +1,5 @@ --- -'@backstage/core-components': minor +'@backstage/core-components': patch --- -Fix the hidden sidebar's submenu when the sidebar is scrollable +Fix the hidden sidebar's sub-menu when the sidebar is scrollable