Merge branch 'backstage:master' into integrations/gitlab-url-reader-token
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-react': patch
|
||||
---
|
||||
|
||||
Add headlamp formatter
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
---
|
||||
|
||||
Changed logging of cluster details to debug to minimise log clutter.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Fix the hidden sidebar's sub-menu when the sidebar is scrollable
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,15 +41,21 @@ import { coreComponentsTranslationRef } from '../../translation';
|
||||
export type SidebarClassKey = 'drawer' | 'drawerOpen';
|
||||
const useStyles = makeStyles<Theme, { sidebarConfig: SidebarConfig }>(
|
||||
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',
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -283,6 +283,12 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
|
||||
// @public (undocumented)
|
||||
export const GroupedResponsesContext: Context<GroupedResponses>;
|
||||
|
||||
// @public (undocumented)
|
||||
export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter {
|
||||
// (undocumented)
|
||||
formatClusterLink(options: ClusterLinksFormatterOptions): Promise<URL>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const HorizontalPodAutoscalerDrawer: (props: {
|
||||
hpa: V2HorizontalPodAutoscaler;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<URL> {
|
||||
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<string, string> = {
|
||||
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] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
+175
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
// 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<string | undefined> {
|
||||
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<void> {
|
||||
|
||||
@@ -32,6 +32,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', () => {
|
||||
@@ -130,8 +131,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',
|
||||
@@ -172,8 +174,28 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
] as Entity[];
|
||||
|
||||
// Filter requested fields
|
||||
const filteredItems = fullItems.map(item => {
|
||||
const filtered: Record<string, any> = { 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<string, any>)[parent]?.[
|
||||
child
|
||||
];
|
||||
} else {
|
||||
filtered[field] = item[field as keyof Entity];
|
||||
}
|
||||
});
|
||||
return filtered as Entity;
|
||||
});
|
||||
|
||||
return { items: filteredItems };
|
||||
},
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
@@ -255,7 +277,7 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
'relations.hasMember': 'user:default/guest',
|
||||
},
|
||||
],
|
||||
fields: ['metadata', 'kind'],
|
||||
fields: ['metadata', 'kind', 'spec.profile'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -300,7 +322,7 @@ describe('MyGroupsSidebarItem Test', () => {
|
||||
'spec.type': 'team',
|
||||
},
|
||||
],
|
||||
fields: ['metadata', 'kind'],
|
||||
fields: ['metadata', 'kind', 'spec.profile'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export const MyGroupsSidebarItem = (props: {
|
||||
...(filter ?? {}),
|
||||
},
|
||||
],
|
||||
fields: ['metadata', 'kind'],
|
||||
fields: ['metadata', 'kind', 'spec.profile'],
|
||||
});
|
||||
return response.items;
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user