diff --git a/.changeset/bright-trainers-brake.md b/.changeset/bright-trainers-brake.md
new file mode 100644
index 0000000000..2299d83a32
--- /dev/null
+++ b/.changeset/bright-trainers-brake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Add frontend-dynamic-container role to eslint config factory
diff --git a/.changeset/chilly-trains-sleep.md b/.changeset/chilly-trains-sleep.md
new file mode 100644
index 0000000000..cde7600c8b
--- /dev/null
+++ b/.changeset/chilly-trains-sleep.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-events-backend-module-aws-sqs': patch
+'@backstage/plugin-catalog-backend-module-aws': patch
+'@backstage/backend-common': patch
+---
+
+Setup user agent header for AWS sdk clients, this enables users to better track API calls made from Backstage to AWS APIs through things like CloudTrail.
diff --git a/.changeset/dry-squids-tap.md b/.changeset/dry-squids-tap.md
new file mode 100644
index 0000000000..613ec4b7cb
--- /dev/null
+++ b/.changeset/dry-squids-tap.md
@@ -0,0 +1,31 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Introduce a new way to encapsulate extension kinds that replaces the extension creator pattern with `createExtensionBlueprint`
+
+This allows the creation of extension instances with the following pattern:
+
+```tsx
+// create the extension blueprint which is used to create instances
+const EntityCardBlueprint = createExtensionBlueprint({
+ kind: 'entity-card',
+ attachTo: { id: 'test', input: 'default' },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory(params: { text: string }) {
+ return {
+ element:
{params.text}
,
+ };
+ },
+});
+
+// create an instance of the extension blueprint with params
+const testExtension = EntityCardBlueprint.make({
+ name: 'foo',
+ params: {
+ text: 'Hello World',
+ },
+});
+```
diff --git a/.changeset/early-trees-dance.md b/.changeset/early-trees-dance.md
new file mode 100644
index 0000000000..55b2c08f89
--- /dev/null
+++ b/.changeset/early-trees-dance.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+The `ExtensionBoundary` now by default infers whether it's routable from whether it outputs a route path.
diff --git a/.changeset/grumpy-owls-suffer.md b/.changeset/grumpy-owls-suffer.md
new file mode 100644
index 0000000000..977055eb33
--- /dev/null
+++ b/.changeset/grumpy-owls-suffer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend-module-gitlab': patch
+---
+
+Allow the `createGitlabProjectVariableAction` to use oauth tokens
diff --git a/.changeset/hip-hairs-exist.md b/.changeset/hip-hairs-exist.md
new file mode 100644
index 0000000000..d8cb939dfb
--- /dev/null
+++ b/.changeset/hip-hairs-exist.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-catalog-backend': patch
+'@backstage/plugin-catalog-node': patch
+---
+
+Added setAllowedLocationTypes while introducing a new extension point called CatalogLocationsExtensionPoint
diff --git a/.changeset/late-games-protect.md b/.changeset/late-games-protect.md
new file mode 100644
index 0000000000..584780f836
--- /dev/null
+++ b/.changeset/late-games-protect.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-react': minor
+---
+
+Add scaffolder option to display object items in separate rows on review page
diff --git a/.changeset/little-suns-fly.md b/.changeset/little-suns-fly.md
new file mode 100644
index 0000000000..887df4be6d
--- /dev/null
+++ b/.changeset/little-suns-fly.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend-module-msgraph': patch
+---
+
+Added option to ingest groups based on their group membership in Azure Entra ID
diff --git a/.changeset/mighty-dolls-retire.md b/.changeset/mighty-dolls-retire.md
new file mode 100644
index 0000000000..b31c443a38
--- /dev/null
+++ b/.changeset/mighty-dolls-retire.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-react': patch
+---
+
+Internal refactor to remove unnecessary `routable` prop in the implementation of the `createEntityContentExtension` alpha export.
diff --git a/.changeset/modern-parrots-protect.md b/.changeset/modern-parrots-protect.md
new file mode 100644
index 0000000000..bb4df2c248
--- /dev/null
+++ b/.changeset/modern-parrots-protect.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-react': patch
+---
+
+support `ajv-errors` for scaffolder validation to allow for customizing the error messages
diff --git a/.changeset/pink-gorillas-brake.md b/.changeset/pink-gorillas-brake.md
new file mode 100644
index 0000000000..188884f13c
--- /dev/null
+++ b/.changeset/pink-gorillas-brake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-react': patch
+---
+
+Fix extra divider displayed on user list picker component
diff --git a/.changeset/swift-kings-sparkle.md b/.changeset/swift-kings-sparkle.md
new file mode 100644
index 0000000000..84ceae962b
--- /dev/null
+++ b/.changeset/swift-kings-sparkle.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend-module-aws': patch
+---
+
+`AwsOrganizationCloudAccountProcessor` configuration field `roleArn` is deprecated in favor of new field `accountId`
diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt
index 6ff0ef56da..1092947245 100644
--- a/.github/vale/config/vocabularies/Backstage/accept.txt
+++ b/.github/vale/config/vocabularies/Backstage/accept.txt
@@ -7,6 +7,7 @@ ADRs
airbrake
Airbrake
Airbrakes
+ajv
Alaria
Alef
allowlisted
diff --git a/docs/features/software-templates/input-examples.md b/docs/features/software-templates/input-examples.md
index f49e76e469..dd1228290f 100644
--- a/docs/features/software-templates/input-examples.md
+++ b/docs/features/software-templates/input-examples.md
@@ -26,6 +26,25 @@ parameters:
ui:help: 'Hint: additional description...'
```
+#### Custom validation error message
+
+```yaml
+parameters:
+ - title: Fill in some steps
+ properties:
+ name:
+ title: Simple text input
+ type: string
+ description: Description about input
+ maxLength: 8
+ pattern: '^([a-zA-Z][a-zA-Z0-9]*)(-[a-zA-Z0-9]+)*$'
+ ui:autofocus: true
+ ui:help: 'Hint: additional description...'
+ errorMessage:
+ properties:
+ name: '1-8 alphanumeric tokens (first starts with letter) delimited by -'
+```
+
### Multi line text input
```yaml
diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md
index 15591eeb67..075fe0bb08 100644
--- a/docs/features/software-templates/writing-templates.md
+++ b/docs/features/software-templates/writing-templates.md
@@ -533,6 +533,11 @@ catalogFilter:
metadata.annotations.github.com/team-slug: { exists: true }
```
+#### Custom validation messages
+
+You may specify custom JSON Schema validation messages as supported by the
+[ajv-errors](https://github.com/ajv-validator/ajv-errors) plugin library to [ajv](https://github.com/ajv-validator/ajv).
+
## `spec.steps` - `Action[]`
The `steps` is an array of the things that you want to happen part of this
diff --git a/docs/frontend-system/architecture/03-extensions.md b/docs/frontend-system/architecture/03-extensions.md
index 19345e0b26..cdc73a17ec 100644
--- a/docs/frontend-system/architecture/03-extensions.md
+++ b/docs/frontend-system/architecture/03-extensions.md
@@ -337,7 +337,7 @@ Similar to plugins the `ErrorBoundary` for extension allows to pass in a fallbac
### Analytics
-Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`.
+Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`. Whether an extension is routable is inferred from its outputs, but you can also explicitly control this behavior by passing the `routable` prop to `ExtensionBoundary`.
The `ExtensionBoundary` can be used like the following in an extension creator:
@@ -359,7 +359,7 @@ export function createSomeExtension<
path: config.path,
routeRef: options.routeRef,
element: (
-
+
),
diff --git a/docs/frontend-system/architecture/08-naming-patterns.md b/docs/frontend-system/architecture/08-naming-patterns.md
index 0916d9fd51..a17525f3ce 100644
--- a/docs/frontend-system/architecture/08-naming-patterns.md
+++ b/docs/frontend-system/architecture/08-naming-patterns.md
@@ -38,33 +38,31 @@ Note that while we use this naming pattern for the plugin instance this is only
| Description | Pattern | Examples |
| ----------- | ------------------------------- | ------------------------------------------------------------------- |
-| Creator | `createExtension` | `createPageExtension`, `createEntityCardExtension` |
+| Blueprint | `Blueprint` | `PageBlueprint`, `EntityCardBlueprint` |
| ID | `[:][/]` | `'core.nav'`, `'page:user-settings'`, `'entity-card:catalog/about'` |
| Symbol | `[][]` | `coreNav`, `userSettingsPage`, `catalogAboutEntityCard` |
-When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the extension creator function used to create the extension, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted.
+When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the blueprint creator, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted.
Example:
```ts
-// This is an extension creator that is used to create an extension of the 'page' kind.
-export function createPageExtension(options) {
- return createExtension({
- kind: 'page', // Kinds are kebab-case
- // ...options
- });
-}
+// This is an extension blueprint that is used to create an extension of the 'page' kind.
+export const PageBlueprint = createExtensionBlueprint({
+ kind: 'page',
+ // ...
+});
// The namespace is inferred from the plugin ID, in this case 'catalog'
// The final ID for this extension will be 'page:catalog/entity'
-const catalogEntityPage = createPageExtension({
+const catalogEntityPage = PageBlueprint.make({
name: 'entity',
// ...
});
// The name is omitted, because the catalog plugin only provides a single extension of this kind
// The final ID for this extension will be 'search-result-list-item:catalog'
-const catalogSearchResultListItem = createSearchResultListItemExtension({
+const catalogSearchResultListItem = SearchResultListItemBlueprint.make({
// ...
});
diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md
index 0a4eda2db5..1868188cdf 100644
--- a/docs/integrations/azure/org.md
+++ b/docs/integrations/azure/org.md
@@ -111,6 +111,17 @@ microsoftGraphOrg:
search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
```
+If you don't want to only ingest groups matching the `search` and/or `filter` query, but also the groups which are members of the matched groups, you can use the `includeSubGroups` configuration:
+
+```yaml
+microsoftGraphOrg:
+ providerId:
+ group:
+ filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
+ search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
+ includeSubGroups: true
+```
+
In addition to these groups, one additional group will be created for your organization.
All imported groups will be a child of this group.
diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts
index 7f02f1468f..5a2655ad1c 100644
--- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts
+++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsCodeCommitUrlReader.ts
@@ -213,6 +213,7 @@ export class AwsCodeCommitUrlReader implements UrlReaderService {
);
const codeCommit = new CodeCommitClient({
+ customUserAgent: 'backstage-aws-codecommit-url-reader',
region: region,
credentials: credentials,
});
diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts
index 27f60f5b77..324538add4 100644
--- a/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts
+++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/AwsS3UrlReader.ts
@@ -222,6 +222,7 @@ export class AwsS3UrlReader implements UrlReaderService {
);
const s3 = new S3Client({
+ customUserAgent: 'backstage-aws-s3-url-reader',
region: region,
credentials: credentials,
endpoint: integration.config.endpoint,
diff --git a/packages/cli/config/eslint-factory.js b/packages/cli/config/eslint-factory.js
index 6bc200cade..96d8478ec0 100644
--- a/packages/cli/config/eslint-factory.js
+++ b/packages/cli/config/eslint-factory.js
@@ -201,6 +201,7 @@ function createConfigForRole(dir, role, extraConfig = {}) {
case 'frontend':
case 'frontend-plugin':
case 'frontend-plugin-module':
+ case 'frontend-dynamic-container':
return createConfig(dir, {
...extraConfig,
extends: [
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 3e19573d95..29c165aaaa 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -499,6 +499,51 @@ export function createExtension<
options: CreateExtensionOptions,
): ExtensionDefinition;
+// @public
+export function createExtensionBlueprint<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+>(
+ options: CreateExtensionBlueprintOptions,
+): ExtensionBlueprint;
+
+// @public (undocumented)
+export interface CreateExtensionBlueprintOptions<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+> {
+ // (undocumented)
+ attachTo: {
+ id: string;
+ input: string;
+ };
+ // (undocumented)
+ configSchema?: PortableSchema;
+ // (undocumented)
+ disabled?: boolean;
+ // (undocumented)
+ factory(
+ params: TParams,
+ context: {
+ node: AppNode;
+ config: TConfig;
+ inputs: Expand>;
+ },
+ ): Expand>;
+ // (undocumented)
+ inputs?: TInputs;
+ // (undocumented)
+ kind: string;
+ // (undocumented)
+ namespace?: string;
+ // (undocumented)
+ output: TOutput;
+}
+
// @public (undocumented)
export function createExtensionDataRef(
id: string,
@@ -538,7 +583,7 @@ export interface CreateExtensionOptions<
// (undocumented)
disabled?: boolean;
// (undocumented)
- factory(options: {
+ factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand>;
@@ -833,6 +878,45 @@ export interface Extension {
readonly id: string;
}
+// @public (undocumented)
+export interface ExtensionBlueprint<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+> {
+ // (undocumented)
+ make(args: {
+ namespace?: string;
+ name?: string;
+ attachTo?: {
+ id: string;
+ input: string;
+ };
+ disabled?: boolean;
+ inputs?: TInputs;
+ output?: TOutput;
+ configSchema?: PortableSchema;
+ params: TParams;
+ factory?(
+ params: TParams,
+ context: {
+ node: AppNode;
+ config: TConfig;
+ inputs: Expand>;
+ orignalFactory(
+ params?: TParams,
+ context?: {
+ node?: AppNode;
+ config?: TConfig;
+ inputs?: Expand>;
+ },
+ ): Expand>;
+ },
+ ): Expand>;
+ }): ExtensionDefinition;
+}
+
// @public (undocumented)
export function ExtensionBoundary(
props: ExtensionBoundaryProps,
@@ -844,7 +928,6 @@ export interface ExtensionBoundaryProps {
children: ReactNode;
// (undocumented)
node: AppNode;
- // (undocumented)
routable?: boolean;
}
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
index 66fb3901fc..1f12f127e8 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
@@ -15,15 +15,24 @@
*/
import React, { useEffect } from 'react';
-import { screen, waitFor } from '@testing-library/react';
-import { MockAnalyticsApi, TestApiProvider } from '@backstage/test-utils';
+import { act, screen, waitFor } from '@testing-library/react';
+import {
+ MockAnalyticsApi,
+ TestApiProvider,
+ withLogCollector,
+} from '@backstage/test-utils';
import { ExtensionBoundary } from './ExtensionBoundary';
import { coreExtensionData, createExtension } from '../wiring';
-import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
+import {
+ analyticsApiRef,
+ createApiFactory,
+ useAnalytics,
+} from '@backstage/core-plugin-api';
import { createRouteRef } from '../routing';
import { createExtensionTester } from '@backstage/frontend-test-utils';
+import { createApiExtension } from '../extensions';
-const wrapInBoundaryExtension = (element: JSX.Element) => {
+const wrapInBoundaryExtension = (element?: JSX.Element) => {
const routeRef = createRouteRef();
return createExtension({
name: 'test',
@@ -54,12 +63,25 @@ describe('ExtensionBoundary', () => {
});
it('should show app error component when an error is thrown', async () => {
- const error = 'Something went wrong';
+ const errorMsg = 'Something went wrong';
const ErrorComponent = () => {
- throw new Error(error);
+ throw new Error(errorMsg);
};
- createExtensionTester(wrapInBoundaryExtension()).render();
- await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument());
+ const { error } = await withLogCollector(['error'], async () => {
+ createExtensionTester(
+ wrapInBoundaryExtension(),
+ ).render();
+ await waitFor(() =>
+ expect(screen.getByText(errorMsg)).toBeInTheDocument(),
+ );
+ });
+ expect(error).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ message: expect.stringContaining(errorMsg),
+ }),
+ ]),
+ );
});
it('should wrap children with analytics context', async () => {
@@ -97,4 +119,38 @@ describe('ExtensionBoundary', () => {
});
});
});
+
+ // TODO(Rugvip): It's annoying to test the inverse of this currently, because the extension tester overrides the subject to always output a path
+ it('should emit analytics events if routable', async () => {
+ const Emitter = () => {
+ const analytics = useAnalytics();
+ useEffect(() => {
+ analytics.captureEvent('dummy', 'dummy');
+ });
+ return null;
+ };
+ const analyticsApiMock = new MockAnalyticsApi();
+
+ await act(async () => {
+ createExtensionTester(wrapInBoundaryExtension())
+ .add(
+ createApiExtension({
+ factory: createApiFactory(analyticsApiRef, analyticsApiMock),
+ }),
+ )
+ .render();
+ });
+
+ expect(analyticsApiMock.getEvents()).toEqual([
+ expect.objectContaining({
+ action: 'navigate',
+ subject: '/',
+ context: expect.objectContaining({
+ pluginId: 'root',
+ extensionId: 'test',
+ }),
+ }),
+ expect.objectContaining({ action: 'dummy' }),
+ ]);
+ });
});
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
index 768352b76e..b6269484b0 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
@@ -26,6 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary';
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
+import { coreExtensionData } from '../wiring';
type RouteTrackerProps = PropsWithChildren<{
disableTracking?: boolean;
@@ -50,6 +51,11 @@ const RouteTracker = (props: RouteTrackerProps) => {
/** @public */
export interface ExtensionBoundaryProps {
node: AppNode;
+ /**
+ * This explicitly marks the extension as routable for the purpose of
+ * capturing analytics events. If not provided, the extension boundary will be
+ * marked as routable if it outputs a routePath.
+ */
routable?: boolean;
children: ReactNode;
}
@@ -58,6 +64,10 @@ export interface ExtensionBoundaryProps {
export function ExtensionBoundary(props: ExtensionBoundaryProps) {
const { node, routable, children } = props;
+ const doesOutputRoutePath = Boolean(
+ node.instance?.getData(coreExtensionData.routePath),
+ );
+
const plugin = node.spec.source;
const Progress = useComponentRef(coreComponentRefs.progress);
const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
@@ -72,7 +82,9 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
}>
- {children}
+
+ {children}
+
diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
index dfe549f32b..cd434a3ffc 100644
--- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
+++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
@@ -87,7 +87,7 @@ export function createPageExtension<
path: config.path,
routeRef: options.routeRef,
element: (
-
+
),
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index be76fbc069..28dc6a4e29 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -91,7 +91,7 @@ export interface CreateExtensionOptions<
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema;
- factory(options: {
+ factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand>;
@@ -115,7 +115,7 @@ export interface InternalExtensionDefinition
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
- factory(options: {
+ factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs;
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx
new file mode 100644
index 0000000000..c7b077bdbd
--- /dev/null
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 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 React from 'react';
+import { coreExtensionData } from './coreExtensionData';
+import { createExtensionBlueprint } from './createExtensionBlueprint';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+
+describe('createExtensionBlueprint', () => {
+ it('should allow creation of extension blueprints', () => {
+ const TestExtensionBlueprint = createExtensionBlueprint({
+ kind: 'test-extension',
+ attachTo: { id: 'test', input: 'default' },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory(params: { text: string }) {
+ return {
+ element: {params.text}
,
+ };
+ },
+ });
+
+ const extension = TestExtensionBlueprint.make({
+ name: 'my-extension',
+ params: {
+ text: 'Hello, world!',
+ },
+ });
+
+ expect(extension).toEqual({
+ $$type: '@backstage/ExtensionDefinition',
+ attachTo: {
+ id: 'test',
+ input: 'default',
+ },
+ configSchema: undefined,
+ disabled: false,
+ inputs: {},
+ kind: 'test-extension',
+ name: 'my-extension',
+ namespace: undefined,
+ output: {
+ element: {
+ $$type: '@backstage/ExtensionDataRef',
+ config: {},
+ id: 'core.reactElement',
+ optional: expect.any(Function),
+ toString: expect.any(Function),
+ },
+ },
+ factory: expect.any(Function),
+ toString: expect.any(Function),
+ version: 'v1',
+ });
+
+ const { container } = createExtensionTester(extension).render();
+ expect(container.querySelector('h1')).toHaveTextContent('Hello, world!');
+ });
+
+ it('should allow overriding of the default factory', () => {
+ const TestExtensionBlueprint = createExtensionBlueprint({
+ kind: 'test-extension',
+ attachTo: { id: 'test', input: 'default' },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory(params: { text: string }) {
+ return {
+ element: {params.text}
,
+ };
+ },
+ });
+
+ const extension = TestExtensionBlueprint.make({
+ name: 'my-extension',
+ params: {
+ text: 'Hello, world!',
+ },
+ factory(params: { text: string }) {
+ return {
+ element: {params.text}
,
+ };
+ },
+ });
+
+ expect(extension).toBeDefined();
+
+ const { container } = createExtensionTester(extension).render();
+ expect(container.querySelector('h2')).toHaveTextContent('Hello, world!');
+ });
+});
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
new file mode 100644
index 0000000000..6c79017a0a
--- /dev/null
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2024 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 { AppNode } from '../apis';
+import { PortableSchema } from '../schema';
+import { Expand } from '../types';
+import {
+ AnyExtensionDataMap,
+ AnyExtensionInputMap,
+ ExtensionDataValues,
+ ExtensionDefinition,
+ ResolvedExtensionInputs,
+ createExtension,
+} from './createExtension';
+
+/**
+ * @public
+ */
+export interface CreateExtensionBlueprintOptions<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+> {
+ kind: string;
+ namespace?: string;
+ attachTo: { id: string; input: string };
+ disabled?: boolean;
+ inputs?: TInputs;
+ output: TOutput;
+ configSchema?: PortableSchema;
+ factory(
+ params: TParams,
+ context: {
+ node: AppNode;
+ config: TConfig;
+ inputs: Expand>;
+ },
+ ): Expand>;
+}
+
+/**
+ * @public
+ */
+export interface ExtensionBlueprint<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+> {
+ make(args: {
+ namespace?: string;
+ name?: string;
+ attachTo?: { id: string; input: string };
+ disabled?: boolean;
+ inputs?: TInputs;
+ output?: TOutput;
+ configSchema?: PortableSchema;
+ params: TParams;
+ factory?(
+ params: TParams,
+ context: {
+ node: AppNode;
+ config: TConfig;
+ inputs: Expand>;
+ orignalFactory(
+ params?: TParams,
+ context?: {
+ node?: AppNode;
+ config?: TConfig;
+ inputs?: Expand>;
+ },
+ ): Expand>;
+ },
+ ): Expand>;
+ }): ExtensionDefinition;
+}
+
+/**
+ * @internal
+ */
+class ExtensionBlueprintImpl<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+> {
+ constructor(
+ private readonly options: CreateExtensionBlueprintOptions<
+ TParams,
+ TInputs,
+ TOutput,
+ TConfig
+ >,
+ ) {}
+
+ public make(args: {
+ namespace?: string;
+ name?: string;
+ attachTo?: { id: string; input: string };
+ disabled?: boolean;
+ inputs?: TInputs;
+ output?: TOutput;
+ configSchema?: PortableSchema;
+ params: TParams;
+ factory?(
+ params: TParams,
+ context: {
+ node: AppNode;
+ config: TConfig;
+ inputs: Expand>;
+ orignalFactory(
+ params?: TParams,
+ context?: {
+ node?: AppNode;
+ config?: TConfig;
+ inputs?: Expand>;
+ },
+ ): Expand>;
+ },
+ ): Expand>;
+ }): ExtensionDefinition {
+ return createExtension({
+ kind: this.options.kind,
+ namespace: args.namespace ?? this.options.namespace,
+ name: args.name,
+ attachTo: args.attachTo ?? this.options.attachTo,
+ disabled: args.disabled ?? this.options.disabled,
+ inputs: args.inputs ?? this.options.inputs,
+ output: args.output ?? this.options.output,
+ configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth
+ factory: ({ node, config, inputs }) => {
+ if (args.factory) {
+ return args.factory(args.params, {
+ node,
+ config,
+ inputs,
+ orignalFactory: (
+ innerParams?: TParams,
+ innerContext?: {
+ config?: TConfig;
+ inputs?: Expand>;
+ },
+ ) =>
+ this.options.factory(innerParams ?? args.params, {
+ node,
+ config: innerContext?.config ?? config,
+ inputs: innerContext?.inputs ?? inputs,
+ }),
+ });
+ }
+
+ return this.options.factory(args.params, {
+ node,
+ config,
+ inputs,
+ });
+ },
+ });
+ }
+}
+
+/**
+ * A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating
+ * types and instances of those types.
+ *
+ * @public
+ */
+export function createExtensionBlueprint<
+ TParams,
+ TInputs extends AnyExtensionInputMap,
+ TOutput extends AnyExtensionDataMap,
+ TConfig,
+>(
+ options: CreateExtensionBlueprintOptions,
+): ExtensionBlueprint {
+ return new ExtensionBlueprintImpl(options);
+}
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index 61f821c7ce..6d20368225 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -48,3 +48,8 @@ export {
type FeatureFlagConfig,
type FrontendFeature,
} from './types';
+export {
+ type CreateExtensionBlueprintOptions,
+ type ExtensionBlueprint,
+ createExtensionBlueprint,
+} from './createExtensionBlueprint';
diff --git a/plugins/catalog-backend-module-aws/config.d.ts b/plugins/catalog-backend-module-aws/config.d.ts
index 4c9c98512e..0a9d9c97db 100644
--- a/plugins/catalog-backend-module-aws/config.d.ts
+++ b/plugins/catalog-backend-module-aws/config.d.ts
@@ -26,8 +26,14 @@ export interface Config {
provider: {
/**
* The role to be assumed by this processor
+ * @deprecated Use `accountId` instead.
*/
roleArn?: string;
+
+ /**
+ * The AWS account ID to query for organizational data
+ */
+ accountId?: string;
};
};
};
diff --git a/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts b/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts
index 7e0dc58559..a84c65abda 100644
--- a/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts
+++ b/plugins/catalog-backend-module-aws/src/awsOrganization/config.test.ts
@@ -22,11 +22,13 @@ describe('readAwsOrganizationConfig', () => {
const config = {
provider: {
roleArn: 'aws::arn::foo',
+ accountId: '111111111111',
},
};
const actual = readAwsOrganizationConfig(new ConfigReader(config));
const expected = {
roleArn: 'aws::arn::foo',
+ accountId: '111111111111',
};
expect(actual).toEqual(expected);
});
diff --git a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts
index e6096b932b..ea16394e0a 100644
--- a/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts
+++ b/plugins/catalog-backend-module-aws/src/awsOrganization/config.ts
@@ -22,8 +22,14 @@ import { Config } from '@backstage/config';
export type AwsOrganizationProviderConfig = {
/**
* The role to assume for the processor.
+ * @deprecated Use `accountId` instead.
*/
roleArn?: string;
+
+ /**
+ * The AWS accountId
+ */
+ accountId?: string;
};
export function readAwsOrganizationConfig(
@@ -32,7 +38,9 @@ export function readAwsOrganizationConfig(
const providerConfig = config.getOptionalConfig('provider');
const roleArn = providerConfig?.getOptionalString('roleArn');
+ const accountId = providerConfig?.getOptionalString('accountId');
return {
roleArn,
+ accountId,
};
}
diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts
index a491dbab7d..a5f5ce2f6c 100644
--- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts
+++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts
@@ -106,6 +106,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor {
}
const eksClient = new EKS({
+ customUserAgent: 'backstage-aws-catalog-eks-cluster-processor',
credentials,
credentialDefaultProvider: providerFunction,
region,
diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts
index 4a454fce6f..73d7a63e2b 100644
--- a/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts
+++ b/plugins/catalog-backend-module-aws/src/processors/AwsOrganizationCloudAccountProcessor.ts
@@ -59,10 +59,18 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor {
static async fromConfig(config: Config, options: { logger: LoggerService }) {
const c = config.getOptionalConfig('catalog.processors.awsOrganization');
const orgConfig = c ? readAwsOrganizationConfig(c) : undefined;
+
+ if (orgConfig?.roleArn) {
+ options.logger.warn(
+ 'The roleArn configuration for AwsOrganizationCloudAccountProcessor ignores the role name, use accountId configuration instead',
+ );
+ }
+
const awsCredentialsManager =
DefaultAwsCredentialsManager.fromConfig(config);
const credProvider = await awsCredentialsManager.getCredentialProvider({
arn: orgConfig?.roleArn,
+ accountId: orgConfig?.accountId,
});
return new AwsOrganizationCloudAccountProcessor(
credProvider,
diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
index 91ba76b695..0c58fe3f3a 100644
--- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
+++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts
@@ -154,6 +154,7 @@ export class AwsS3EntityProvider implements EntityProvider {
accountId ? { accountId } : undefined,
);
this.s3 = new S3({
+ customUserAgent: 'backstage-aws-catalog-s3-entity-provider',
apiVersion: '2006-03-01',
credentialDefaultProvider: () => credProvider.sdkCredentialProvider,
endpoint: this.integration.config.endpoint,
diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md
index 9640f3708b..57f50fbd18 100644
--- a/plugins/catalog-backend-module-msgraph/api-report.md
+++ b/plugins/catalog-backend-module-msgraph/api-report.md
@@ -39,10 +39,10 @@ export function defaultUserTransformer(
// @public
export type GroupMember =
| (MicrosoftGraph.Group & {
- '@odata.type': '#microsoft.graph.user';
+ '@odata.type': '#microsoft.graph.group';
})
| (MicrosoftGraph.User & {
- '@odata.type': '#microsoft.graph.group';
+ '@odata.type': '#microsoft.graph.user';
});
// @public
@@ -215,6 +215,7 @@ export type MicrosoftGraphProviderConfig = {
groupFilter?: string;
groupSearch?: string;
groupSelect?: string[];
+ groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
loadUserPhotos?: boolean;
schedule?: TaskScheduleDefinition;
@@ -263,6 +264,7 @@ export function readMicrosoftGraphOrg(
groupSearch?: string;
groupFilter?: string;
groupSelect?: string[];
+ groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts
index 5a1f56a03f..a470c075f0 100644
--- a/plugins/catalog-backend-module-msgraph/config.d.ts
+++ b/plugins/catalog-backend-module-msgraph/config.d.ts
@@ -192,6 +192,11 @@ export interface Config {
* E.g. ["id", "displayName", "description"]
*/
select?: string[];
+ /**
+ * Whether to ingest groups that are members of the found/filtered/searched groups.
+ * Default value is `false`.
+ */
+ includeSubGroups?: boolean;
};
userGroupMember?: {
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts
index 1233b1e2f9..c190e2a959 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts
@@ -64,8 +64,8 @@ export type ODataQuery = {
* @public
*/
export type GroupMember =
- | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' })
- | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' });
+ | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.group' })
+ | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.user' });
/**
* A HTTP Client that communicates with Microsoft Graph API.
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
index c0e9f61f29..0ec56f134d 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts
@@ -174,6 +174,7 @@ describe('readProviderConfigs', () => {
expand: 'member',
filter: 'securityEnabled eq false',
select: ['id', 'displayName', 'description'],
+ includeSubGroups: true,
},
schedule: {
frequency: 'PT30M',
@@ -202,6 +203,7 @@ describe('readProviderConfigs', () => {
groupExpand: 'member',
groupSelect: ['id', 'displayName', 'description'],
groupFilter: 'securityEnabled eq false',
+ groupIncludeSubGroups: true,
schedule: {
frequency: { minutes: 30 },
timeout: {
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
index 1545114e45..257a7d5736 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts
@@ -116,6 +116,12 @@ export type MicrosoftGraphProviderConfig = {
*/
groupSelect?: string[];
+ /**
+ * Whether to ingest groups that are members of the found/filtered/searched groups.
+ * Default value is `false`.
+ */
+ groupIncludeSubGroups?: boolean;
+
/**
* By default, the Microsoft Graph API only provides the basic feature set
* for querying. Certain features are limited to advanced query capabilities
@@ -292,6 +298,9 @@ export function readProviderConfig(
const groupFilter = config.getOptionalString('group.filter');
const groupSearch = config.getOptionalString('group.search');
const groupSelect = config.getOptionalStringArray('group.select');
+ const groupIncludeSubGroups = config.getOptionalBoolean(
+ 'group.includeSubGroups',
+ );
const queryMode = config.getOptionalString('queryMode');
if (
@@ -347,6 +356,7 @@ export function readProviderConfig(
groupFilter,
groupSearch,
groupSelect,
+ groupIncludeSubGroups,
queryMode,
userGroupMemberFilter,
userGroupMemberSearch,
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts
index 8821ffd2db..b83597653f 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts
@@ -90,6 +90,9 @@ describe('read microsoft graph', () => {
yield {
'@odata.type': '#microsoft.graph.group',
id: 'childgroupid',
+ displayName: 'Child Group Name',
+ description: 'Child Group Description',
+ mail: 'childgroup@example.com',
};
yield {
'@odata.type': '#microsoft.graph.user',
@@ -770,6 +773,117 @@ describe('read microsoft graph', () => {
top: 999,
});
});
+
+ it('should read groups and their sub groups', async () => {
+ async function* getExampleGroupMembersForSubGroup(): AsyncIterable {
+ yield {
+ '@odata.type': '#microsoft.graph.user',
+ id: 'userid2',
+ };
+ }
+
+ client.getGroups.mockImplementation(getExampleGroups);
+ client.getGroupMembers.mockImplementationOnce(getExampleGroupMembers);
+ client.getGroupMembers.mockImplementationOnce(
+ getExampleGroupMembersForSubGroup,
+ );
+ client.getOrganization.mockResolvedValue({
+ id: 'tenantid',
+ displayName: 'Organization Name',
+ });
+ client.getGroupPhotoWithSizeLimit.mockResolvedValue(
+ 'data:image/jpeg;base64,...',
+ );
+
+ const { groups, groupMember, groupMemberOf, rootGroup } =
+ await readMicrosoftGraphGroups(client, 'tenantid', {
+ groupIncludeSubGroups: true,
+ });
+
+ const expectedRootGroup = group({
+ metadata: {
+ annotations: {
+ 'graph.microsoft.com/tenant-id': 'tenantid',
+ },
+ name: 'organization_name',
+ description: 'Organization Name',
+ },
+ spec: {
+ type: 'root',
+ profile: {
+ displayName: 'Organization Name',
+ },
+ children: [],
+ },
+ });
+ expect(groups).toEqual([
+ expectedRootGroup,
+ group({
+ metadata: {
+ annotations: {
+ 'graph.microsoft.com/group-id': 'childgroupid',
+ },
+ name: 'child_group_name',
+ description: 'Child Group Description',
+ },
+ spec: {
+ type: 'team',
+ profile: {
+ displayName: 'Child Group Name',
+ email: 'childgroup@example.com',
+ // TODO: Loading groups photos doesn't work right now as Microsoft
+ // Graph doesn't allows this yet
+ /* picture: 'data:image/jpeg;base64,...',*/
+ },
+ children: [],
+ },
+ }),
+ group({
+ metadata: {
+ annotations: {
+ 'graph.microsoft.com/group-id': 'groupid',
+ },
+ name: 'group_name',
+ description: 'Group Description',
+ },
+ spec: {
+ type: 'team',
+ profile: {
+ displayName: 'Group Name',
+ email: 'group@example.com',
+ // TODO: Loading groups photos doesn't work right now as Microsoft
+ // Graph doesn't allows this yet
+ /* picture: 'data:image/jpeg;base64,...',*/
+ },
+ children: [],
+ },
+ }),
+ ]);
+ expect(rootGroup).toEqual(expectedRootGroup);
+ expect(groupMember.get('groupid')).toEqual(new Set(['childgroupid']));
+ expect(groupMemberOf.get('userid')).toEqual(new Set(['groupid']));
+ expect(groupMemberOf.get('userid2')).toEqual(new Set(['childgroupid']));
+ expect(groupMember.get('organization_name')).toEqual(new Set());
+
+ expect(client.getGroups).toHaveBeenCalledTimes(1);
+ expect(client.getGroups).toHaveBeenCalledWith(
+ {
+ top: 999,
+ },
+ undefined,
+ );
+ expect(client.getGroupMembers).toHaveBeenCalledTimes(2);
+ expect(client.getGroupMembers).toHaveBeenCalledWith('groupid', {
+ top: 999,
+ });
+ expect(client.getGroupMembers).toHaveBeenCalledWith('childgroupid', {
+ top: 999,
+ });
+ // TODO: Loading groups photos doesn't work right now as Microsoft Graph
+ // doesn't allows this yet
+ // expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1);
+ // expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120);
+ });
});
describe('resolveRelations', () => {
diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts
index 2ca21fca9c..60e5c3d79b 100644
--- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts
+++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts
@@ -178,6 +178,7 @@ export async function readMicrosoftGraphGroups(
groupFilter?: string;
groupSearch?: string;
groupSelect?: string[];
+ groupIncludeSubGroups?: boolean;
groupTransformer?: GroupTransformer;
organizationTransformer?: OrganizationTransformer;
},
@@ -244,6 +245,31 @@ export async function readMicrosoftGraphGroups(
if (member['@odata.type'] === '#microsoft.graph.group') {
ensureItem(groupMember, group.id!, member.id);
+
+ if (options?.groupIncludeSubGroups) {
+ const groupMemberEntity = await transformer(member);
+
+ if (groupMemberEntity) {
+ groups.push(groupMemberEntity);
+
+ for await (const subMember of client.getGroupMembers(
+ member.id!,
+ { top: PAGE_SIZE },
+ )) {
+ if (!subMember.id) {
+ continue;
+ }
+
+ if (subMember['@odata.type'] === '#microsoft.graph.user') {
+ ensureItem(groupMemberOf, subMember.id, member.id!);
+ }
+
+ if (subMember['@odata.type'] === '#microsoft.graph.group') {
+ ensureItem(groupMember, member.id!, subMember.id);
+ }
+ }
+ }
+ }
}
}
@@ -377,6 +403,7 @@ export async function readMicrosoftGraphOrg(
groupSearch?: string;
groupFilter?: string;
groupSelect?: string[];
+ groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
@@ -421,6 +448,7 @@ export async function readMicrosoftGraphOrg(
groupFilter: options.groupFilter,
groupSearch: options.groupSearch,
groupSelect: options.groupSelect,
+ groupIncludeSubGroups: options.groupIncludeSubGroups,
groupTransformer: options.groupTransformer,
organizationTransformer: options.organizationTransformer,
});
diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
index 21995d2b8d..a019ac79a3 100644
--- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
+++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts
@@ -338,6 +338,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider {
groupFilter: provider.groupFilter,
groupSearch: provider.groupSearch,
groupSelect: provider.groupSelect,
+ groupIncludeSubGroups: provider.groupIncludeSubGroups,
queryMode: provider.queryMode,
groupTransformer: this.options.groupTransformer,
userTransformer: this.options.userTransformer,
diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts
index aebd969701..2f5fd9103d 100644
--- a/plugins/catalog-backend/src/service/CatalogPlugin.ts
+++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts
@@ -28,6 +28,8 @@ import {
catalogPermissionExtensionPoint,
CatalogProcessingExtensionPoint,
catalogProcessingExtensionPoint,
+ CatalogLocationsExtensionPoint,
+ catalogLocationsExtensionPoint,
} from '@backstage/plugin-catalog-node/alpha';
import {
CatalogProcessor,
@@ -41,6 +43,20 @@ import { merge } from 'lodash';
import { Permission } from '@backstage/plugin-permission-common';
import { ForwardedError } from '@backstage/errors';
+class CatalogLocationsExtensionPointImpl
+ implements CatalogLocationsExtensionPoint
+{
+ #locationTypes = new Array();
+
+ setAllowedLocationTypes(locationTypes: Array) {
+ this.#locationTypes = locationTypes;
+ }
+
+ get allowedLocationTypes() {
+ return this.#locationTypes;
+ }
+}
+
class CatalogProcessingExtensionPointImpl
implements CatalogProcessingExtensionPoint
{
@@ -199,6 +215,12 @@ export const catalogPlugin = createBackendPlugin({
const modelExtensions = new CatalogModelExtensionPointImpl();
env.registerExtensionPoint(catalogModelExtensionPoint, modelExtensions);
+ const locationTypeExtensions = new CatalogLocationsExtensionPointImpl();
+ env.registerExtensionPoint(
+ catalogLocationsExtensionPoint,
+ locationTypeExtensions,
+ );
+
env.registerInit({
deps: {
logger: coreServices.logger,
@@ -271,6 +293,10 @@ export const catalogPlugin = createBackendPlugin({
builder.addPermissionRules(...permissionExtensions.permissionRules);
builder.setFieldFormatValidators(modelExtensions.fieldValidators);
+ builder.setAllowedLocationTypes(
+ locationTypeExtensions.allowedLocationTypes,
+ );
+
const { processingEngine, router } = await builder.build();
lifecycle.addStartupHook(async () => {
diff --git a/plugins/catalog-node/api-report-alpha.md b/plugins/catalog-node/api-report-alpha.md
index b61d1b1d27..e14f55c196 100644
--- a/plugins/catalog-node/api-report-alpha.md
+++ b/plugins/catalog-node/api-report-alpha.md
@@ -34,6 +34,14 @@ export interface CatalogAnalysisExtensionPoint {
// @alpha (undocumented)
export const catalogAnalysisExtensionPoint: ExtensionPoint;
+// @alpha (undocumented)
+export interface CatalogLocationsExtensionPoint {
+ setAllowedLocationTypes(locationTypes: Array): void;
+}
+
+// @alpha (undocumented)
+export const catalogLocationsExtensionPoint: ExtensionPoint;
+
// @alpha (undocumented)
export interface CatalogModelExtensionPoint {
setEntityDataParser(parser: CatalogProcessorParser): void;
diff --git a/plugins/catalog-node/src/alpha.ts b/plugins/catalog-node/src/alpha.ts
index 91b5bb99b6..b75ec48a7f 100644
--- a/plugins/catalog-node/src/alpha.ts
+++ b/plugins/catalog-node/src/alpha.ts
@@ -15,6 +15,8 @@
*/
export { catalogServiceRef } from './catalogService';
+export type { CatalogLocationsExtensionPoint } from './extensions';
+export { catalogLocationsExtensionPoint } from './extensions';
export type { CatalogProcessingExtensionPoint } from './extensions';
export { catalogProcessingExtensionPoint } from './extensions';
export type { CatalogAnalysisExtensionPoint } from './extensions';
diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts
index 7c1fe6c30d..a971e1588d 100644
--- a/plugins/catalog-node/src/extensions.ts
+++ b/plugins/catalog-node/src/extensions.ts
@@ -31,6 +31,25 @@ import {
} from '@backstage/plugin-permission-common';
import { PermissionRule } from '@backstage/plugin-permission-node';
+/**
+ * @alpha
+ */
+export interface CatalogLocationsExtensionPoint {
+ /**
+ * Allows setting custom location types, such as showcased in: https://backstage.io/docs/features/software-catalog/external-integrations/#creating-a-catalog-data-reader-processor
+ * @param locationTypes - List of location types to allow, default is "url" and "file"
+ */
+ setAllowedLocationTypes(locationTypes: Array): void;
+}
+
+/**
+ * @alpha
+ */
+export const catalogLocationsExtensionPoint =
+ createExtensionPoint({
+ id: 'catalog.locations',
+ });
+
/**
* @alpha
*/
diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx
index b1650d1f5b..85712b358a 100644
--- a/plugins/catalog-react/src/alpha.tsx
+++ b/plugins/catalog-react/src/alpha.tsx
@@ -166,7 +166,7 @@ export function createEntityContentExtension<
title: config.title,
routeRef: options.routeRef,
element: (
-
+
),
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
index 7f0ff8be74..c6b515aa67 100644
--- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
@@ -247,11 +247,11 @@ export const UserListPicker = (props: UserListPickerProps) => {
- {group.items.map(item => (
+ {group.items.map((item, index) => (