From df122310a059c1a33d322fc952297c0ce5d72e40 Mon Sep 17 00:00:00 2001 From: secustor Date: Tue, 16 Jan 2024 17:15:40 +0100 Subject: [PATCH 001/176] feat(catalog): allow setting EntityDataParser using CatalogProcessingExtensionPoint Signed-off-by: secustor --- .changeset/polite-zoos-pay.md | 6 ++++++ plugins/catalog-backend/src/service/CatalogPlugin.ts | 9 +++++++++ plugins/catalog-node/api-report-alpha.md | 3 +++ plugins/catalog-node/src/extensions.ts | 2 ++ 4 files changed, 20 insertions(+) create mode 100644 .changeset/polite-zoos-pay.md diff --git a/.changeset/polite-zoos-pay.md b/.changeset/polite-zoos-pay.md new file mode 100644 index 0000000000..1a6a0c69a7 --- /dev/null +++ b/.changeset/polite-zoos-pay.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend': minor +'@backstage/plugin-catalog-node': minor +--- + +Allow setting EntityDataParser using CatalogProcessingExtensionPoint diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 0800d41340..10ca57d7e1 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -28,11 +28,13 @@ import { } from '@backstage/plugin-catalog-node/alpha'; import { CatalogProcessor, + CatalogProcessorParser, EntityProvider, ScmLocationAnalyzer, } from '@backstage/plugin-catalog-node'; import { loggerToWinstonLogger } from '@backstage/backend-common'; import { PlaceholderResolver } from '../modules'; +import { defaultEntityDataParser } from '../modules/util/parse'; class CatalogProcessingExtensionPointImpl implements CatalogProcessingExtensionPoint @@ -40,6 +42,7 @@ class CatalogProcessingExtensionPointImpl #processors = new Array(); #entityProviders = new Array(); #placeholderResolvers: Record = {}; + entityDataParser: CatalogProcessorParser = defaultEntityDataParser; addProcessor( ...processors: Array> @@ -61,6 +64,10 @@ class CatalogProcessingExtensionPointImpl this.#placeholderResolvers[key] = resolver; } + setEntityDataParser(parser: CatalogProcessorParser): void { + this.entityDataParser = parser; + } + get processors() { return this.#processors; } @@ -164,6 +171,8 @@ export const catalogPlugin = createBackendPlugin({ }); builder.addProcessor(...processingExtensions.processors); builder.addEntityProvider(...processingExtensions.entityProviders); + builder.setEntityDataParser(processingExtensions.entityDataParser); + Object.entries(processingExtensions.placeholderResolvers).forEach( ([key, resolver]) => builder.setPlaceholderResolver(key, resolver), ); diff --git a/plugins/catalog-node/api-report-alpha.md b/plugins/catalog-node/api-report-alpha.md index 74339b1bc9..a231e43885 100644 --- a/plugins/catalog-node/api-report-alpha.md +++ b/plugins/catalog-node/api-report-alpha.md @@ -5,6 +5,7 @@ ```ts import { CatalogApi } from '@backstage/catalog-client'; import { CatalogProcessor } from '@backstage/plugin-catalog-node'; +import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; import { EntitiesSearchFilter } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-node'; @@ -54,6 +55,8 @@ export interface CatalogProcessingExtensionPoint { addProcessor( ...processors: Array> ): void; + // (undocumented) + setEntityDataParser(parser: CatalogProcessorParser): void; } // @alpha (undocumented) diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index 7aa9264030..e52468bcab 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -18,6 +18,7 @@ import { createExtensionPoint } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { CatalogProcessor, + CatalogProcessorParser, EntitiesSearchFilter, EntityProvider, PlaceholderResolver, @@ -37,6 +38,7 @@ export interface CatalogProcessingExtensionPoint { ...providers: Array> ): void; addPlaceholderResolver(key: string, resolver: PlaceholderResolver): void; + setEntityDataParser(parser: CatalogProcessorParser): void; } /** From 7cfcddd9aaf3828c854799e5cc3635abba2708cc Mon Sep 17 00:00:00 2001 From: secustor Date: Thu, 18 Jan 2024 00:00:44 +0100 Subject: [PATCH 002/176] feat: throw error if extensions point tries to set data parser multiple times Signed-off-by: secustor --- plugins/catalog-backend/src/service/CatalogPlugin.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 10ca57d7e1..91d3fa0ecc 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -42,7 +42,7 @@ class CatalogProcessingExtensionPointImpl #processors = new Array(); #entityProviders = new Array(); #placeholderResolvers: Record = {}; - entityDataParser: CatalogProcessorParser = defaultEntityDataParser; + entityDataParser?: CatalogProcessorParser; addProcessor( ...processors: Array> @@ -65,6 +65,11 @@ class CatalogProcessingExtensionPointImpl } setEntityDataParser(parser: CatalogProcessorParser): void { + if (this.entityDataParser) { + throw new Error( + 'Attempted to install second EntityDataParser. Only one can be set.', + ); + } this.entityDataParser = parser; } @@ -171,7 +176,9 @@ export const catalogPlugin = createBackendPlugin({ }); builder.addProcessor(...processingExtensions.processors); builder.addEntityProvider(...processingExtensions.entityProviders); - builder.setEntityDataParser(processingExtensions.entityDataParser); + builder.setEntityDataParser( + processingExtensions.entityDataParser ?? defaultEntityDataParser, + ); Object.entries(processingExtensions.placeholderResolvers).forEach( ([key, resolver]) => builder.setPlaceholderResolver(key, resolver), From c66fce65c43428c1944b2a1ac35db5840f6722f2 Mon Sep 17 00:00:00 2001 From: secustor Date: Tue, 23 Jan 2024 16:41:16 +0100 Subject: [PATCH 003/176] refactor(catalog): use private field and rely on default parser provided by build() Signed-off-by: secustor --- .../src/service/CatalogPlugin.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 91d3fa0ecc..c65dadafae 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -34,7 +34,6 @@ import { } from '@backstage/plugin-catalog-node'; import { loggerToWinstonLogger } from '@backstage/backend-common'; import { PlaceholderResolver } from '../modules'; -import { defaultEntityDataParser } from '../modules/util/parse'; class CatalogProcessingExtensionPointImpl implements CatalogProcessingExtensionPoint @@ -42,7 +41,7 @@ class CatalogProcessingExtensionPointImpl #processors = new Array(); #entityProviders = new Array(); #placeholderResolvers: Record = {}; - entityDataParser?: CatalogProcessorParser; + #entityDataParser?: CatalogProcessorParser; addProcessor( ...processors: Array> @@ -65,12 +64,12 @@ class CatalogProcessingExtensionPointImpl } setEntityDataParser(parser: CatalogProcessorParser): void { - if (this.entityDataParser) { + if (this.#entityDataParser) { throw new Error( 'Attempted to install second EntityDataParser. Only one can be set.', ); } - this.entityDataParser = parser; + this.#entityDataParser = parser; } get processors() { @@ -84,6 +83,10 @@ class CatalogProcessingExtensionPointImpl get placeholderResolvers() { return this.#placeholderResolvers; } + + get entityDataParser() { + return this.#entityDataParser; + } } class CatalogAnalysisExtensionPointImpl @@ -176,9 +179,10 @@ export const catalogPlugin = createBackendPlugin({ }); builder.addProcessor(...processingExtensions.processors); builder.addEntityProvider(...processingExtensions.entityProviders); - builder.setEntityDataParser( - processingExtensions.entityDataParser ?? defaultEntityDataParser, - ); + + if (processingExtensions.entityDataParser) { + builder.setEntityDataParser(processingExtensions.entityDataParser); + } Object.entries(processingExtensions.placeholderResolvers).forEach( ([key, resolver]) => builder.setPlaceholderResolver(key, resolver), From 4fb960003a5fac08d1c6bb5136c6fd575e373f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Mon, 12 Feb 2024 11:14:52 +0100 Subject: [PATCH 004/176] Parameterize LinguistCard title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- .changeset/lazy-terms-shake.md | 5 +++++ plugins/linguist/README.md | 6 ++++++ .../linguist/src/components/LinguistCard/LinguistCard.tsx | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .changeset/lazy-terms-shake.md diff --git a/.changeset/lazy-terms-shake.md b/.changeset/lazy-terms-shake.md new file mode 100644 index 0000000000..41f5292709 --- /dev/null +++ b/.changeset/lazy-terms-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-linguist': minor +--- + +Allow to optionally pass component's title as LinguistCard parameter diff --git a/plugins/linguist/README.md b/plugins/linguist/README.md index 7ad70f1db6..72855c0894 100644 --- a/plugins/linguist/README.md +++ b/plugins/linguist/README.md @@ -79,6 +79,12 @@ To setup the Linguist Card frontend you'll need to do the following steps: ``` +3. (optionally) Set component's title - default is "Languages" + + ```tsx + + ``` + **Notes:** - The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx index 6a07a958c2..3b63abd406 100644 --- a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -55,7 +55,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const LinguistCard = () => { +export const LinguistCard = ({ title = 'Languages' }) => { const classes = useStyles(); const theme = useTheme(); const { entity } = useEntity(); @@ -70,7 +70,7 @@ export const LinguistCard = () => { if (items && items.languageCount === 0 && items.totalBytes === 0) { return ( - + From 01fff3ff1c895ebad2d49224e7547e332bc9eea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Mon, 12 Feb 2024 11:44:29 +0100 Subject: [PATCH 005/176] generated api reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- plugins/linguist/api-report.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/linguist/api-report.md b/plugins/linguist/api-report.md index 71e2460088..ca1ce3cdd2 100644 --- a/plugins/linguist/api-report.md +++ b/plugins/linguist/api-report.md @@ -10,7 +10,11 @@ import { Entity } from '@backstage/catalog-model'; import { JSX as JSX_2 } from 'react'; // @public (undocumented) -export const EntityLinguistCard: () => JSX_2.Element; +export const EntityLinguistCard: ({ + title, +}: { + title?: string | undefined; +}) => JSX_2.Element; // @public (undocumented) export const isLinguistAvailable: (entity: Entity) => boolean; From e0bb6bfd5c43c79c9cf20d936e6a400e30aa695f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Mon, 12 Feb 2024 11:50:10 +0100 Subject: [PATCH 006/176] Update .changeset/lazy-terms-shake.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Philipp Hugenroth Signed-off-by: Piotr Piątkiewicz --- .changeset/lazy-terms-shake.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lazy-terms-shake.md b/.changeset/lazy-terms-shake.md index 41f5292709..62fd2d848e 100644 --- a/.changeset/lazy-terms-shake.md +++ b/.changeset/lazy-terms-shake.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-linguist': minor +'@backstage/plugin-linguist': patch --- Allow to optionally pass component's title as LinguistCard parameter From fe8e16df4a20c93c86722010585c7663ef23519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Mon, 12 Feb 2024 15:44:05 +0100 Subject: [PATCH 007/176] Get linguist component title from translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- .changeset/lazy-terms-shake.md | 2 +- plugins/linguist/README.md | 6 ----- plugins/linguist/api-report.md | 6 +---- .../components/LinguistCard/LinguistCard.tsx | 7 +++-- plugins/linguist/src/translation.ts | 26 +++++++++++++++++++ 5 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 plugins/linguist/src/translation.ts diff --git a/.changeset/lazy-terms-shake.md b/.changeset/lazy-terms-shake.md index 62fd2d848e..03c0fee21f 100644 --- a/.changeset/lazy-terms-shake.md +++ b/.changeset/lazy-terms-shake.md @@ -2,4 +2,4 @@ '@backstage/plugin-linguist': patch --- -Allow to optionally pass component's title as LinguistCard parameter +Get component's title from translation file diff --git a/plugins/linguist/README.md b/plugins/linguist/README.md index 72855c0894..7ad70f1db6 100644 --- a/plugins/linguist/README.md +++ b/plugins/linguist/README.md @@ -79,12 +79,6 @@ To setup the Linguist Card frontend you'll need to do the following steps: ``` -3. (optionally) Set component's title - default is "Languages" - - ```tsx - - ``` - **Notes:** - The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation diff --git a/plugins/linguist/api-report.md b/plugins/linguist/api-report.md index ca1ce3cdd2..71e2460088 100644 --- a/plugins/linguist/api-report.md +++ b/plugins/linguist/api-report.md @@ -10,11 +10,7 @@ import { Entity } from '@backstage/catalog-model'; import { JSX as JSX_2 } from 'react'; // @public (undocumented) -export const EntityLinguistCard: ({ - title, -}: { - title?: string | undefined; -}) => JSX_2.Element; +export const EntityLinguistCard: () => JSX_2.Element; // @public (undocumented) export const isLinguistAvailable: (entity: Entity) => boolean; diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx index 3b63abd406..95aa5e0d89 100644 --- a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -27,6 +27,8 @@ import React from 'react'; import slugify from 'slugify'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useLanguages } from '../../hooks'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { linguistTranslationRef } from '../../translation'; const useStyles = makeStyles(theme => ({ infoCard: { @@ -55,7 +57,8 @@ const useStyles = makeStyles(theme => ({ }, })); -export const LinguistCard = ({ title = 'Languages' }) => { +export const LinguistCard = () => { + const { t } = useTranslationRef(linguistTranslationRef); const classes = useStyles(); const theme = useTheme(); const { entity } = useEntity(); @@ -70,7 +73,7 @@ export const LinguistCard = ({ title = 'Languages' }) => { if (items && items.languageCount === 0 && items.totalBytes === 0) { return ( - + diff --git a/plugins/linguist/src/translation.ts b/plugins/linguist/src/translation.ts new file mode 100644 index 0000000000..78a34f2f7e --- /dev/null +++ b/plugins/linguist/src/translation.ts @@ -0,0 +1,26 @@ +/* + * 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export const linguistTranslationRef = createTranslationRef({ + id: 'linguist', + messages: { + component: { + title: 'Languages', + }, + }, +}); From 5bc5d5cf30fd328d96b42d772b9330777f420911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Mon, 12 Feb 2024 16:52:55 +0100 Subject: [PATCH 008/176] Export translation in linguist package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- plugins/linguist/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/linguist/package.json b/plugins/linguist/package.json index 4d308b2f73..dbc5da2d31 100644 --- a/plugins/linguist/package.json +++ b/plugins/linguist/package.json @@ -10,7 +10,8 @@ "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha.ts", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./src/translation": "./src/translation.ts" }, "typesVersions": { "*": { From 8d3734b42af16f1322d37ce26da7265eebc52f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Tue, 13 Feb 2024 08:37:06 +0100 Subject: [PATCH 009/176] fix translation import, added one more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- .changeset/lazy-terms-shake.md | 2 +- plugins/linguist/api-report-alpha.md | 10 ++++++++++ plugins/linguist/package.json | 3 +-- plugins/linguist/src/alpha.ts | 1 + .../src/components/LinguistCard/LinguistCard.tsx | 4 +--- plugins/linguist/src/translation.ts | 1 + 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.changeset/lazy-terms-shake.md b/.changeset/lazy-terms-shake.md index 03c0fee21f..efe89f1c12 100644 --- a/.changeset/lazy-terms-shake.md +++ b/.changeset/lazy-terms-shake.md @@ -2,4 +2,4 @@ '@backstage/plugin-linguist': patch --- -Get component's title from translation file +Get component's title from translation file. See: https://backstage.io/docs/plugins/internationalization#for-an-application-developer-overwrite-plugin-messages diff --git a/plugins/linguist/api-report-alpha.md b/plugins/linguist/api-report-alpha.md index 418439d331..4a250535b0 100644 --- a/plugins/linguist/api-report-alpha.md +++ b/plugins/linguist/api-report-alpha.md @@ -4,10 +4,20 @@ ```ts import { BackstagePlugin } from '@backstage/frontend-plugin-api'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) const _default: BackstagePlugin<{}, {}>; export default _default; +// @alpha (undocumented) +export const linguistTranslationRef: TranslationRef< + 'linguist', + { + readonly 'component.title': 'Languages'; + readonly 'component.noData': 'There is currently no language data for this entity.'; + } +>; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/linguist/package.json b/plugins/linguist/package.json index dbc5da2d31..4d308b2f73 100644 --- a/plugins/linguist/package.json +++ b/plugins/linguist/package.json @@ -10,8 +10,7 @@ "exports": { ".": "./src/index.ts", "./alpha": "./src/alpha.ts", - "./package.json": "./package.json", - "./src/translation": "./src/translation.ts" + "./package.json": "./package.json" }, "typesVersions": { "*": { diff --git a/plugins/linguist/src/alpha.ts b/plugins/linguist/src/alpha.ts index e80f131817..287775ade0 100644 --- a/plugins/linguist/src/alpha.ts +++ b/plugins/linguist/src/alpha.ts @@ -16,3 +16,4 @@ export * from './alpha/index'; export { default } from './alpha/index'; +export * from './translation'; diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx index 95aa5e0d89..3a07dd35e2 100644 --- a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -76,9 +76,7 @@ export const LinguistCard = () => { - - There is currently no language data for this entity. - + {t('component.noData')} diff --git a/plugins/linguist/src/translation.ts b/plugins/linguist/src/translation.ts index 78a34f2f7e..f971b70490 100644 --- a/plugins/linguist/src/translation.ts +++ b/plugins/linguist/src/translation.ts @@ -21,6 +21,7 @@ export const linguistTranslationRef = createTranslationRef({ messages: { component: { title: 'Languages', + noData: 'There is currently no language data for this entity.', }, }, }); From d1d602e6d971529160b45479396a561d22714590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Tue, 13 Feb 2024 10:39:44 +0100 Subject: [PATCH 010/176] updated translations keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- plugins/linguist/api-report-alpha.md | 4 ++-- plugins/linguist/src/components/LinguistCard/LinguistCard.tsx | 4 ++-- plugins/linguist/src/translation.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/linguist/api-report-alpha.md b/plugins/linguist/api-report-alpha.md index 4a250535b0..07d2244e30 100644 --- a/plugins/linguist/api-report-alpha.md +++ b/plugins/linguist/api-report-alpha.md @@ -14,8 +14,8 @@ export default _default; export const linguistTranslationRef: TranslationRef< 'linguist', { - readonly 'component.title': 'Languages'; - readonly 'component.noData': 'There is currently no language data for this entity.'; + readonly 'entityCard.title': 'Languages'; + readonly 'entityCard.noData': 'There is currently no language data for this entity.'; } >; diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx index 3a07dd35e2..19225f1471 100644 --- a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -73,10 +73,10 @@ export const LinguistCard = () => { if (items && items.languageCount === 0 && items.totalBytes === 0) { return ( - + - {t('component.noData')} + {t('entityCard.noData')} diff --git a/plugins/linguist/src/translation.ts b/plugins/linguist/src/translation.ts index f971b70490..7055d3dfe8 100644 --- a/plugins/linguist/src/translation.ts +++ b/plugins/linguist/src/translation.ts @@ -19,7 +19,7 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; export const linguistTranslationRef = createTranslationRef({ id: 'linguist', messages: { - component: { + entityCard: { title: 'Languages', noData: 'There is currently no language data for this entity.', }, From d8eea9b165220c609b4aae726cfd2b06a49259d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Pi=C4=85tkiewicz?= Date: Tue, 13 Feb 2024 12:22:31 +0100 Subject: [PATCH 011/176] added missing translation reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piotr Piątkiewicz --- plugins/linguist/src/components/LinguistCard/LinguistCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx index 19225f1471..213ca316ca 100644 --- a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -89,7 +89,7 @@ export const LinguistCard = () => { const processedDate = items?.processedDate; return breakdown && processedDate ? ( - + {breakdown.map((language, index: number) => { barWidth = barWidth + language.percentage; From c9e5b59f78db5f3bcdb6f3c3aeaf3723d63894ff Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 1 Feb 2024 16:26:14 +0100 Subject: [PATCH 012/176] errors: set statusCode in ResponseError Signed-off-by: Vincenzo Scamporlino --- .../errors/src/errors/ResponseError.test.ts | 2 ++ packages/errors/src/errors/ResponseError.ts | 26 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/errors/src/errors/ResponseError.test.ts b/packages/errors/src/errors/ResponseError.test.ts index 94f2e850d9..38db084b03 100644 --- a/packages/errors/src/errors/ResponseError.test.ts +++ b/packages/errors/src/errors/ResponseError.test.ts @@ -35,6 +35,8 @@ describe('ResponseError', () => { const e = await ResponseError.fromResponse(response as Response); expect(e.name).toEqual('ResponseError'); expect(e.message).toEqual('Request failed with 444 Fours'); + expect(e.statusCode).toEqual(444); + expect(e.statusText).toEqual('Fours'); expect(e.cause.name).toEqual('Fours'); expect(e.cause.message).toEqual('Expected fives'); expect(e.cause.stack).toEqual('lines'); diff --git a/packages/errors/src/errors/ResponseError.ts b/packages/errors/src/errors/ResponseError.ts index f8ba0a1ba1..b5ca1e4ab8 100644 --- a/packages/errors/src/errors/ResponseError.ts +++ b/packages/errors/src/errors/ResponseError.ts @@ -53,6 +53,9 @@ export class ResponseError extends Error { */ readonly cause: Error; + readonly statusCode: number; + + readonly statusText: string; /** * Constructs a ResponseError based on a failed response. * @@ -65,9 +68,9 @@ export class ResponseError extends Error { ): Promise { const data = await parseErrorResponseBody(response); - const status = data.response.statusCode || response.status; - const statusText = data.error.name || response.statusText; - const message = `Request failed with ${status} ${statusText}`; + const statusCode = data.response.statusCode || response.status; + const statusText = response.statusText; + const message = `Request failed with ${statusCode} ${statusText}`; const cause = deserializeError(data.error); return new ResponseError({ @@ -75,19 +78,26 @@ export class ResponseError extends Error { response, data, cause, + statusCode, + statusText, }); } - private constructor(props: { + private constructor(opts: { message: string; response: ConsumedResponse; data: ErrorResponseBody; cause: Error; + statusCode: number; + statusText: string; }) { - super(props.message); + super(opts.message); + this.name = 'ResponseError'; - this.response = props.response; - this.body = props.data; - this.cause = props.cause; + this.response = opts.response; + this.body = opts.data; + this.cause = opts.cause; + this.statusCode = opts.statusCode; + this.statusText = opts.statusText; } } From b4cb0085b9ca7de247870f7d5b23f2ec4b4549ac Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 1 Feb 2024 16:26:29 +0100 Subject: [PATCH 013/176] backend-common: test for ResponseError Signed-off-by: Vincenzo Scamporlino --- .../src/middleware/errorHandler.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index 3cc473daa0..ab07a91022 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -21,11 +21,13 @@ import { NotAllowedError, NotFoundError, NotModifiedError, + ResponseError, } from '@backstage/errors'; import express from 'express'; import createError from 'http-errors'; import request from 'supertest'; import { errorHandler } from './errorHandler'; +import { STATUS_CODES } from 'http'; describe('errorHandler', () => { it('gives default code and message', async () => { @@ -116,6 +118,53 @@ describe('errorHandler', () => { app.use('/ConflictError', () => { throw new ConflictError(); }); + app.use('/ResponseErrorBackstagePlugin', async (_req, _res, next) => { + const mockedResponse = { + status: jest.fn(() => mockedResponse), + json: jest.fn(() => mockedResponse), + } as unknown as jest.Mocked; + + // serialize AuthenticationError in mockedResponse + errorHandler()( + new AuthenticationError('an error'), + { method: 'GET', url: '' } as express.Request, + mockedResponse, + jest.fn(), + ); + + const status = mockedResponse.status.mock.calls[0][0]; + next( + await ResponseError.fromResponse({ + headers: new Headers({ + 'content-type': 'application/json', + }), + ok: false, + redirected: false, + status, + statusText: STATUS_CODES[status]!, + type: 'default', + url: '', + text: async () => + JSON.stringify(mockedResponse.json.mock.calls[0][0]), + }), + ); + }); + app.use('/ResponseError', async (_req, _res, next) => { + next( + await ResponseError.fromResponse({ + headers: new Headers({ + 'content-type': 'application/json', + }), + ok: false, + redirected: false, + status: 403, + statusText: STATUS_CODES[403]!, + type: 'default', + url: '', + text: async () => JSON.stringify({}), + }), + ); + }); app.use(errorHandler()); const r = request(app); @@ -138,6 +187,14 @@ describe('errorHandler', () => { expect((await r.get('/ConflictError')).body.error.name).toBe( 'ConflictError', ); + expect((await r.get('/ResponseErrorBackstagePlugin')).status).toBe(401); + expect((await r.get('/ResponseErrorBackstagePlugin')).body.error.name).toBe( + 'ResponseError', + ); + expect((await r.get('/ResponseError')).status).toBe(403); + expect((await r.get('/ResponseError')).body.error.name).toBe( + 'ResponseError', + ); }); it('logs all 500 errors', async () => { From 2636075b2fe50cbca9d3c7f3752f4e35eeb149ab Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 1 Feb 2024 18:16:25 +0100 Subject: [PATCH 014/176] ResponseError changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/cyan-dryers-share.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cyan-dryers-share.md diff --git a/.changeset/cyan-dryers-share.md b/.changeset/cyan-dryers-share.md new file mode 100644 index 0000000000..4c1ce1d553 --- /dev/null +++ b/.changeset/cyan-dryers-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/errors': patch +--- + +Fixed an issue that was causing ResponseError not to report the HTTP status from the provided response. From f4cf3f3dd4a28cf567e0c3c383f66804bda92f6c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:17:31 +0100 Subject: [PATCH 015/176] catalog-client: fix error message Signed-off-by: Vincenzo Scamporlino --- packages/catalog-client/src/CatalogClient.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index c36b3d96b5..81c0a6af5c 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -761,7 +761,7 @@ describe('CatalogClient', () => { }, 'url:http://example.com', ), - ).rejects.toThrow(/Request failed with 500 Error/); + ).rejects.toThrow(/Request failed with 500 Internal Server Error/); }); }); }); From 276781c2dd4fb52745dddf2e6a7a66294559ffed Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:25:01 +0100 Subject: [PATCH 016/176] vault: fix error message Signed-off-by: Vincenzo Scamporlino --- plugins/vault/src/api.test.ts | 2 +- .../src/components/EntityVaultTable/EntityVaultTable.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/vault/src/api.test.ts b/plugins/vault/src/api.test.ts index 7ecd09b6f4..777137d0e7 100644 --- a/plugins/vault/src/api.test.ts +++ b/plugins/vault/src/api.test.ts @@ -110,7 +110,7 @@ describe('api', () => { it('should throw an error if the Vault API responds with a non-successful HTTP status code', async () => { await expect(api.listSecrets('test/error')).rejects.toThrow( - 'Request failed with 400 Error', + 'Request failed with 400 Bad Request', ); }); }); diff --git a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx index 5e191df9eb..14963ad13b 100644 --- a/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx +++ b/plugins/vault/src/components/EntityVaultTable/EntityVaultTable.test.tsx @@ -161,7 +161,7 @@ describe('EntityVaultTable', () => { expect( rendered.getByText( - /Unexpected error while fetching secrets from path \'test\/error\'\: Request failed with 400 Error/, + /Unexpected error while fetching secrets from path \'test\/error\'\: Request failed with 400 Bad Request/, ), ).toBeInTheDocument(); }); From 8a3932ffe8a93e3ca393e60d988a95d7ee80f24d Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:25:15 +0100 Subject: [PATCH 017/176] vault: fix warning in test Signed-off-by: Vincenzo Scamporlino --- .../EntityVaultCard/EntityVaultCard.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx index 9450ddc41d..ec62ea8757 100644 --- a/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx +++ b/plugins/vault/src/components/EntityVaultCard/EntityVaultCard.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/test-utils'; import { ComponentEntity } from '@backstage/catalog-model'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { EntityVaultCard } from './EntityVaultCard'; import { EntityProvider } from '@backstage/plugin-catalog-react'; @@ -45,8 +45,11 @@ describe('EntityVaultCard', () => { , ); - expect( - rendered.getByText(/Add the annotation to your Component YAML/), - ).toBeInTheDocument(); + + await waitFor(() => + expect( + rendered.getByText(/Add the annotation to your Component YAML/), + ).toBeInTheDocument(), + ); }); }); From b354046dadfe15e805ec45080406f26453e2ad6a Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:26:10 +0100 Subject: [PATCH 018/176] scaffolder-backend-module-confluence-to-markdown: fix error message Signed-off-by: Vincenzo Scamporlino --- .../src/actions/confluence/confluenceToMarkdown.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts index 5ce8bb2697..39c9c98544 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts @@ -221,7 +221,7 @@ describe('confluence:transform:markdown', () => { const action = createConfluenceToMarkdownAction(options); await expect(async () => { await action.handler(mockContext); - }).rejects.toThrow('Request failed with 401 Error'); + }).rejects.toThrow('Request failed with 401 nope'); }); it('should return nothing in results from the first api call and fail', async () => { @@ -284,6 +284,6 @@ describe('confluence:transform:markdown', () => { const action = createConfluenceToMarkdownAction(options); await expect(async () => { await action.handler(mockContext); - }).rejects.toThrow('Request failed with 404 Error'); + }).rejects.toThrow('Request failed with 404 nope'); }); }); From 20340074c47bad515df547798f0b5e6df1585d20 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:26:27 +0100 Subject: [PATCH 019/176] core-components: fix error text Signed-off-by: Vincenzo Scamporlino --- .../src/layout/ProxiedSignInPage/ProxiedSignInPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.test.tsx b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.test.tsx index 3f0c01b56b..05960cd213 100644 --- a/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.test.tsx +++ b/packages/core-components/src/layout/ProxiedSignInPage/ProxiedSignInPage.test.tsx @@ -97,7 +97,7 @@ describe('ProxiedSignInPage', () => { render(Subject); await expect( - screen.findByText('Request failed with 401 Error'), + screen.findByText('Request failed with 401 Unauthorized'), ).resolves.toBeInTheDocument(); }); }); From 406c5675a12f277984874e12a154f541c986ce09 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 15 Feb 2024 13:41:55 +0100 Subject: [PATCH 020/176] errors: api report Signed-off-by: Vincenzo Scamporlino --- packages/errors/api-report.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 3ece63035b..894aac8413 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -128,6 +128,10 @@ export class ResponseError extends Error { }, ): Promise; readonly response: ConsumedResponse; + // (undocumented) + readonly statusCode: number; + // (undocumented) + readonly statusText: string; } // @public From 8b7d574c3dffbd5286bb8eb530c3eef2328aa11e Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 9 Feb 2024 19:24:37 -0600 Subject: [PATCH 021/176] Unified Theme Docs Signed-off-by: Andre Wanlin --- docs/getting-started/app-custom-theme.md | 321 ++++++++++-------- packages/theme/api-report.md | 3 + .../theme/src/base/createBaseThemeOptions.ts | 76 +++-- packages/theme/src/base/index.ts | 5 +- 4 files changed, 222 insertions(+), 183 deletions(-) diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index d6a13ca6e3..c70451df45 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -4,54 +4,38 @@ title: Customize the look-and-feel of your App description: Documentation on Customizing look and feel of the App --- -Backstage ships with a default theme with a light and dark mode variant. The -themes are provided as a part of the -[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package, -which also includes utilities for customizing the default theme, or creating -completely new themes. +Backstage ships with a default theme with a light and dark mode variant. The themes are provided as a part of the [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package, which also includes utilities for customizing the default theme, or creating completely new themes. ## Creating a Custom Theme -The easiest way to create a new theme is to use the `createTheme` function -exported by the -[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package. You -can use it to override some basic parameters of the default theme such as the -color palette and font. +The easiest way to create a new theme is to use the `createUnifiedTheme` function exported by the [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) package. You can use it to override some basic parameters of the default theme such as the color palette and font. -For example, you can create a new theme based on the default light theme like -this: +For example, you can create a new theme based on the default light theme like this: ```ts -import { createTheme, lightTheme } from '@backstage/theme'; +import { + createBaseThemeOptions, + createUnifiedTheme, + palettes, +} from '@backstage/theme'; -const myTheme = createTheme({ - palette: lightTheme.palette, +const myTheme = createUnifiedTheme({ + ...createBaseThemeOptions({ + palette: palettes.light, + }), fontFamily: 'Comic Sans MS', defaultPageTheme: 'home', }); ``` -If you want more control over the theme, and for example customize font sizes -and margins, you can use the lower-level `createThemeOverrides` function -exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme) -in combination with -[`createTheme`](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme) -from [`@material-ui/core`](https://www.npmjs.com/package/@material-ui/core). See -the "Overriding Backstage and Material UI css rules" section below. - -You can also create a theme from scratch that matches the `BackstageTheme` type -exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). -See the -[Material UI docs on theming](https://material-ui.com/customization/theming/) -for more information about how that can be done. +You can also create a theme from scratch that matches the `BackstageTheme` type exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). See the +[Material UI docs on theming](https://material-ui.com/customization/theming/) for more information about how that can be done. ## Using your Custom Theme -To add a custom theme to your Backstage app, you pass it as configuration to -`createApp`. +To add a custom theme to your Backstage app, you pass it as configuration to `createApp`. -For example, adding the theme that we created in the previous section can be -done like this: +For example, adding the theme that we created in the previous section can be done like this: ```tsx import { createApp } from '@backstage/app-defaults'; @@ -68,70 +52,68 @@ const app = createApp({ variant: 'light', icon: , Provider: ({ children }) => ( - - {children} - + ), }] }) ``` -Note that your list of custom themes overrides the default themes. If you still -want to use the default themes, they are exported as `lightTheme` and -`darkTheme` from -[`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). +Note that your list of custom themes overrides the default themes. If you still want to use the default themes, they are exported as `themes.light` and `themes.light` from [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). ## Example of a custom theme ```ts import { - createTheme, + createBaseThemeOptions, + createUnifiedTheme, genPageTheme, - lightTheme, + palettes, shapes, } from '@backstage/theme'; -const myTheme = createTheme({ - palette: { - ...lightTheme.palette, - primary: { - main: '#343b58', +const myTheme = createUnifiedTheme({ + ...createBaseThemeOptions({ + palette: { + ...palettes.light, + primary: { + main: '#343b58', + }, + secondary: { + main: '#565a6e', + }, + error: { + main: '#8c4351', + }, + warning: { + main: '#8f5e15', + }, + info: { + main: '#34548a', + }, + success: { + main: '#485e30', + }, + background: { + default: '#d5d6db', + paper: '#d5d6db', + }, + banner: { + info: '#34548a', + error: '#8c4351', + text: '#343b58', + link: '#565a6e', + }, + errorBackground: '#8c4351', + warningBackground: '#8f5e15', + infoBackground: '#343b58', + navigation: { + background: '#343b58', + indicator: '#8f5e15', + color: '#d5d6db', + selectedColor: '#ffffff', + }, }, - secondary: { - main: '#565a6e', - }, - error: { - main: '#8c4351', - }, - warning: { - main: '#8f5e15', - }, - info: { - main: '#34548a', - }, - success: { - main: '#485e30', - }, - background: { - default: '#d5d6db', - paper: '#d5d6db', - }, - banner: { - info: '#34548a', - error: '#8c4351', - text: '#343b58', - link: '#565a6e', - }, - errorBackground: '#8c4351', - warningBackground: '#8f5e15', - infoBackground: '#343b58', - navigation: { - background: '#343b58', - indicator: '#8f5e15', - color: '#d5d6db', - selectedColor: '#ffffff', - }, - }, + }), defaultPageTheme: 'home', fontFamily: 'Comic Sans MS', /* below drives the header colors */ @@ -161,16 +143,92 @@ const myTheme = createTheme({ }); ``` -For a more complete example of a custom theme including Backstage and -Material UI component overrides, see the [Aperture -theme](https://github.com/backstage/demo/blob/master/packages/app/src/theme/aperture.ts) -from the [Backstage demo site](https://demo.backstage.io). +For a more complete example of a custom theme including Backstage and Material UI component overrides, see the [Aperture theme](https://github.com/backstage/demo/blob/master/packages/app/src/theme/aperture.ts) from the [Backstage demo site](https://demo.backstage.io). + +## Custom Typography + +When creating a custom theme you can also customize vairous aspexts of the default typography, here's an exampl using simplified theme: + +```tsx +import { + createBaseThemeOptions, + createUnifiedTheme, + palettes, +} from '@backstage/theme'; + +const myTheme = createUnifiedTheme({ + ...createBaseThemeOptions({ + palette: palettes.light, + typography: { + htmlFontSize: 16, + fontFamily: 'Arial, sans-serif', + h1: { + fontSize: 54, + fontWeight: 700, + marginBottom: 10, + }, + h2: { + fontSize: 40, + fontWeight: 700, + marginBottom: 8, + }, + h3: { + fontSize: 32, + fontWeight: 700, + marginBottom: 6, + }, + h4: { + fontWeight: 700, + fontSize: 28, + marginBottom: 6, + }, + h5: { + fontWeight: 700, + fontSize: 24, + marginBottom: 4, + }, + h6: { + fontWeight: 700, + fontSize: 20, + marginBottom: 2, + }, + }, + defaultPageTheme: 'home', + }), +}); +``` + +If you wanted to only override a sub-set of the typography setting, for example just `h1` then you would do this: + +```tsx +import { + createBaseThemeOptions, + createUnifiedTheme, + defaultTypography, + palettes, +} from '@backstage/theme'; + +const myTheme = createUnifiedTheme({ + ...createBaseThemeOptions({ + palette: palettes.light, + typography: { + ...defaultTypography, + htmlFontSize: 16, + fontFamily: 'Roboto, sans-serif', + h1: { + fontSize: 72, + fontWeight: 700, + marginBottom: 10, + }, + }, + defaultPageTheme: 'home', + }), +}); +``` ## Overriding Backstage and Material UI components styles -When creating a custom theme you would be applying different values to -component's css rules that use the theme object. For example, a Backstage -component's styles might look like this: +When creating a custom theme you would be applying different values to component's CSS rules that use the theme object. For example, a Backstage component's styles might look like this: ```tsx const useStyles = makeStyles( @@ -185,83 +243,50 @@ const useStyles = makeStyles( ); ``` -Notice how the `padding` is getting its value from `theme.spacing`, that means -that setting a value for spacing in your custom theme would affect this -component padding property and the same goes for `backgroundImage` which uses -`theme.page.backgroundImage`. However, the `boxShadow` property doesn't -reference any value from the theme, that means that creating a custom theme -wouldn't be enough to alter the `box-shadow` property or to add css rules that -aren't already defined like a margin. For these cases you should also create an -override. +Notice how the `padding` is getting its value from `theme.spacing`, that means that setting a value for spacing in your custom theme would affect this component padding property and the same goes for `backgroundImage` which uses `theme.page.backgroundImage`. However, the `boxShadow` property doesn't reference any value from the theme, that means that creating a custom theme wouldn't be enough to alter the `box-shadow` property or to add css rules that aren't already defined like a margin. For these cases you should also create an override. + +Here's how you would do that: ```tsx -import { createApp } from '@backstage/core-app-api'; -import { BackstageTheme, lightTheme } from '@backstage/theme'; -/** - * The `@backstage/core-components` package exposes this type that - * contains all Backstage and `material-ui` components that can be - * overridden along with the classes key those components use. - */ -import { BackstageOverrides } from '@backstage/core-components'; +import { + createBaseThemeOptions, + createUnifiedTheme, + palettes, +} from '@backstage/theme'; -export const createCustomThemeOverrides = ( - theme: BackstageTheme, -): BackstageOverrides => { - return { +const myTheme = createUnifiedTheme({ + ...createBaseThemeOptions({ + palette: palettes.light, + }), + fontFamily: 'Comic Sans MS', + defaultPageTheme: 'home', + components: { BackstageHeader: { - header: { - width: 'auto', - margin: '20px', - boxShadow: 'none', - borderBottom: `4px solid ${theme.palette.primary.main}`, + styleOverrides: { + header: ({ theme }) => ({ + width: 'auto', + margin: '20px', + boxShadow: 'none', + borderBottom: `4px solid ${theme.palette.primary.main}`, + }), }, }, - }; -}; - -const customTheme: BackstageTheme = { - ...lightTheme, - overrides: { - // These are the overrides that Backstage applies to `material-ui` components - ...lightTheme.overrides, - // These are your custom overrides, either to `material-ui` or Backstage components. - ...createCustomThemeOverrides(lightTheme), }, -}; - -const app = createApp({ - apis: ..., - plugins: ..., - themes: [{ - id: 'my-theme', - title: 'My Custom Theme', - variant: 'light', - Provider: ({ children }) => ( - - {children} - - ), - }] }); ``` ## Custom Logo -In addition to a custom theme, you can also customize the logo displayed at the -far top left of the site. +In addition to a custom theme, you can also customize the logo displayed at the far top left of the site. -In your frontend app, locate `src/components/Root/` folder. You'll find two -components: +In your frontend app, locate `src/components/Root/` folder. You'll find two components: - `LogoFull.tsx` - A larger logo used when the Sidebar navigation is opened. -- `LogoIcon.tsx` - A smaller logo used when the sidebar navigation is closed. +- `LogoIcon.tsx` - A smaller logo used when the Sidebar navigation is closed. -To replace the images, you can simply replace the relevant code in those -components with raw SVG definitions. +To replace the images, you can simply replace the relevant code in those components with raw SVG definitions. -You can also use another web image format such as PNG by importing it. To do -this, place your new image into a new subdirectory such as -`src/components/Root/logo/my-company-logo.png`, and then add this code: +You can also use another web image format such as PNG by importing it. To do this, place your new image into a new subdirectory such as `src/components/Root/logo/my-company-logo.png`, and then add this code: ```tsx import MyCustomLogoFull from './logo/my-company-logo.png'; diff --git a/packages/theme/api-report.md b/packages/theme/api-report.md index 21d6be6a78..988569ece7 100644 --- a/packages/theme/api-report.md +++ b/packages/theme/api-report.md @@ -205,6 +205,9 @@ export const darkTheme: Theme_3; // @public export const defaultComponentThemes: ThemeOptions['components']; +// @public +export const defaultTypography: BackstageTypography; + // @public export function genPageTheme(props: { colors: string[]; diff --git a/packages/theme/src/base/createBaseThemeOptions.ts b/packages/theme/src/base/createBaseThemeOptions.ts index f0c8ab5fe9..ab02af61c8 100644 --- a/packages/theme/src/base/createBaseThemeOptions.ts +++ b/packages/theme/src/base/createBaseThemeOptions.ts @@ -22,6 +22,46 @@ const DEFAULT_FONT_FAMILY = '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif'; const DEFAULT_PAGE_THEME = 'home'; +/** + * Default Typography settings. + * + * @public + */ +export const defaultTypography: BackstageTypography = { + htmlFontSize: DEFAULT_HTML_FONT_SIZE, + fontFamily: DEFAULT_FONT_FAMILY, + h1: { + fontSize: 54, + fontWeight: 700, + marginBottom: 10, + }, + h2: { + fontSize: 40, + fontWeight: 700, + marginBottom: 8, + }, + h3: { + fontSize: 32, + fontWeight: 700, + marginBottom: 6, + }, + h4: { + fontWeight: 700, + fontSize: 28, + marginBottom: 6, + }, + h5: { + fontWeight: 700, + fontSize: 24, + marginBottom: 4, + }, + h6: { + fontWeight: 700, + fontSize: 20, + marginBottom: 2, + }, +}; + /** * Options for {@link createBaseThemeOptions}. * @@ -57,40 +97,8 @@ export function createBaseThemeOptions( throw new Error(`${defaultPageTheme} is not defined in pageTheme.`); } - const defaultTypography: BackstageTypography = { - htmlFontSize, - fontFamily, - h1: { - fontSize: 54, - fontWeight: 700, - marginBottom: 10, - }, - h2: { - fontSize: 40, - fontWeight: 700, - marginBottom: 8, - }, - h3: { - fontSize: 32, - fontWeight: 700, - marginBottom: 6, - }, - h4: { - fontWeight: 700, - fontSize: 28, - marginBottom: 6, - }, - h5: { - fontWeight: 700, - fontSize: 24, - marginBottom: 4, - }, - h6: { - fontWeight: 700, - fontSize: 20, - marginBottom: 2, - }, - }; + defaultTypography.htmlFontSize = htmlFontSize; + defaultTypography.fontFamily = fontFamily; return { palette, diff --git a/packages/theme/src/base/index.ts b/packages/theme/src/base/index.ts index 2da9fb0fac..f7a6b2fd07 100644 --- a/packages/theme/src/base/index.ts +++ b/packages/theme/src/base/index.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -export { createBaseThemeOptions } from './createBaseThemeOptions'; +export { + createBaseThemeOptions, + defaultTypography, +} from './createBaseThemeOptions'; export type { BaseThemeOptionsInput } from './createBaseThemeOptions'; export { colorVariants, genPageTheme, pageTheme, shapes } from './pageTheme'; export { palettes } from './palettes'; From 6f4d2a0cbb6821af5f540126686aee5d391c5136 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 9 Feb 2024 19:26:32 -0600 Subject: [PATCH 022/176] Added changeset Signed-off-by: Andre Wanlin --- .changeset/fifty-moons-study.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fifty-moons-study.md diff --git a/.changeset/fifty-moons-study.md b/.changeset/fifty-moons-study.md new file mode 100644 index 0000000000..3744960870 --- /dev/null +++ b/.changeset/fifty-moons-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': patch +--- + +Exported `defaultTypography` to make adjusting these values in a custom theme easier From ee35f26d72bf1fd2737ab5b42ac161996e6588d4 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sat, 10 Feb 2024 14:49:12 -0600 Subject: [PATCH 023/176] Fixed typos Signed-off-by: Andre Wanlin --- docs/getting-started/app-custom-theme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index c70451df45..766b9ed1e8 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -147,7 +147,7 @@ For a more complete example of a custom theme including Backstage and Material U ## Custom Typography -When creating a custom theme you can also customize vairous aspexts of the default typography, here's an exampl using simplified theme: +When creating a custom theme you can also customize various aspects of the default typography, here's an example using simplified theme: ```tsx import { From 3e8a02947ddba8e74fb8e640c43f8e7be045405f Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Thu, 15 Feb 2024 08:25:53 -0600 Subject: [PATCH 024/176] Refinements based on recent Discord comments Signed-off-by: Andre Wanlin --- docs/getting-started/app-custom-theme.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 766b9ed1e8..6ce70be063 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -12,7 +12,7 @@ The easiest way to create a new theme is to use the `createUnifiedTheme` functio For example, you can create a new theme based on the default light theme like this: -```ts +```ts title="packages/app/src/theme/myTheme.ts" import { createBaseThemeOptions, createUnifiedTheme, @@ -28,6 +28,8 @@ const myTheme = createUnifiedTheme({ }); ``` +> Note: we recommend creating a `theme` folder in `packages/app/src` to place your theme file to keep things nicely organized. + You can also create a theme from scratch that matches the `BackstageTheme` type exported by [`@backstage/theme`](https://www.npmjs.com/package/@backstage/theme). See the [Material UI docs on theming](https://material-ui.com/customization/theming/) for more information about how that can be done. @@ -37,7 +39,7 @@ To add a custom theme to your Backstage app, you pass it as configuration to `cr For example, adding the theme that we created in the previous section can be done like this: -```tsx +```tsx title="packages/app/src/App.tsx" import { createApp } from '@backstage/app-defaults'; import { ThemeProvider } from '@material-ui/core/styles'; import CssBaseline from '@material-ui/core/CssBaseline'; @@ -62,7 +64,7 @@ Note that your list of custom themes overrides the default themes. If you still ## Example of a custom theme -```ts +```ts title="packages/app/src/theme/myTheme.ts" import { createBaseThemeOptions, createUnifiedTheme, @@ -149,7 +151,7 @@ For a more complete example of a custom theme including Backstage and Material U When creating a custom theme you can also customize various aspects of the default typography, here's an example using simplified theme: -```tsx +```ts title="packages/app/src/theme/myTheme.ts" import { createBaseThemeOptions, createUnifiedTheme, @@ -200,7 +202,7 @@ const myTheme = createUnifiedTheme({ If you wanted to only override a sub-set of the typography setting, for example just `h1` then you would do this: -```tsx +```ts title="packages/app/src/theme/myTheme.ts" import { createBaseThemeOptions, createUnifiedTheme, @@ -247,7 +249,7 @@ Notice how the `padding` is getting its value from `theme.spacing`, that means t Here's how you would do that: -```tsx +```ts title="packages/app/src/theme/myTheme.ts" import { createBaseThemeOptions, createUnifiedTheme, @@ -433,7 +435,7 @@ For this example we'll show you how you can expand the sidebar with a sub-menu: 3. Then update the `@backstage/core-components` import like this: - ```tsx + ```tsx title="packages/app/src/components/Root/Root.tsx" import { Sidebar, sidebarConfig, @@ -455,7 +457,7 @@ For this example we'll show you how you can expand the sidebar with a sub-menu: 4. Finally replace `` with this: - ```tsx + ```tsx title="packages/app/src/components/Root/Root.tsx" Date: Thu, 15 Feb 2024 18:17:15 +0000 Subject: [PATCH 025/176] Added Azure Devops Scopes to scm api Signed-off-by: Phill Morton --- .changeset/forty-oranges-joke.md | 5 +++++ packages/integration-react/src/api/ScmAuth.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/forty-oranges-joke.md diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md new file mode 100644 index 0000000000..b2fdaccdc1 --- /dev/null +++ b/.changeset/forty-oranges-joke.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration-react': patch +--- + +Updated azure devops scopes to include the clientid for Azure Dev Ops OAuth. diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index b3dab4029e..a22b15e400 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -199,13 +199,13 @@ export class ScmAuth implements ScmAuthApi { const host = options?.host ?? 'dev.azure.com'; return new ScmAuth('azure', microsoftAuthApi, host, { default: [ - 'vso.build', - 'vso.code', - 'vso.graph', - 'vso.project', - 'vso.profile', + '499b84ac-1321-427f-aa17-267ca6975798/vso.build', + '499b84ac-1321-427f-aa17-267ca6975798/vso.code', + '499b84ac-1321-427f-aa17-267ca6975798/vso.graph', + '499b84ac-1321-427f-aa17-267ca6975798/vso.project', + '499b84ac-1321-427f-aa17-267ca6975798/vso.profile', ], - repoWrite: ['vso.code_manage'], + repoWrite: ['499b84ac-1321-427f-aa17-267ca6975798/vso.code_manage'], }); } From b38dc5591a924a4614c714c5ee7d16d7fd27ff1a Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 15 Feb 2024 18:17:15 +0000 Subject: [PATCH 026/176] Added Azure Devops Scopes to scm api Signed-off-by: Phill Morton --- .changeset/forty-oranges-joke.md | 5 +++++ packages/integration-react/src/api/ScmAuth.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/forty-oranges-joke.md diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md new file mode 100644 index 0000000000..b2fdaccdc1 --- /dev/null +++ b/.changeset/forty-oranges-joke.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration-react': patch +--- + +Updated azure devops scopes to include the clientid for Azure Dev Ops OAuth. diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index b3dab4029e..a22b15e400 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -199,13 +199,13 @@ export class ScmAuth implements ScmAuthApi { const host = options?.host ?? 'dev.azure.com'; return new ScmAuth('azure', microsoftAuthApi, host, { default: [ - 'vso.build', - 'vso.code', - 'vso.graph', - 'vso.project', - 'vso.profile', + '499b84ac-1321-427f-aa17-267ca6975798/vso.build', + '499b84ac-1321-427f-aa17-267ca6975798/vso.code', + '499b84ac-1321-427f-aa17-267ca6975798/vso.graph', + '499b84ac-1321-427f-aa17-267ca6975798/vso.project', + '499b84ac-1321-427f-aa17-267ca6975798/vso.profile', ], - repoWrite: ['vso.code_manage'], + repoWrite: ['499b84ac-1321-427f-aa17-267ca6975798/vso.code_manage'], }); } From 61f7a1911b36dd523a27297a5210f9093441b4a2 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Thu, 15 Feb 2024 21:04:09 +0100 Subject: [PATCH 027/176] fixed Azure DevOps ADR file path Signed-off-by: Deepankumar Loganathan --- plugins/adr/src/components/AdrReader/AdrReader.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx index 6b07eee4cd..f41b1b1fdb 100644 --- a/plugins/adr/src/components/AdrReader/AdrReader.tsx +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -45,8 +45,18 @@ export const AdrReader = (props: { const scmIntegrations = useApi(scmIntegrationsApiRef); const adrApi = useApi(adrApiRef); const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations); + let url = `${adrLocationUrl.replace(/\/$/, '')}`; + const adrUrlPath = url.match(/path=\/.*\&/); + if (adrUrlPath) { + // Azure DevOps SCM handle the path in URL Params + const adrPath = adrUrlPath![0].replace(/\&$/, ''); + const regex = new RegExp(`${adrPath}`); + url = url.replace(regex, `${adrPath}/${adr}}`); + } else { + // Other SCM tools + url = `${url}/${adr}`; + } - const url = `${adrLocationUrl.replace(/\/$/, '')}/${adr}`; const { value, loading, error } = useAsync( async () => adrApi.readAdr(url), [url], From 533563474d98fecd3615fd2f2442a0aea112f3ab Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Fri, 16 Feb 2024 09:35:35 +0100 Subject: [PATCH 028/176] changeset updated Signed-off-by: Deepankumar Loganathan --- .changeset/clever-eagles-boil.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-eagles-boil.md diff --git a/.changeset/clever-eagles-boil.md b/.changeset/clever-eagles-boil.md new file mode 100644 index 0000000000..6e69d2db2f --- /dev/null +++ b/.changeset/clever-eagles-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr': patch +--- + +Fixed Azure DevOps ADR file path From 44c817ae23fb3887c5647008ef381619505238a7 Mon Sep 17 00:00:00 2001 From: Aramis Date: Fri, 16 Feb 2024 18:19:45 -0500 Subject: [PATCH 029/176] chore: update my account for contributions Signed-off-by: Aramis --- OWNERS.md | 8 ++++---- scripts/check-docs-quality.js | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/OWNERS.md b/OWNERS.md index 1e92e68c40..f955a9de11 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -114,9 +114,9 @@ Team: @backstage/openapi-tooling-maintainers Scope: Tooling for frontend and backend schema-first OpenAPI development. -| Name | Organization | GitHub | Discord | -| -------------- | ------------ | --------------------------------------- | ------------- | -| Aramis Sennyey | | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` | +| Name | Organization | GitHub | Discord | +| -------------- | ------------ | ----------------------------------------------------- | ------------- | +| Aramis Sennyey | | [aramissennyeydd](https://github.com/aramissennyeydd) | `Aramis#7984` | ### Scaffolder @@ -144,7 +144,7 @@ Scope: The Scaffolder frontend and backend plugins, and related tooling. | Alex Crome | | [afscrome](https://github.com/afscrome) | `afscrome` | | Andre Wanlin | Spotify | [awanlin](https://github.com/awanlin) | `ahhhndre` | | Andrew Thauer | Wealthsimple | [andrewthauer](https://github.com/andrewthauer) | `andrewthauer#3060` | -| Aramis Sennyey | | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` | +| Aramis Sennyey | | [aramissennyeydd](https://github.com/aramissennyeydd) | `Aramis#7984` | | Brian Fletcher | Roadie.io | [punkle](https://github.com/punkle) | `Brian Fletcher#7051` | | Carlos Esteban Lopez Jaramillo | VMWare | [luchillo17](https://github.com/luchillo17) | `luchillo17#8777` | | David Tuite | Roadie.io | [dtuite](https://github.com/dtuite) | `David Tuite (roadie.io)#1010` | diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index 119dd6bef4..9a9336b3e9 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -32,7 +32,11 @@ const IGNORED_WHEN_LISTING = [ /^docs[/\\]reference[/\\]/, ]; -const IGNORED_WHEN_EXPLICIT = [/^.*[/\\]knip-report\.md$/]; +const IGNORED_WHEN_EXPLICIT = [ + /^ADOPTERS\.md$/, + /^OWNERS\.md$/, + /^.*[/\\]knip-report\.md$/, +]; const rootDir = resolvePath(__dirname, '..'); @@ -120,7 +124,6 @@ async function main() { const relativePaths = absolutePaths .map(path => relativePath(rootDir, path)) .filter(path => !IGNORED_WHEN_EXPLICIT.some(pattern => pattern.test(path))); - const success = await runVale( relativePaths.length === 0 ? await listFiles() : relativePaths, ); From 3e93fe1bd4ce0776fd517fc2eed232592d707afe Mon Sep 17 00:00:00 2001 From: Jack Murray Date: Tue, 23 Jan 2024 10:56:53 +0000 Subject: [PATCH 030/176] add a failing test to repro the problem Signed-off-by: Jack Murray <115712715+jackmtpt@users.noreply.github.com> --- ...cs_with_additional_plugins_with_config.yml | 9 ++++++ .../src/stages/generate/helpers.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 plugins/techdocs-node/src/stages/generate/__fixtures__/mkdocs_with_additional_plugins_with_config.yml diff --git a/plugins/techdocs-node/src/stages/generate/__fixtures__/mkdocs_with_additional_plugins_with_config.yml b/plugins/techdocs-node/src/stages/generate/__fixtures__/mkdocs_with_additional_plugins_with_config.yml new file mode 100644 index 0000000000..6c945162d7 --- /dev/null +++ b/plugins/techdocs-node/src/stages/generate/__fixtures__/mkdocs_with_additional_plugins_with_config.yml @@ -0,0 +1,9 @@ +site_name: Test site name +site_description: Test site description +docs_dir: docs/ +plugins: + - not-techdocs-core + - also-not-techdocs-core + - custom-plugin: + with: + configuration: 1 diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 24d9b3effb..1b47cfb05c 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -81,6 +81,12 @@ const mkdocsYmlWithoutPlugins = fs.readFileSync( const mkdocsYmlWithAdditionalPlugins = fs.readFileSync( resolvePath(__filename, '../__fixtures__/mkdocs_with_additional_plugins.yml'), ); +const mkdocsYmlWithAdditionalPluginsWithConfig = fs.readFileSync( + resolvePath( + __filename, + '../__fixtures__/mkdocs_with_additional_plugins_with_config.yml', + ), +); const mkdocsYmlWithEnvTag = fs.readFileSync( resolvePath(__filename, '../__fixtures__/mkdocs_with_env_tag.yml'), ); @@ -321,6 +327,8 @@ describe('helpers', () => { 'mkdocs_with_techdocs_plugin.yml': mkdocsYmlWithTechdocsPlugins, 'mkdocs_without_plugins.yml': mkdocsYmlWithoutPlugins, 'mkdocs_with_additional_plugins.yml': mkdocsYmlWithAdditionalPlugins, + 'mkdocs_with_additional_plugins_with_config.yml': + mkdocsYmlWithAdditionalPluginsWithConfig, }); }); it('should not add additional plugins if techdocs exists already in mkdocs file', async () => { @@ -386,6 +394,28 @@ describe('helpers', () => { expect(parsedYml.plugins).toContain('techdocs-core'); expect(parsedYml.plugins).toContain('custom-plugin'); }); + it('should not overwrite config when defaults are added', async () => { + await patchMkdocsYmlWithPlugins( + mockDir.resolve('mkdocs_with_additional_plugins_with_config.yml'), + mockLogger, + ['techdocs-core', 'custom-plugin'], + ); + + const updatedMkdocsYml = await fs.readFile( + mockDir.resolve('mkdocs_with_additional_plugins_with_config.yml'), + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + plugins: object[]; + }; + expect(parsedYml.plugins).toHaveLength(4); + expect(parsedYml.plugins).toContain('techdocs-core'); + // we want our original object with its properties to be preserved, and for the basic string form of the plugin + // to NOT be added as well. + expect(parsedYml.plugins).not.toContain('custom-plugin'); + expect(parsedYml.plugins).toContainEqual({ + 'custom-plugin': { with: { configuration: 1 } }, + }); + }); }); describe('patchIndexPreBuild', () => { From bdbbd096a681ef20264b6710849905e794e55560 Mon Sep 17 00:00:00 2001 From: Jack Murray Date: Tue, 23 Jan 2024 12:38:29 +0000 Subject: [PATCH 031/176] rewrite patchMkdocsYmlWithPlugins to correctly only merge in all default plugins that are missing Signed-off-by: Jack Murray <115712715+jackmtpt@users.noreply.github.com> --- .../src/stages/generate/mkdocsPatchers.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts index 945aab3884..762a1c267a 100644 --- a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts +++ b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts @@ -153,21 +153,30 @@ export const patchMkdocsYmlWithPlugins = async ( defaultPlugins: string[] = ['techdocs-core'], ) => { await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => { - // Modify mkdocs.yaml to contain the required default plugins + // Modify mkdocs.yaml to contain the required default plugins. + // If no plugins are defined we can just return the defaults. if (!('plugins' in mkdocsYml)) { mkdocsYml.plugins = defaultPlugins; return true; } - if ( - mkdocsYml.plugins && - !defaultPlugins.every(plugin => mkdocsYml.plugins!.includes(plugin)) - ) { - mkdocsYml.plugins = [ - ...new Set([...mkdocsYml.plugins, ...defaultPlugins]), - ]; - return true; - } - return false; + // Otherwise, check each default plugin and include it if necessary. + let changesMade = false; + + defaultPlugins.forEach(dp => { + // if the plugin isn't there as a string, and isn't there as an object (which may itself contain extra config) + // then we need to add it + if ( + !( + mkdocsYml.plugins!.includes(dp) || + mkdocsYml.plugins!.some(p => p.hasOwnProperty(dp)) + ) + ) { + mkdocsYml.plugins = [...new Set([...mkdocsYml.plugins!, dp])]; + changesMade = true; + } + }); + + return changesMade; }); }; From 5b4f565a1c68801158b61f974e53f134b13b2039 Mon Sep 17 00:00:00 2001 From: Jack Murray <115712715+jackmtpt@users.noreply.github.com> Date: Mon, 19 Feb 2024 11:35:52 +0000 Subject: [PATCH 032/176] add changeset Signed-off-by: Jack Murray <115712715+jackmtpt@users.noreply.github.com> --- .changeset/fresh-rings-tell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-rings-tell.md diff --git a/.changeset/fresh-rings-tell.md b/.changeset/fresh-rings-tell.md new file mode 100644 index 0000000000..82fe952e4a --- /dev/null +++ b/.changeset/fresh-rings-tell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-node': patch +--- + +Fix handling of default plugins that have configuration From b2f2276ccd091c86c36a26eea63fff9bb2f83e73 Mon Sep 17 00:00:00 2001 From: secustor Date: Mon, 19 Feb 2024 13:16:05 +0100 Subject: [PATCH 033/176] feat: move custom data parser to CatalogModel extension point Signed-off-by: secustor --- .changeset/polite-zoos-pay.md | 2 +- .../src/service/CatalogPlugin.ts | 33 ++++++++++--------- plugins/catalog-node/api-report-alpha.md | 3 +- plugins/catalog-node/src/extensions.ts | 7 +++- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.changeset/polite-zoos-pay.md b/.changeset/polite-zoos-pay.md index 1a6a0c69a7..9596d1356c 100644 --- a/.changeset/polite-zoos-pay.md +++ b/.changeset/polite-zoos-pay.md @@ -3,4 +3,4 @@ '@backstage/plugin-catalog-node': minor --- -Allow setting EntityDataParser using CatalogProcessingExtensionPoint +Allow setting EntityDataParser using CatalogModelExtensionPoint diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 7f7d07e6c8..1b4a6fba51 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -49,7 +49,6 @@ class CatalogProcessingExtensionPointImpl unprocessedEntity: Entity; errors: Error[]; }) => Promise | void; - #entityDataParser?: CatalogProcessorParser; addProcessor( ...processors: Array> @@ -80,15 +79,6 @@ class CatalogProcessingExtensionPointImpl this.#onProcessingErrorHandler = handler; } - setEntityDataParser(parser: CatalogProcessorParser): void { - if (this.#entityDataParser) { - throw new Error( - 'Attempted to install second EntityDataParser. Only one can be set.', - ); - } - this.#entityDataParser = parser; - } - get processors() { return this.#processors; } @@ -104,10 +94,6 @@ class CatalogProcessingExtensionPointImpl get onProcessingErrorHandler() { return this.#onProcessingErrorHandler; } - - get entityDataParser() { - return this.#entityDataParser; - } } class CatalogAnalysisExtensionPointImpl @@ -152,6 +138,21 @@ class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint { get fieldValidators() { return this.#fieldValidators; } + + #entityDataParser?: CatalogProcessorParser; + + setEntityDataParser(parser: CatalogProcessorParser): void { + if (this.#entityDataParser) { + throw new Error( + 'Attempted to install second EntityDataParser. Only one can be set.', + ); + } + this.#entityDataParser = parser; + } + + get entityDataParser() { + return this.#entityDataParser; + } } /** @@ -221,8 +222,8 @@ export const catalogPlugin = createBackendPlugin({ builder.addProcessor(...processingExtensions.processors); builder.addEntityProvider(...processingExtensions.entityProviders); - if (processingExtensions.entityDataParser) { - builder.setEntityDataParser(processingExtensions.entityDataParser); + if (modelExtensions.entityDataParser) { + builder.setEntityDataParser(modelExtensions.entityDataParser); } Object.entries(processingExtensions.placeholderResolvers).forEach( diff --git a/plugins/catalog-node/api-report-alpha.md b/plugins/catalog-node/api-report-alpha.md index d92e1b12c4..17ad6e7c1e 100644 --- a/plugins/catalog-node/api-report-alpha.md +++ b/plugins/catalog-node/api-report-alpha.md @@ -28,6 +28,7 @@ export const catalogAnalysisExtensionPoint: ExtensionPoint): void; } @@ -71,8 +72,6 @@ export interface CatalogProcessingExtensionPoint { errors: Error[]; }) => Promise | void, ): void; - // (undocumented) - setEntityDataParser(parser: CatalogProcessorParser): void; } // @alpha (undocumented) diff --git a/plugins/catalog-node/src/extensions.ts b/plugins/catalog-node/src/extensions.ts index 5890349a95..09a7e1d894 100644 --- a/plugins/catalog-node/src/extensions.ts +++ b/plugins/catalog-node/src/extensions.ts @@ -38,7 +38,6 @@ export interface CatalogProcessingExtensionPoint { ...providers: Array> ): void; addPlaceholderResolver(key: string, resolver: PlaceholderResolver): void; - setEntityDataParser(parser: CatalogProcessorParser): void; setOnProcessingErrorHandler( handler: (event: { unprocessedEntity: Entity; @@ -57,6 +56,12 @@ export interface CatalogModelExtensionPoint { * @param validators - The (subset of) validators to set */ setFieldValidators(validators: Partial): void; + + /** + * Sets the entity data parser which is used to read raw data from locations + * @param parser - Parser which will used to extract entities from raw data + */ + setEntityDataParser(parser: CatalogProcessorParser): void; } /** From 3b5d4f6c16bd9f2bf4ad2254f2c51facd9eb41a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 18 Feb 2024 15:26:25 +0100 Subject: [PATCH 034/176] bep-0003: illustrate with some sequence diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../README.md | 73 +++++++ .../token-sequence-cookie.drawio.svg | 203 ++++++++++++++++++ .../token-sequence-obo.drawio.svg | 203 ++++++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 beps/0003-auth-architecture-evolution/token-sequence-cookie.drawio.svg create mode 100644 beps/0003-auth-architecture-evolution/token-sequence-obo.drawio.svg diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index dc475fa14d..f96758634b 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -425,6 +425,79 @@ backend: The exact impact that this has is that it disables the check in the `HttpRouterService` implementation, effectively applying the `unauthenticated` access level to all routes. Furthermore, it will also change `AuthService` so that the `getPluginRequestToken()` method will now issue an empty token for a `'none'` principal, rather than throwing. +### Token Details + +Note that this section is NOT normative. It illustrates the token shapes and major token flows that are involved in this proposal, but intentionally leaves out some low level details and is subject to change. + +#### Backstage Identity Tokens + +These are the regular tokens, commonly in short referred to just as "Backstage Tokens", that the auth backend generates for the user during sign-in. These are sent along with calls to backend plugins to identify the user. This BEP does not aim to change the shape of these tokens; this section is only here for informative purposes to convey what pieces of information that are at play. + +This is a JWT token. + +```yaml +# Header +{ + "alg": "ES256", + "kid": "4f5a0543-894a-4176-b0b7-699a7026b72f" +} +# Payload +{ + "iss": "http://localhost:7007/api/auth", + "sub": "user:default/example-user", + "ent": ["user:default/example-user", "group:default/my-team"], + "aud": "backstage", + "iat": 1708333140, + "exp": 1708336740 +} +``` + +The key ID is some random UUID. The `iss` (issuer) is the external base URL of your auth backend. Note that it uses the `ES256` asymmetric signature algorithm, and the auth backend exposes a JWKS that contains the public parts of the signing keys. The `sub` is an entity ref denoting who the signed in user is, and the `ent` is an array of entity refs that they claim ownership through. The `aud` (audience) is hardcoded to the string `"backstage"` always. + +#### Legacy Service Tokens + +These are the tokens that have been used for backend-to-backend communications before this BEP, and they will likely have changes as part of this work. + +This is a JWT token. + +```yaml +# Header +{ + "alg": "HS256" +} +# Payload +{ + "sub": "backstage-server", + "exp": 1708337056 +} +``` + +Note that unlike the identity token in the previous section, it uses the `HS256` symmetric signature algorithm. The key used is the first of the `backend.auth.keys` entries in your `app-config`, which is a shared secret among all backend plugins, enabling them to know that the caller is a legitimate one. But the token does not contain any information about who the caller is (it's just a generic `"backstage-server"`), nor who the receiver (audience) is. + +#### New Cookie Token Flow + +Some plugins serve static content that the browser engine requests directly, e.g. the TechDocs plugin. Those calls cannot easily have a bearer token attached to them. For these use cases a cookie based flow will be used instead. + +![Cookie token sequence diagram](./token-sequence-cookie.drawio.svg) + +The frontend part of the plugin ensures that a cookie endpoint on the backend part of the plugin is called before attempting to render static content. This endpoint validates the user's identity token and sets a corresponding cookie on the response. Subsequent requests for getting static content will automatically have this cookie attached to them by the browser. + +We intentionally do not specify here how the cookie token is acquired. It might be issued by the plugin itself or by the auth backend depending on how the architecture evolves, but this does not have any effect on plugin code. + +The cookie token contains the user's identifying information just like the identity cookie but is severely limited. It has the plugin itself specified as its audience. Thus, this token is not usable in any bearer token context, nor as a cookie toward any other plugin. + +#### New Service OBO Token Flow + +When a backend service needs to in turn make a request to another upstream service to fulfil the original request, it uses an On-Behalf-Of (OBO) token for the purpose. + +![OBO token sequence diagram](./token-sequence-obo.drawio.svg) + +The initial request in this picture is a frontend plugin, but the same concept applies if it is initiated by a service. The scaffolder backend in this example acquires an OBO token to be able to talk to the catalog plugin. + +We intentionally do not specify here how the OBO token is acquired. It might be issued by the plugin itself or by the auth backend depending on how the architecture evolves, but this does not have any effect on plugin code. + +The OBO token specifies the target service as its audience and itself as the subject, but additionally also contains the original caller's identifying information. Thus, the target service can identify who the nearest caller is but also apply permissions that are relevant to the original caller. The token is thus scoped to not be usable toward other backend plugins. + ## Release Plan The existing `IdentityService` and `TokenManagerService` will be deprecated and instead implemented in terms of the new `AuthService`. diff --git a/beps/0003-auth-architecture-evolution/token-sequence-cookie.drawio.svg b/beps/0003-auth-architecture-evolution/token-sequence-cookie.drawio.svg new file mode 100644 index 0000000000..b20b528c35 --- /dev/null +++ b/beps/0003-auth-architecture-evolution/token-sequence-cookie.drawio.svg @@ -0,0 +1,203 @@ + + + + + + + + +
+
+
+ cookie flow +
+
+
+
+ + cookie flow + +
+
+ + + + + +
+
+
+ Browser +
+
+
+
+ + Browser + +
+
+ + + + + + +
+
+
+ Techdocs +
+ Backend +
+
+
+
+ + Techdocs... + +
+
+ + + + + + +
+
+
+ GET /cookie +
+
+
+
+ + GET /cookie + +
+
+ + + + + +
+
+
+ set cookie on response +
+
+
+
+ + set cookie on response + +
+
+ + + + + +
+
+
+ browser static content requests +
+
+
+
+ + browser static content requests + +
+
+ + + + +
+
+
+ cookie token +
+
+
+
+ + cookie token + +
+
+ + + + +
+
+
+ cookie token +
+
+
+
+ + cookie token + +
+
+ + + + +
+
+
+ user identity token +
+
+
+
+ + user identity token + +
+
+ + + + + +
+
+
+ + acquire cookie token +
+ based on user token +
+
+
+
+
+
+ + acquire cookie token... + +
+
+ + + + +
+ + + + + Text is not SVG - cannot display + + + +
diff --git a/beps/0003-auth-architecture-evolution/token-sequence-obo.drawio.svg b/beps/0003-auth-architecture-evolution/token-sequence-obo.drawio.svg new file mode 100644 index 0000000000..f337f8b572 --- /dev/null +++ b/beps/0003-auth-architecture-evolution/token-sequence-obo.drawio.svg @@ -0,0 +1,203 @@ + + + + + + + + +
+
+
+ Browser +
+
+
+
+ + Browser + +
+
+ + + + + + +
+
+
+ Scaffolder +
+ Backend +
+
+
+
+ + Scaffolder... + +
+
+ + + + + + +
+
+
+ + acquire service obo token +
+ based on user token +
+
+
+
+
+
+ + acquire service obo token... + +
+
+ + + + + +
+
+
+ POST /tasks +
+
+
+
+ + POST /tasks + +
+
+ + + + + +
+
+
+ success +
+
+
+
+ + success + +
+
+ + + + + +
+
+
+ Catalog +
+ Backend +
+
+
+
+ + Catalog... + +
+
+ + + + + + +
+
+
+ GET /entities/... +
+
+
+
+ + GET /entities/... + +
+
+ + + + + +
+
+
+ template entity +
+
+
+
+ + template entity + +
+
+ + + + +
+
+
+ user identity token +
+
+
+
+ + user identity token + +
+
+ + + + +
+
+
+ service obo token +
+
+
+
+ + service obo token + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
From 85db926bb6a40d0546113dbc13b7f70b961f22ce Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Tue, 20 Feb 2024 14:32:12 +0100 Subject: [PATCH 035/176] New backend system for Azure Site backend plugin Signed-off-by: Deepankumar Loganathan --- .changeset/five-mayflies-juggle.md | 5 +++ plugins/azure-sites-backend/README.md | 29 +++++++++++- plugins/azure-sites-backend/api-report.md | 4 ++ plugins/azure-sites-backend/package.json | 2 + plugins/azure-sites-backend/src/index.ts | 1 + plugins/azure-sites-backend/src/plugin.ts | 54 +++++++++++++++++++++++ yarn.lock | 2 + 7 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changeset/five-mayflies-juggle.md create mode 100644 plugins/azure-sites-backend/src/plugin.ts diff --git a/.changeset/five-mayflies-juggle.md b/.changeset/five-mayflies-juggle.md new file mode 100644 index 0000000000..56bf28a8e9 --- /dev/null +++ b/.changeset/five-mayflies-juggle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-sites-backend': patch +--- + +Added new backend system for the Azure Sites backend plugin diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index 892cbe4024..843e9ff92b 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -33,6 +33,8 @@ Configuration Details: Here's how to get the backend plugin up and running: +#### Legacy Backend System + 1. First we need to add the `@backstage/plugin-azure-sites-backend` package to your backend: ```sh @@ -105,6 +107,29 @@ Here's how to get the backend plugin up and running: } ``` -5. Now run `yarn start-backend` from the repo root. +#### New Backend System -6. Finally, open `http://localhost:7007/api/azure/health` in a browser, it should return `{"status":"ok"}`. +The Azure Sites backend plugin has support for the [new backend system](https://backstage.io/docs/backend-system/), here's how you can set that up: + +In your `packages/backend/src/index.ts` make the following changes: + +```diff + import { createBackend } from '@backstage/backend-defaults'; ++ import { azureSitesPlugin } from '@backstage/plugin-azure-sites-backend; + + const backend = createBackend(); + + // ... other feature additions + ++ backend.add(azureSitesPlugin); + + // ... + + backend.start(); +``` + +#### Start Backed & Test + +1. Now run `yarn start-backend` from the repo root. + +2. Finally, open `http://localhost:7007/api/azure/health` in a browser, it should return `{"status":"ok"}`. diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index 04cd22596a..5adc3c3c47 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -6,6 +6,7 @@ import { AzureSiteListRequest } from '@backstage/plugin-azure-sites-common'; import { AzureSiteListResponse } from '@backstage/plugin-azure-sites-common'; import { AzureSiteStartStopRequest } from '@backstage/plugin-azure-sites-common'; +import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; @@ -50,6 +51,9 @@ export class AzureSitesConfig { readonly tenantId: string; } +// @public +export const azureSitesPlugin: () => BackendFeature; + // @public (undocumented) export function createRouter(options: RouterOptions): Promise; diff --git a/plugins/azure-sites-backend/package.json b/plugins/azure-sites-backend/package.json index 05eafa97f1..ff4b41441f 100644 --- a/plugins/azure-sites-backend/package.json +++ b/plugins/azure-sites-backend/package.json @@ -36,12 +36,14 @@ "@azure/arm-resourcegraph": "^4.2.1", "@azure/identity": "^4.0.0", "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-sites-common": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", "@types/express": "^4.17.6", diff --git a/plugins/azure-sites-backend/src/index.ts b/plugins/azure-sites-backend/src/index.ts index 97f94e4470..22d120acc5 100644 --- a/plugins/azure-sites-backend/src/index.ts +++ b/plugins/azure-sites-backend/src/index.ts @@ -17,3 +17,4 @@ export * from './service/router'; export * from './api'; export * from './config'; +export { azureSitesPlugin } from './plugin'; diff --git a/plugins/azure-sites-backend/src/plugin.ts b/plugins/azure-sites-backend/src/plugin.ts new file mode 100644 index 0000000000..90a9c416bd --- /dev/null +++ b/plugins/azure-sites-backend/src/plugin.ts @@ -0,0 +1,54 @@ +/* + * 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 { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { AzureSitesApi } from './api'; + +/** + * Azure Sites Backend Plugin + * + * @public + */ +export const azureSitesPlugin = createBackendPlugin({ + pluginId: 'azure-sites', + register(env) { + env.registerInit({ + deps: { + config: coreServices.rootConfig, + logger: coreServices.logger, + httpRouter: coreServices.httpRouter, + permissions: coreServices.permissions, + catalogApi: catalogServiceRef, + }, + async init({ config, logger, httpRouter, permissions, catalogApi }) { + const azureSitesApi = AzureSitesApi.fromConfig(config); + httpRouter.use( + await createRouter({ + logger: loggerToWinstonLogger(logger), + azureSitesApi, + permissions, + catalogApi, + }), + ); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index b612c502e2..f223ccdff7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5093,6 +5093,7 @@ __metadata: "@azure/arm-resourcegraph": ^4.2.1 "@azure/identity": ^4.0.0 "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -5100,6 +5101,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-sites-common": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" "@types/express": ^4.17.6 From 0cd6d20f712d5c45974567e396027c283ff270fc Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Tue, 20 Feb 2024 15:03:33 +0100 Subject: [PATCH 036/176] fixed plugin export and README Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/README.md | 6 ++++-- plugins/azure-sites-backend/api-report.md | 3 ++- plugins/azure-sites-backend/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index 843e9ff92b..554a3d0cc4 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -51,14 +51,17 @@ Here's how to get the backend plugin up and running: } from '@backstage/plugin-azure-sites-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; + import { CatalogClient } from '@backstage/catalog-client' export default async function createPlugin( env: PluginEnvironment, ): Promise { return await createRouter({ + const catalogApi = new CatalogClient({ discoveryApi: env.discovery }) logger: env.logger, azureSitesApi: AzureSitesApi.fromConfig(env.config), permissions: env.permissions, + catalogApi }); } ``` @@ -115,13 +118,12 @@ In your `packages/backend/src/index.ts` make the following changes: ```diff import { createBackend } from '@backstage/backend-defaults'; -+ import { azureSitesPlugin } from '@backstage/plugin-azure-sites-backend; const backend = createBackend(); // ... other feature additions -+ backend.add(azureSitesPlugin); ++ backend.add(import('@backstage/plugin-azure-sites-backend')); // ... diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index 5adc3c3c47..9c2875e286 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -52,7 +52,8 @@ export class AzureSitesConfig { } // @public -export const azureSitesPlugin: () => BackendFeature; +const azureSitesPlugin: () => BackendFeature; +export default azureSitesPlugin; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; diff --git a/plugins/azure-sites-backend/src/index.ts b/plugins/azure-sites-backend/src/index.ts index 22d120acc5..7f56b8e92a 100644 --- a/plugins/azure-sites-backend/src/index.ts +++ b/plugins/azure-sites-backend/src/index.ts @@ -17,4 +17,4 @@ export * from './service/router'; export * from './api'; export * from './config'; -export { azureSitesPlugin } from './plugin'; +export { azureSitesPlugin as default } from './plugin'; From a7bd9a4f98a265a9f6c340303b990f7fe2be685e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Fern=C3=A1ndez?= Date: Tue, 20 Feb 2024 15:36:41 +0100 Subject: [PATCH 037/176] fix: wrong link fixed for Architecture Overview - Package Architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miguel Fernández --- docs/backend-system/architecture/01-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backend-system/architecture/01-index.md b/docs/backend-system/architecture/01-index.md index 9c37df7b66..f388dff98d 100644 --- a/docs/backend-system/architecture/01-index.md +++ b/docs/backend-system/architecture/01-index.md @@ -57,7 +57,7 @@ Just like plugins, modules also have access to services and can depend on their A detailed explanation of the package architecture can be found in the [Backstage Architecture -Overview](../../overview/architecture-overview.md#package-architecture). The +Overview](../../overview/architecture-overview/#package-architecture). The most important packages to consider for this system are the following: - `plugin--backend` houses the implementation of the backend plugins From 2e925ed0764a67ef667cdb73e6fdff9a1db9c502 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Tue, 20 Feb 2024 22:18:12 +0100 Subject: [PATCH 038/176] fixed ADR file path in UrlResolver Signed-off-by: Deepankumar Loganathan --- plugins/adr-common/api-report.md | 1 + plugins/adr-common/src/index.ts | 8 ++++++++ .../adr/src/components/AdrReader/AdrReader.tsx | 17 +++-------------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/plugins/adr-common/api-report.md b/plugins/adr-common/api-report.md index a36854f0e7..f12a086359 100644 --- a/plugins/adr-common/api-report.md +++ b/plugins/adr-common/api-report.md @@ -25,6 +25,7 @@ export const ANNOTATION_ADR_LOCATION = 'backstage.io/adr-location'; export const getAdrLocationUrl: ( entity: Entity, scmIntegration: ScmIntegrationRegistry, + adrFilePath?: String, ) => string; // @public diff --git a/plugins/adr-common/src/index.ts b/plugins/adr-common/src/index.ts index 97c82b2047..7b441d091c 100644 --- a/plugins/adr-common/src/index.ts +++ b/plugins/adr-common/src/index.ts @@ -57,11 +57,19 @@ export const isAdrAvailable = (entity: Entity) => export const getAdrLocationUrl = ( entity: Entity, scmIntegration: ScmIntegrationRegistry, + adrFilePath?: String, ) => { if (!isAdrAvailable(entity)) { throw new Error(`Missing ADR annotation: ${ANNOTATION_ADR_LOCATION}`); } + if (adrFilePath) { + return scmIntegration.resolveUrl({ + url: `${getAdrLocationDir(entity)!.replace(/\/$/, '')}/${adrFilePath}`, + base: getEntitySourceLocation(entity).target, + }); + } + return scmIntegration.resolveUrl({ url: getAdrLocationDir(entity)!, base: getEntitySourceLocation(entity).target, diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx index f41b1b1fdb..2169165288 100644 --- a/plugins/adr/src/components/AdrReader/AdrReader.tsx +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -44,22 +44,11 @@ export const AdrReader = (props: { const { entity } = useEntity(); const scmIntegrations = useApi(scmIntegrationsApiRef); const adrApi = useApi(adrApiRef); - const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations); - let url = `${adrLocationUrl.replace(/\/$/, '')}`; - const adrUrlPath = url.match(/path=\/.*\&/); - if (adrUrlPath) { - // Azure DevOps SCM handle the path in URL Params - const adrPath = adrUrlPath![0].replace(/\&$/, ''); - const regex = new RegExp(`${adrPath}`); - url = url.replace(regex, `${adrPath}/${adr}}`); - } else { - // Other SCM tools - url = `${url}/${adr}`; - } + const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations, adr); const { value, loading, error } = useAsync( - async () => adrApi.readAdr(url), - [url], + async () => adrApi.readAdr(adrLocationUrl), + [adrLocationUrl], ); const adrContent = useMemo(() => { From 293c835e05370a46b12f5fc020a8202ff5d69667 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Wed, 1 Nov 2023 13:50:11 +1100 Subject: [PATCH 039/176] Add support for Service Tokens to the cfaccess auth provider Signed-off-by: Tyler Davis --- .changeset/soft-otters-report.md | 5 + .../cloudflare-access/provider.test.ts | 97 +++++++++++++++++++ .../providers/cloudflare-access/provider.ts | 38 ++++++-- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 .changeset/soft-otters-report.md diff --git a/.changeset/soft-otters-report.md b/.changeset/soft-otters-report.md new file mode 100644 index 0000000000..d71d3a2e9a --- /dev/null +++ b/.changeset/soft-otters-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Add support for Service Tokens to Cloudflare Access auth provider diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts index 95e4b0901b..f34795fa0d 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -34,6 +34,15 @@ const mockClaims = { exp: 1632833763, iss: 'ISSUER_URL', }; +const mockServiceTokenJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIiLCJuYW1lIjoiQm90IiwiY29tbW9uX25hbWUiOiJ0ZXN0X3Rva2VuX2lkLmFjY2VzcyIsImlhdCI6MTUxNjIzOTAyMn0.KEe-qBHuN8HKh1LobtDQnCJ3rxZOhW-lMSDad8uV_l0'; +const mockServiceTokenClaims = { + sub: '', + common_name: 'test_token_id.access', + iat: 1632833760, + exp: 1632833763, + iss: 'ISSUER_URL', +}; const mockCfIdentity = { name: 'foo', id: '123', @@ -78,6 +87,32 @@ const identityOkResponse = { }, }; +const identityOkServiceTokenResponse = { + backstageIdentity: { + expiresInSeconds: undefined, + identity: { + ownershipEntityRefs: ['user:default/jimmymarkum'], + type: 'user', + userEntityRef: 'user:default/jimmymarkum', + }, + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }, + profile: { + email: undefined, + }, + providerInfo: { + cfAccessIdentityProfile: { + email: 'test_token_id.access@foobar.com', + groups: [], + id: 'test_token_id.access', + name: 'Bot', + }, + claims: mockServiceTokenClaims, + expiresInSeconds: 3, + }, +}; + const mockAuthenticatedUserEmail = 'user.name@email.test'; const mockCacheClient = { get: jest.fn(), @@ -121,6 +156,12 @@ describe('CloudflareAccessAuthProvider', () => { }, } as unknown as express.Request; + const mockRequestWithSericeTokenJwtHeader = { + header: jest.fn(() => { + return mockServiceTokenJwt; + }), + } as unknown as express.Request; + const mockRequestWithoutJwt = { header: jest.fn(_ => { return undefined; @@ -169,7 +210,63 @@ describe('CloudflareAccessAuthProvider', () => { cache: mockCacheClient, }); + const providerServiceToken = new CloudflareAccessAuthProvider({ + teamName: 'foobar', + resolverContext: {} as AuthResolverContext, + authHandler: async result => { + expect(result).toEqual( + expect.objectContaining({ + claims: mockServiceTokenClaims, + cfIdentity: { + email: 'test_token_id.access@foobar.com', + groups: [], + id: 'test_token_id.access', + name: 'Bot', + }, + token: mockServiceTokenJwt, + }), + ); + return { + profile: { + email: result.claims.email, + }, + }; + }, + signInResolver: async ({ result }) => { + expect(result).toEqual( + expect.objectContaining({ + claims: mockServiceTokenClaims, + cfIdentity: { + email: 'test_token_id.access@foobar.com', + groups: [], + id: 'test_token_id.access', + name: 'Bot', + }, + token: mockServiceTokenJwt, + }), + ); + return { + token: + 'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob', + }; + }, + cache: mockCacheClient, + }); + describe('when JWT is valid', () => { + it('validates a service token JWT without calling get-identity', async () => { + jwtMock.mockReturnValue( + Promise.resolve({ payload: mockServiceTokenClaims }), + ); + await providerServiceToken.refresh( + mockRequestWithSericeTokenJwtHeader, + mockResponse, + ); + expect(mockResponse.json).toHaveBeenCalledWith( + identityOkServiceTokenResponse, + ); + }); + it('returns cfidentity also when get-identity succeeds', async () => { jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims })); mockFetch.mockReturnValueOnce( diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index fe271f9058..c6217e6523 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -17,7 +17,6 @@ import { AuthHandler } from '../types'; import fetch, { Headers } from 'node-fetch'; import express from 'express'; -import * as _ from 'lodash'; import { jwtVerify, createRemoteJWKSet } from 'jose'; import { AuthenticationError, @@ -260,8 +259,20 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { const verifyResult = await jwtVerify(jwt, this.jwtKeySet, { issuer: `https://${this.teamName}.cloudflareaccess.com`, }); - const sub = verifyResult.payload.sub; - const cfAccessResultStr = await this.cache?.get(`${CACHE_PREFIX}/${sub}`); + + const isServiceToken = verifyResult.payload.sub === ''; + + const subject = isServiceToken + ? (verifyResult.payload.common_name as string) + : verifyResult.payload.sub; + if (!subject) { + throw new AuthenticationError( + `Missing both sub and common_name from Cloudflare Access JWT`, + ); + } + + const cacheKey = `${CACHE_PREFIX}/${subject}`; + const cfAccessResultStr = await this.cache?.get(cacheKey); if (typeof cfAccessResultStr === 'string') { const result = JSON.parse(cfAccessResultStr) as CloudflareAccessResult; return { @@ -270,12 +281,23 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { }; } const claims = verifyResult.payload as CloudflareAccessClaims; + // Builds a passport profile from JWT claims first try { - // If we successfully fetch the get-identity endpoint, - // We supplement the passport profile with richer user identity - // information here. - const cfIdentity = await this.getIdentityProfile(jwt); + let cfIdentity: CloudflareAccessIdentityProfile; + if (isServiceToken) { + cfIdentity = { + id: subject, + name: 'Bot', + email: `${subject}@${this.teamName}.com`, + groups: [], + }; + } else { + // If we successfully fetch the get-identity endpoint, + // We supplement the passport profile with richer user identity + // information here. + cfIdentity = await this.getIdentityProfile(jwt); + } // Stores a stringified JSON object in cfaccess provider cache only when // we complete all steps const cfAccessResult = { @@ -283,7 +305,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { cfIdentity, expiresInSeconds: claims.exp - claims.iat, }; - this.cache?.set(`${CACHE_PREFIX}/${sub}`, JSON.stringify(cfAccessResult)); + this.cache?.set(cacheKey, JSON.stringify(cfAccessResult)); return { ...cfAccessResult, token: jwt, From 329e210003bc66c71326162c2a79446bbff4d1f6 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Wed, 21 Feb 2024 19:46:23 +0100 Subject: [PATCH 040/176] added adrFileLocationUrl for adr file read Signed-off-by: Deepankumar Loganathan --- plugins/adr-common/src/index.ts | 9 ++++----- plugins/adr/src/components/AdrReader/AdrReader.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/adr-common/src/index.ts b/plugins/adr-common/src/index.ts index 7b441d091c..85d61cd270 100644 --- a/plugins/adr-common/src/index.ts +++ b/plugins/adr-common/src/index.ts @@ -63,15 +63,14 @@ export const getAdrLocationUrl = ( throw new Error(`Missing ADR annotation: ${ANNOTATION_ADR_LOCATION}`); } + let url = getAdrLocationDir(entity)!.replace(/\/$/, ''); + if (adrFilePath) { - return scmIntegration.resolveUrl({ - url: `${getAdrLocationDir(entity)!.replace(/\/$/, '')}/${adrFilePath}`, - base: getEntitySourceLocation(entity).target, - }); + url = `${url}/${adrFilePath}`; } return scmIntegration.resolveUrl({ - url: getAdrLocationDir(entity)!, + url, base: getEntitySourceLocation(entity).target, }); }; diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx index 2169165288..d0dd8b7444 100644 --- a/plugins/adr/src/components/AdrReader/AdrReader.tsx +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -44,11 +44,12 @@ export const AdrReader = (props: { const { entity } = useEntity(); const scmIntegrations = useApi(scmIntegrationsApiRef); const adrApi = useApi(adrApiRef); - const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations, adr); + const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations); + const adrFileLocationUrl = getAdrLocationUrl(entity, scmIntegrations, adr); const { value, loading, error } = useAsync( - async () => adrApi.readAdr(adrLocationUrl), - [adrLocationUrl], + async () => adrApi.readAdr(adrFileLocationUrl), + [adrFileLocationUrl], ); const adrContent = useMemo(() => { From 3191d616e835bba03f14007090e041d0869efdfd Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Wed, 7 Feb 2024 20:26:04 +1100 Subject: [PATCH 041/176] pr feedback: make service tokens configurable Signed-off-by: Tyler Davis --- docs/auth/cloudflare/access.md | 2 ++ .../cloudflare-access/provider.test.ts | 31 +++++++++++++++++++ .../providers/cloudflare-access/provider.ts | 31 +++++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index b1d8e4c676..457c3abd3e 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -25,6 +25,8 @@ auth: providers: cfaccess: teamName: + serviceTokens: + "1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com ``` You can find the team name in the Cloudflare Zero Trust dashboard. diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts index f34795fa0d..f5f1f361ab 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -43,6 +43,15 @@ const mockServiceTokenClaims = { exp: 1632833763, iss: 'ISSUER_URL', }; +const mockServiceTokenDisallowedJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIiLCJuYW1lIjoiQm90IiwiY29tbW9uX25hbWUiOiJzb21lX290aGVyX3Rva2VuX2lkLmFjY2VzcyIsImlhdCI6MTUxNjIzOTAyMn0.qQeeQW_urYrrTq-tuKZWURwTUrjzgyFyZA9ViQtD-FM'; +const mockServiceTokenDisallowedClaims = { + sub: '', + common_name: 'some_other_token_id.access', + iat: 1632833760, + exp: 1632833763, + iss: 'ISSUER_URL', +}; const mockCfIdentity = { name: 'foo', id: '123', @@ -162,6 +171,12 @@ describe('CloudflareAccessAuthProvider', () => { }), } as unknown as express.Request; + const mockRequestWithSericeTokenDisallowedJwtHeader = { + header: jest.fn(() => { + return mockServiceTokenDisallowedJwt; + }), + } as unknown as express.Request; + const mockRequestWithoutJwt = { header: jest.fn(_ => { return undefined; @@ -179,6 +194,7 @@ describe('CloudflareAccessAuthProvider', () => { const provider = new CloudflareAccessAuthProvider({ teamName: 'foobar', + serviceTokens: {}, resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( @@ -212,6 +228,9 @@ describe('CloudflareAccessAuthProvider', () => { const providerServiceToken = new CloudflareAccessAuthProvider({ teamName: 'foobar', + serviceTokens: { + 'test_token_id.access': 'test_token_id.access@foobar.com', + }, resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( @@ -267,6 +286,18 @@ describe('CloudflareAccessAuthProvider', () => { ); }); + it('rejects a disallowed service token JWT without calling get-identity', async () => { + jwtMock.mockReturnValue( + Promise.resolve({ payload: mockServiceTokenDisallowedClaims }), + ); + await expect( + providerServiceToken.refresh( + mockRequestWithSericeTokenDisallowedJwtHeader, + mockResponse, + ), + ).rejects.toThrow(); + }); + it('returns cfidentity also when get-identity succeeds', async () => { jwtMock.mockReturnValue(Promise.resolve({ payload: mockClaims })); mockFetch.mockReturnValueOnce( diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index c6217e6523..31e6e6d391 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -48,6 +48,8 @@ const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1'; */ export const CF_DEFAULT_CACHE_TTL = 3600; +type ServiceTokens = Record; + /** @public */ export type Options = { /** @@ -58,6 +60,15 @@ export type Options = { * https://.cloudflareaccess.com/cdn-cgi/access/certs */ teamName: string; + /** + * Allowed Cloudflare Service Tokens + * + * Cloudflare does not currently allow assigning any sort of identity to + * Service Tokens. Therefore, this allows you to build an allow list mapping + * the Client ID of any Service Tokens that should be allowed to pass the + * auth check to the identity (email) you would like to associate with it. + */ + serviceTokens: ServiceTokens; authHandler: AuthHandler; signInResolver: SignInResolver; resolverContext: AuthResolverContext; @@ -178,6 +189,7 @@ export type CloudflareAccessResponse = export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { private readonly teamName: string; + private readonly serviceTokens: ServiceTokens; private readonly resolverContext: AuthResolverContext; private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; @@ -186,6 +198,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { constructor(options: Options) { this.teamName = options.teamName; + this.serviceTokens = options.serviceTokens; this.authHandler = options.authHandler; this.signInResolver = options.signInResolver; this.resolverContext = options.resolverContext; @@ -260,7 +273,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { issuer: `https://${this.teamName}.cloudflareaccess.com`, }); - const isServiceToken = verifyResult.payload.sub === ''; + const isServiceToken = !verifyResult.payload.sub; const subject = isServiceToken ? (verifyResult.payload.common_name as string) @@ -271,6 +284,12 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { ); } + if (isServiceToken && !this.serviceTokens.hasOwnProperty(subject)) { + throw new AuthenticationError( + `${subject} is not a permitted Service Token.`, + ); + } + const cacheKey = `${CACHE_PREFIX}/${subject}`; const cfAccessResultStr = await this.cache?.get(cacheKey); if (typeof cfAccessResultStr === 'string') { @@ -289,7 +308,7 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { cfIdentity = { id: subject, name: 'Bot', - email: `${subject}@${this.teamName}.com`, + email: this.serviceTokens[subject], groups: [], }; } else { @@ -372,6 +391,13 @@ export const cfAccess = createAuthProviderIntegration({ }) { return ({ config, resolverContext }) => { const teamName = config.getString('teamName'); + const serviceTokensConfig = config.getOptionalConfig('serviceTokens'); + const serviceTokens: ServiceTokens = {}; + if (serviceTokensConfig) { + serviceTokensConfig.keys().forEach(key => { + serviceTokens[key] = serviceTokensConfig.getString(key); + }); + } if (!options.signIn.resolver) { throw new Error( @@ -393,6 +419,7 @@ export const cfAccess = createAuthProviderIntegration({ return new CloudflareAccessAuthProvider({ teamName, + serviceTokens, signInResolver: options?.signIn.resolver, authHandler, resolverContext, From 7a1c12bb299f37bd606d2e63625dd5c403c437ed Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Wed, 7 Feb 2024 20:30:57 +1100 Subject: [PATCH 042/176] Update docs/auth/cloudflare/access.md typo in docs Signed-off-by: Tyler Davis --- docs/auth/cloudflare/access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index 457c3abd3e..7c5c5d51d4 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -26,7 +26,7 @@ auth: cfaccess: teamName: serviceTokens: - "1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com + "1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com" ``` You can find the team name in the Cloudflare Zero Trust dashboard. From 0d1ad9faf9e6b0006c43c922c0167f5a7521c720 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Thu, 22 Feb 2024 17:20:17 +1100 Subject: [PATCH 043/176] PR feedback: change structure of serviceTokens config Signed-off-by: Tyler Davis --- docs/auth/cloudflare/access.md | 2 +- plugins/auth-backend/config.d.ts | 5 +++ .../providers/cloudflare-access/provider.ts | 32 +++++++++++-------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index 7c5c5d51d4..17a37c6ef8 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -26,7 +26,7 @@ auth: cfaccess: teamName: serviceTokens: - "1uh2fh19efvfh129f1f919u21f2f19jf2.access": "bot-user@your-company.com" + '1uh2fh19efvfh129f1f919u21f2f19jf2.access': 'bot-user@your-company.com' ``` You can find the team name in the Cloudflare Zero Trust dashboard. diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 4c8430b33f..0a1425c98b 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -185,6 +185,11 @@ export interface Config { /** @visibility frontend */ cfaccess?: { teamName: string; + /** @visibility secret */ + serviceTokens?: Array<{ + token: string; + subject: string; + }>; }; /** * The backstage token expiration. diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts index 31e6e6d391..e4d317f8f6 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.ts @@ -48,7 +48,10 @@ const CACHE_PREFIX = 'providers/cloudflare-access/profile-v1'; */ export const CF_DEFAULT_CACHE_TTL = 3600; -type ServiceTokens = Record; +type ServiceToken = { + token: string; + subject: string; +}; /** @public */ export type Options = { @@ -68,7 +71,7 @@ export type Options = { * the Client ID of any Service Tokens that should be allowed to pass the * auth check to the identity (email) you would like to associate with it. */ - serviceTokens: ServiceTokens; + serviceTokens: ServiceToken[]; authHandler: AuthHandler; signInResolver: SignInResolver; resolverContext: AuthResolverContext; @@ -189,7 +192,7 @@ export type CloudflareAccessResponse = export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { private readonly teamName: string; - private readonly serviceTokens: ServiceTokens; + private readonly serviceTokens: ServiceToken[]; private readonly resolverContext: AuthResolverContext; private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; @@ -284,7 +287,8 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { ); } - if (isServiceToken && !this.serviceTokens.hasOwnProperty(subject)) { + const serviceToken = this.serviceTokens.find(st => st.token === subject); + if (isServiceToken && !serviceToken) { throw new AuthenticationError( `${subject} is not a permitted Service Token.`, ); @@ -304,11 +308,11 @@ export class CloudflareAccessAuthProvider implements AuthProviderRouteHandlers { // Builds a passport profile from JWT claims first try { let cfIdentity: CloudflareAccessIdentityProfile; - if (isServiceToken) { + if (serviceToken) { cfIdentity = { id: subject, name: 'Bot', - email: this.serviceTokens[subject], + email: serviceToken.subject, groups: [], }; } else { @@ -391,13 +395,15 @@ export const cfAccess = createAuthProviderIntegration({ }) { return ({ config, resolverContext }) => { const teamName = config.getString('teamName'); - const serviceTokensConfig = config.getOptionalConfig('serviceTokens'); - const serviceTokens: ServiceTokens = {}; - if (serviceTokensConfig) { - serviceTokensConfig.keys().forEach(key => { - serviceTokens[key] = serviceTokensConfig.getString(key); - }); - } + const serviceTokensConfig = + config.getOptionalConfigArray('serviceTokens'); + const serviceTokens = + serviceTokensConfig?.map(cfg => { + return { + token: cfg.getString('token'), + subject: cfg.getString('subject'), + } as ServiceToken; + }) || []; if (!options.signIn.resolver) { throw new Error( From d26553df9d9eedee7590ad4768ea42c97e6abc65 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Thu, 22 Feb 2024 17:30:55 +1100 Subject: [PATCH 044/176] update docs Signed-off-by: Tyler Davis --- docs/auth/cloudflare/access.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/auth/cloudflare/access.md b/docs/auth/cloudflare/access.md index 17a37c6ef8..509c0ba052 100644 --- a/docs/auth/cloudflare/access.md +++ b/docs/auth/cloudflare/access.md @@ -26,10 +26,13 @@ auth: cfaccess: teamName: serviceTokens: - '1uh2fh19efvfh129f1f919u21f2f19jf2.access': 'bot-user@your-company.com' + - token: '1uh2fh19efvfh129f1f919u21f2f19jf2.access' + subject: 'bot-user@your-company.com' ``` -You can find the team name in the Cloudflare Zero Trust dashboard. +You can find the team name in the Cloudflare Zero Trust dashboard. The Service +Tokens section is optional -- you only need it if you have some Cloudflare +Service Tokens that you want to be able to log in to your Backstage instance. This config section must be in place for the provider to load at all. Now let's add the provider itself. From bfd0d62351bb028e09547244a64cbd8d59832c00 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Thu, 22 Feb 2024 17:31:59 +1100 Subject: [PATCH 045/176] update tests Signed-off-by: Tyler Davis --- .../src/providers/cloudflare-access/provider.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts index f5f1f361ab..f5f1233ecc 100644 --- a/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts +++ b/plugins/auth-backend/src/providers/cloudflare-access/provider.test.ts @@ -194,7 +194,7 @@ describe('CloudflareAccessAuthProvider', () => { const provider = new CloudflareAccessAuthProvider({ teamName: 'foobar', - serviceTokens: {}, + serviceTokens: [], resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( @@ -228,9 +228,12 @@ describe('CloudflareAccessAuthProvider', () => { const providerServiceToken = new CloudflareAccessAuthProvider({ teamName: 'foobar', - serviceTokens: { - 'test_token_id.access': 'test_token_id.access@foobar.com', - }, + serviceTokens: [ + { + token: 'test_token_id.access', + subject: 'test_token_id.access@foobar.com', + }, + ], resolverContext: {} as AuthResolverContext, authHandler: async result => { expect(result).toEqual( From 0562a7011fac67bae23273ea9a0734ac49b93d83 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 13 Feb 2024 09:57:17 +0100 Subject: [PATCH 046/176] feat(core-compat-api): create system icon abstraction Signed-off-by: Camila Belo --- .../src/components/SystemIcon.tsx | 52 +++++++++++++++++++ .../core-compat-api/src/components/index.ts | 17 ++++++ packages/core-compat-api/src/index.ts | 2 + 3 files changed, 71 insertions(+) create mode 100644 packages/core-compat-api/src/components/SystemIcon.tsx create mode 100644 packages/core-compat-api/src/components/index.ts diff --git a/packages/core-compat-api/src/components/SystemIcon.tsx b/packages/core-compat-api/src/components/SystemIcon.tsx new file mode 100644 index 0000000000..00c20da5db --- /dev/null +++ b/packages/core-compat-api/src/components/SystemIcon.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useApp } from '@backstage/core-plugin-api'; +import React from 'react'; +import { compatWrapper } from '../compatWrapper'; + +/** + * @public + * Props for the System Icon component. + */ +export type SystemIconProps = { + // The id of the system icon to render. + id: string; + // An optional fallback element to render when the system icon is not found. + fallback?: JSX.Element; +}; + +function SystemIcon(props: SystemIconProps) { + const { id, fallback = null } = props; + const app = useApp(); + const Component = app.getSystemIcon(id); + return Component ? : fallback; +} + +/** + * @public + * SystemIcon is a component that renders a system icon by its id. + * @example + * Rendering the "kind:api" icon: + * ```tsx + * + * ``` + */ +function CompatSystemIcon(props: SystemIconProps) { + return compatWrapper(); +} + +export { CompatSystemIcon as SystemIcon }; diff --git a/packages/core-compat-api/src/components/index.ts b/packages/core-compat-api/src/components/index.ts new file mode 100644 index 0000000000..e02ab727c6 --- /dev/null +++ b/packages/core-compat-api/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { SystemIcon, type SystemIconProps } from './SystemIcon'; diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 88e1892eac..3da227e554 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -17,6 +17,8 @@ export * from './compatWrapper'; export * from './apis'; +export * from './components'; + export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyRouteRef, From 76173663b7100bed891823f4dcfd2b3887641eec Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 13 Feb 2024 11:19:59 +0100 Subject: [PATCH 047/176] refactor(api-docs): use compat system icon Signed-off-by: Camila Belo --- plugins/api-docs/src/alpha.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 38b8db89c6..4835cd17ed 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -27,10 +27,10 @@ import { } from '@backstage/frontend-plugin-api'; import { + SystemIcon, compatWrapper, convertLegacyRouteRef, } from '@backstage/core-compat-api'; -import { useApp } from '@backstage/core-plugin-api'; import { createEntityCardExtension, @@ -46,16 +46,10 @@ import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute, registerComponentRouteRef } from './routes'; import { apiDocsConfigRef } from './config'; -function ApiIcon() { - const app = useApp(); - const KindApiSystemIcon = app.getSystemIcon('kind:api')!; - return ; -} - const apiDocsNavItem = createNavItemExtension({ title: 'APIs', routeRef: convertLegacyRouteRef(rootRoute), - icon: () => compatWrapper(), + icon: () => , }); const apiDocsConfigApi = createApiExtension({ From f25e9ff96bbc087324d8887165704c850b974313 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 13 Feb 2024 11:21:05 +0100 Subject: [PATCH 048/176] docs(core-compat-api): update api reports Signed-off-by: Camila Belo --- packages/core-compat-api/api-report.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index be80ca50e9..828d3d9d6a 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -69,6 +69,15 @@ export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { captureEvent(_event: AnalyticsEvent | AnalyticsEvent_2): void; } +// @public +export function SystemIcon(props: SystemIconProps): React_2.JSX.Element; + +// @public +export type SystemIconProps = { + id: string; + fallback?: JSX.Element; +}; + // @public export type ToNewRouteRef = T extends RouteRef From 7854120d054f655aab84a5148a142e467527c1e4 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 13 Feb 2024 13:14:41 +0100 Subject: [PATCH 049/176] docs: create changeset files Signed-off-by: Camila Belo --- .changeset/friendly-news-sin.md | 5 +++++ .changeset/red-taxis-swim.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/friendly-news-sin.md create mode 100644 .changeset/red-taxis-swim.md diff --git a/.changeset/friendly-news-sin.md b/.changeset/friendly-news-sin.md new file mode 100644 index 0000000000..ca337370af --- /dev/null +++ b/.changeset/friendly-news-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Create an abstraction to consume legacy system icons in new system extensions. diff --git a/.changeset/red-taxis-swim.md b/.changeset/red-taxis-swim.md new file mode 100644 index 0000000000..7cd2428fac --- /dev/null +++ b/.changeset/red-taxis-swim.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Use the system icon compatibility component in the navigation item extension. From 77dc92eecc3a2d3863446273e376686a4e4acad9 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 15 Feb 2024 14:30:34 +0100 Subject: [PATCH 050/176] refactor(core-compat-api): apply review suggestions Signed-off-by: Camila Belo --- packages/core-compat-api/api-report.md | 8 +-- packages/core-compat-api/package.json | 1 + .../src/components/SystemIcon.test.tsx | 41 +++++++++++++++ .../src/components/SystemIcon.tsx | 52 ++++++++++++++----- plugins/api-docs/src/alpha.tsx | 2 +- yarn.lock | 1 + 6 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 packages/core-compat-api/src/components/SystemIcon.test.tsx diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index 828d3d9d6a..82f13f1482 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -8,9 +8,11 @@ import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; +import { ComponentProps } from 'react'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; @@ -73,9 +75,9 @@ export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { export function SystemIcon(props: SystemIconProps): React_2.JSX.Element; // @public -export type SystemIconProps = { - id: string; - fallback?: JSX.Element; +export type SystemIconProps = ComponentProps & { + keys: string | string[]; + Fallback?: IconComponent; }; // @public diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index ee70bc8089..21bf8ede49 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-puppetdb": "workspace:^", "@backstage/plugin-stackstorm": "workspace:^", + "@backstage/test-utils": "workspace:^", "@oriflame/backstage-plugin-score-card": "^0.8.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0" diff --git a/packages/core-compat-api/src/components/SystemIcon.test.tsx b/packages/core-compat-api/src/components/SystemIcon.test.tsx new file mode 100644 index 0000000000..ee3a20308d --- /dev/null +++ b/packages/core-compat-api/src/components/SystemIcon.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { SystemIcon } from './SystemIcon'; + +describe('SystemIcon', () => { + it('should render the correct system icon', async () => { + const { container } = await renderInTestApp(); + expect(container.querySelector('svg')).toBeDefined(); + }); + + it('should render the first found ico when multiple keys are provided', async () => { + const { container } = await renderInTestApp( + , + ); + expect(container.querySelector('svg')).toBeDefined(); + }); + + it('should render the fallback component when no system icon is found', async () => { + await renderInTestApp( +
Fallback Icon
} />, + ); + expect(screen.getByText('Fallback Icon')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-compat-api/src/components/SystemIcon.tsx b/packages/core-compat-api/src/components/SystemIcon.tsx index 00c20da5db..7aa7110a97 100644 --- a/packages/core-compat-api/src/components/SystemIcon.tsx +++ b/packages/core-compat-api/src/components/SystemIcon.tsx @@ -14,26 +14,30 @@ * limitations under the License. */ -import { useApp } from '@backstage/core-plugin-api'; -import React from 'react'; +import React, { ComponentProps } from 'react'; +import { useApp, IconComponent } from '@backstage/core-plugin-api'; import { compatWrapper } from '../compatWrapper'; /** * @public - * Props for the System Icon component. + * Props for the SystemIcon component. */ -export type SystemIconProps = { - // The id of the system icon to render. - id: string; - // An optional fallback element to render when the system icon is not found. - fallback?: JSX.Element; +export type SystemIconProps = ComponentProps & { + // The id of the system icon to render, if provided as an array, the first icon found will be rendered. + keys: string | string[]; + // An optional fallback icon component to render when the system icon is not found. + // Default to () => null. + Fallback?: IconComponent; }; function SystemIcon(props: SystemIconProps) { - const { id, fallback = null } = props; + const { keys, Fallback = () => null, ...rest } = props; const app = useApp(); - const Component = app.getSystemIcon(id); - return Component ? : fallback; + for (const key of Array.isArray(keys) ? keys : [keys]) { + const Icon = app.getSystemIcon(key); + if (Icon) return ; + } + return ; } /** @@ -42,11 +46,33 @@ function SystemIcon(props: SystemIconProps) { * @example * Rendering the "kind:api" icon: * ```tsx - * + * + * ``` + * @example + * Providing multiple icon ids: + * ```tsx + * + * ``` + * @example + * Customizing the fallback icon: + * ```tsx + * + * ``` + * @example + * Customizing the icon font size: + * ```tsx + * * ``` */ function CompatSystemIcon(props: SystemIconProps) { - return compatWrapper(); + try { + // Check if the app context is available + useApp(); + return ; + } catch { + // Fallback to the compat wrapper if the app context is not available + return compatWrapper(); + } } export { CompatSystemIcon as SystemIcon }; diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 4835cd17ed..fd28a59893 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -49,7 +49,7 @@ import { apiDocsConfigRef } from './config'; const apiDocsNavItem = createNavItemExtension({ title: 'APIs', routeRef: convertLegacyRouteRef(rootRoute), - icon: () => , + icon: () => , }); const apiDocsConfigApi = createApiExtension({ diff --git a/yarn.lock b/yarn.lock index 06edaceef5..c8deda0b1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3818,6 +3818,7 @@ __metadata: "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-puppetdb": "workspace:^" "@backstage/plugin-stackstorm": "workspace:^" + "@backstage/test-utils": "workspace:^" "@backstage/version-bridge": "workspace:^" "@oriflame/backstage-plugin-score-card": ^0.8.0 "@testing-library/jest-dom": ^6.0.0 From e951416945582d0e7bffacabdd83b842bc475718 Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 12:48:49 +0000 Subject: [PATCH 051/176] Add Profile information to Azure SCM OAuth Request. Signed-off-by: Phill Morton --- packages/integration-react/src/api/ScmAuth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index a22b15e400..8b26c232f4 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -204,6 +204,9 @@ export class ScmAuth implements ScmAuthApi { '499b84ac-1321-427f-aa17-267ca6975798/vso.graph', '499b84ac-1321-427f-aa17-267ca6975798/vso.project', '499b84ac-1321-427f-aa17-267ca6975798/vso.profile', + 'profile', + 'openid', + 'email', ], repoWrite: ['499b84ac-1321-427f-aa17-267ca6975798/vso.code_manage'], }); From 964926f10e08160dad0cc96964435d52e5fa85a2 Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 12:52:32 +0000 Subject: [PATCH 052/176] add change set Signed-off-by: Phill Morton --- .changeset/great-rice-hunt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/great-rice-hunt.md diff --git a/.changeset/great-rice-hunt.md b/.changeset/great-rice-hunt.md new file mode 100644 index 0000000000..ecd815b387 --- /dev/null +++ b/.changeset/great-rice-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration-react': patch +--- + +Add profile and email scopes to ADO SCM Auth. From 4149571239c53669e2ce84df48bb609bcd073e24 Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 13:33:17 +0000 Subject: [PATCH 053/176] chore: small nits Signed-off-by: Phill Morton --- .changeset/forty-oranges-joke.md | 4 ++-- .changeset/great-rice-hunt.md | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 .changeset/great-rice-hunt.md diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md index b2fdaccdc1..9f41431245 100644 --- a/.changeset/forty-oranges-joke.md +++ b/.changeset/forty-oranges-joke.md @@ -1,5 +1,5 @@ --- -'@backstage/integration-react': patch +'@backstage/integration-react': minor --- -Updated azure devops scopes to include the clientid for Azure Dev Ops OAuth. +Updated `microsoftAuthApi` scopes to for Azure DevOps to be fully qualified. Also added `openid`, `profile` and `email` scopes diff --git a/.changeset/great-rice-hunt.md b/.changeset/great-rice-hunt.md deleted file mode 100644 index ecd815b387..0000000000 --- a/.changeset/great-rice-hunt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/integration-react': patch ---- - -Add profile and email scopes to ADO SCM Auth. From 1fecc471dd6406ffe9790516c577abd34c5a9472 Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 13:58:10 +0000 Subject: [PATCH 054/176] remove the additional scopes - not required. Signed-off-by: Phill Morton --- .changeset/forty-oranges-joke.md | 2 +- packages/integration-react/src/api/ScmAuth.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md index 9f41431245..bc43cc8e4c 100644 --- a/.changeset/forty-oranges-joke.md +++ b/.changeset/forty-oranges-joke.md @@ -2,4 +2,4 @@ '@backstage/integration-react': minor --- -Updated `microsoftAuthApi` scopes to for Azure DevOps to be fully qualified. Also added `openid`, `profile` and `email` scopes +Updated `microsoftAuthApi` scopes to for Azure DevOps to be fully qualified. diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index 8b26c232f4..a22b15e400 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -204,9 +204,6 @@ export class ScmAuth implements ScmAuthApi { '499b84ac-1321-427f-aa17-267ca6975798/vso.graph', '499b84ac-1321-427f-aa17-267ca6975798/vso.project', '499b84ac-1321-427f-aa17-267ca6975798/vso.profile', - 'profile', - 'openid', - 'email', ], repoWrite: ['499b84ac-1321-427f-aa17-267ca6975798/vso.code_manage'], }); From 5e8665378491ef5dfd173b182fe4a962bcd70e41 Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 14:27:55 +0000 Subject: [PATCH 055/176] chore - fix typo in changeset Signed-off-by: Phill Morton --- .changeset/forty-oranges-joke.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md index bc43cc8e4c..fb08927fa0 100644 --- a/.changeset/forty-oranges-joke.md +++ b/.changeset/forty-oranges-joke.md @@ -2,4 +2,4 @@ '@backstage/integration-react': minor --- -Updated `microsoftAuthApi` scopes to for Azure DevOps to be fully qualified. +Updated `microsoftAuthApi` scopes for Azure DevOps to be fully qualified. From 7f3ed08594e74e680a34835d5719feb42e8351ee Mon Sep 17 00:00:00 2001 From: Phill Morton Date: Thu, 22 Feb 2024 14:49:13 +0000 Subject: [PATCH 056/176] Update test cases to include clientId for ADO Signed-off-by: Phill Morton --- packages/integration-react/src/api/ScmAuth.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/integration-react/src/api/ScmAuth.test.ts b/packages/integration-react/src/api/ScmAuth.test.ts index 7e174c4cbf..d252b95b85 100644 --- a/packages/integration-react/src/api/ScmAuth.test.ts +++ b/packages/integration-react/src/api/ScmAuth.test.ts @@ -112,7 +112,8 @@ describe('ScmAuth', () => { await expect( azureAuth.getCredentials({ url: 'http://example.com' }), ).resolves.toMatchObject({ - token: 'vso.build vso.code vso.graph vso.project vso.profile', + token: + '499b84ac-1321-427f-aa17-267ca6975798/vso.build 499b84ac-1321-427f-aa17-267ca6975798/vso.code 499b84ac-1321-427f-aa17-267ca6975798/vso.graph 499b84ac-1321-427f-aa17-267ca6975798/vso.project 499b84ac-1321-427f-aa17-267ca6975798/vso.profile', }); await expect( azureAuth.getCredentials({ @@ -121,7 +122,7 @@ describe('ScmAuth', () => { }), ).resolves.toMatchObject({ token: - 'vso.build vso.code vso.graph vso.project vso.profile vso.code_manage', + '499b84ac-1321-427f-aa17-267ca6975798/vso.build 499b84ac-1321-427f-aa17-267ca6975798/vso.code 499b84ac-1321-427f-aa17-267ca6975798/vso.graph 499b84ac-1321-427f-aa17-267ca6975798/vso.project 499b84ac-1321-427f-aa17-267ca6975798/vso.profile 499b84ac-1321-427f-aa17-267ca6975798/vso.code_manage', }); const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); @@ -174,10 +175,15 @@ describe('ScmAuth', () => { await expect( azureAuth.getCredentials({ url: 'http://example.com', - additionalScope: { customScopes: { azure: ['vso.org'] } }, + additionalScope: { + customScopes: { + azure: ['499b84ac-1321-427f-aa17-267ca6975798/vso.org'], + }, + }, }), ).resolves.toMatchObject({ - token: 'vso.build vso.code vso.graph vso.project vso.profile vso.org', + token: + '499b84ac-1321-427f-aa17-267ca6975798/vso.build 499b84ac-1321-427f-aa17-267ca6975798/vso.code 499b84ac-1321-427f-aa17-267ca6975798/vso.graph 499b84ac-1321-427f-aa17-267ca6975798/vso.project 499b84ac-1321-427f-aa17-267ca6975798/vso.profile 499b84ac-1321-427f-aa17-267ca6975798/vso.org', }); const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); From e4a8455a18fc0aad6d80dded70ca5c63c9d5f4dc Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 22 Feb 2024 16:10:39 +0100 Subject: [PATCH 057/176] Update packages/core-compat-api/src/components/SystemIcon.test.tsx Co-authored-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Signed-off-by: Camila Belo --- packages/core-compat-api/src/components/SystemIcon.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-compat-api/src/components/SystemIcon.test.tsx b/packages/core-compat-api/src/components/SystemIcon.test.tsx index ee3a20308d..12b8f41ea5 100644 --- a/packages/core-compat-api/src/components/SystemIcon.test.tsx +++ b/packages/core-compat-api/src/components/SystemIcon.test.tsx @@ -25,7 +25,7 @@ describe('SystemIcon', () => { expect(container.querySelector('svg')).toBeDefined(); }); - it('should render the first found ico when multiple keys are provided', async () => { + it('should render the first found icon when multiple keys are provided', async () => { const { container } = await renderInTestApp( , ); From f91e2d13342e6d079d9271250aefd453e29fd161 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:50:03 +0000 Subject: [PATCH 058/176] chore(deps): update chromaui/action action to v11 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/verify_storybook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml index 70fb16ac48..e12aba2e0e 100644 --- a/.github/workflows/verify_storybook.yml +++ b/.github/workflows/verify_storybook.yml @@ -51,7 +51,7 @@ jobs: - run: yarn build-storybook - - uses: chromaui/action@c9067691aca4a28d6fbb40d9eea6e144369fbcae # v10 + - uses: chromaui/action@fd0e276c344bab4dc69a023fdf89ffb9b79b3b31 # v11 with: token: ${{ secrets.GITHUB_TOKEN }} # projectToken intentionally shared to allow collaborators to run Chromatic on forks From 26425db3a9214c7e083e90cdbe061e45443eda8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Fern=C3=A1ndez?= Date: Thu, 22 Feb 2024 22:01:08 +0100 Subject: [PATCH 059/176] fix: newline in the middle of the link removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It seems that Docusaurus does not support it Signed-off-by: Miguel Fernández --- docs/backend-system/architecture/01-index.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/backend-system/architecture/01-index.md b/docs/backend-system/architecture/01-index.md index f388dff98d..9379ca545b 100644 --- a/docs/backend-system/architecture/01-index.md +++ b/docs/backend-system/architecture/01-index.md @@ -56,8 +56,7 @@ Just like plugins, modules also have access to services and can depend on their ## Package structure A detailed explanation of the package architecture can be found in the -[Backstage Architecture -Overview](../../overview/architecture-overview/#package-architecture). The +[Backstage Architecture Overview](../../overview/architecture-overview/#package-architecture). The most important packages to consider for this system are the following: - `plugin--backend` houses the implementation of the backend plugins From d5f69bbe1e6edc0cbbfcb31218baded9c26ace79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Fern=C3=A1ndez?= Date: Thu, 22 Feb 2024 22:02:19 +0100 Subject: [PATCH 060/176] fix: revert .md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was not causing the issue Signed-off-by: Miguel Fernández --- docs/backend-system/architecture/01-index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backend-system/architecture/01-index.md b/docs/backend-system/architecture/01-index.md index 9379ca545b..681d0fada2 100644 --- a/docs/backend-system/architecture/01-index.md +++ b/docs/backend-system/architecture/01-index.md @@ -56,7 +56,7 @@ Just like plugins, modules also have access to services and can depend on their ## Package structure A detailed explanation of the package architecture can be found in the -[Backstage Architecture Overview](../../overview/architecture-overview/#package-architecture). The +[Backstage Architecture Overview](../../overview/architecture-overview.md#package-architecture). The most important packages to consider for this system are the following: - `plugin--backend` houses the implementation of the backend plugins From 6c4e1c43b6d45eff2c1e364c5b6163353aca6d9f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 23 Feb 2024 14:27:22 +0100 Subject: [PATCH 061/176] beps/0003: new cookie auth design Signed-off-by: Patrik Oldsberg --- .../README.md | 156 ++++++++++-------- 1 file changed, 91 insertions(+), 65 deletions(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index dc475fa14d..ff952a4fdb 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -68,9 +68,9 @@ Two new backend service interfaces are introduced to support these new features. The proposed design leaves the decision for how different endpoints are protected to the implementation of the plugin backends themselves. This includes whether particular routes should allow anonymous access, access from users authenticated via a cookie, or perhaps only allow access from other plugin backends and external services. This means that integrators do not need to - and do not have the ability to - configure access controls of individual endpoints, except for what the permission system already provides, and what is made available through static configuration or extension points. -In order to allow for cookie-based authentication of incoming user requests, the `auth` plugin backend is extended to be able to issue user tokens with reduced scope, which in turn integrate with the new `AuthService` and `HttpAuthService`. The ability to use cookie auth for requests is an opt-in per route and is only be permitted for read methods (`GET`, `HEAD`, `OPTIONS`). The actual implementation of cookie-based flows will be up to each plugin, but with significant help from the new auth service interfaces. +To ensure a secure-by-default design, there is a default access control policy that applies to all plugin routes, known as the "default auth policy". This policy is to only allow access from authenticated users and services, and is implemented in the `HttpRouterService` interface. In order to allow either unauthenticated access or cookie-based access, a plugin must opt-out of the default auth policy for specific path prefixes, effectively leaving the access control implementation to the plugin itself. This is done through the new `addAuthPolicy` method that is added to the `HttpRouterService` interface. -In order to allow either unauthenticated access or cookie-based access, a plugin must explicitly opt-in the specific path prefixes that these should be available at. This is done through a new method that is added to the `HttpRouterService` interface. +In order to allow for cookie-based authentication of incoming user requests, the `AuthService` is able to issue user tokens with limited scope. These limited scope tokens can still be used to fetch user information and in on-behalf-of service calls, but they are rejected by the default auth policy. The ability to use cookie auth for requests is an advanced use-case where plugins need to handle a lot of the auth logic, for example cookie storage and retrieval. These tokens with limited scope can also be user in other contexts where it is beneficial to avoid storing full user credentials, but instead use credentials that can be upgraded in a controlled manner, such as scaffolder tasks. The `AuthService` implementation can choose to have a longer expiry of the limited tokens compared to the full user tokens, but this is not a requirement. For service-to-service communication we will move away from reusing user tokens in upstream requests. We will instead implement an "On-Behalf-Of" flow where incoming user credentials are encapsulated in a service token for the upstream request. In line with this the new auth service interfaces will aim to make it difficult to directly forward credentials from incoming requests, and instead encourage that plugin backends issue new service credentials for upstream requests. @@ -98,7 +98,7 @@ export type BackstageUserPrincipal = { }; export type BackstageServicePrincipal = { - type: 'user'; + type: 'service'; // Exact format TBD, possibly 'plugin:' or 'external:' subject: string; @@ -120,7 +120,12 @@ export type BackstagePrincipalTypes = { }; export interface AuthService { - authenticate(token: string): Promise; + authenticate( + token: string, + options?: { + allowLimitedAccess?: boolean; + }, + ): Promise; isPrincipal( credentials: BackstageCredentials, @@ -135,6 +140,10 @@ export interface AuthService { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string }>; + + getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }>; } ``` @@ -149,6 +158,7 @@ export interface BackstageUserInfo { } export interface UserInfoService { + // The implementation of this method should support both regular and limited user credentials getUserInfo(credentials: BackstageCredentials): Promise; } ``` @@ -159,13 +169,13 @@ The `UserInfoService` is exported by `@backstage/auth-node`, and the initial imp > Open question: Should this instead be added to the `HttpAuthService`? It may fit a bit better there, but on the other hand it might make sense to add additional policies unrelated to authentication too, such as rate limiting. -The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling either cookie auth or unauthenticated access. +The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling unauthenticated access. ```ts export interface HttpRouterServiceAuthPolicy { // The path matches in the same way as if it was passed to `express.Router.use(path, ...)` path: string; - allow: 'unauthenticated' | 'user-cookie'; + allow: 'unauthenticated'; } export interface HttpRouterService { @@ -209,14 +219,60 @@ export default createBackendPlugin({ register(env) { env.registerInit({ deps: { + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, http: coreServices.httpRouter, }, - async init({ http }) { + async init({ auth, httpAuth, http }) { + const router = Router(); + + // Endpoint that sets the cookie for the user + router.get('/cookie', async (req, res) => { + const { token } = await auth.getLimitedUserToken( + await httpAuth.credentials(req, { allow: ['user'] }), + ); + + res + .cookie(AUTH_COOKIE_NAME, token, getCookieOptions(req)) + .json({ ok: true }); + }); + + // Endpoint protected by cookie auth + router.get( + '/static', + async (req, res, next) => { + const limitedToken = getCookieFromRequest(req); + + if (limitedToken) { + const credentials = await auth.authenticate(limitedToken, { + allowLimitedAccess: true, + }); + + // In this example this check is redundant, but if the intention is to + // only allow users to access this endpoint then this might be necessary. + // In practice this is likely going to be implicit in the sense that if + // then endpoint wants to access user data, then this check has to be done. + if (!auth.isPrincipal(credentials, 'user')) { + throw new AuthenticationError( + 'Auth cookie is not a user token', + ); + } + } else { + await httpAuth.authenticate(req, { + allow: ['user', 'service'], + }); + } + + next(); + }, + express.static(/* ... */), + ); + // The order of these two calls does not matter - http.use(await createRouter(/* ... */)); + http.use(router); http.addAuthPolicy({ path: '/static', - allow: 'user-cookie', + allow: 'unauthenticated', }); }, }); @@ -224,7 +280,7 @@ export default createBackendPlugin({ }); ``` -#### A plugin that allows both public access and cookie auth +#### A plugin that disabled the default auth policy and handles auth by itself ```ts export default createBackendPlugin({ @@ -236,15 +292,8 @@ export default createBackendPlugin({ }, async init({ http }) { http.use(await createRouter(/* ... */)); - http.addAuthPolicy({ path: '/', - allow: 'user-cookie', - }); - - // Unauthenticated access takes precedence, the /public endpoint does not require cookie auth - http.addAuthPolicy({ - path: '/public', allow: 'unauthenticated', }); }, @@ -272,18 +321,10 @@ export interface HttpAuthService { | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', >( req: Request, - options?: { - allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; - }, + options?: { allow?: Array }, ): Promise< BackstageCredentials >; - - // The cookie issued by this method must be consumable by the `credentials` method, which in turn - // should create a credentials object that can be passed to the `getPluginRequestToken` method. - // The issued token must then in turn be a valid token for a user principal with full access. - issueUserCookie(res: Response): Promise; } ``` @@ -377,41 +418,26 @@ router.get('/read-data', (req, res) => { }); ``` -#### Issuing a cookie and allowing user cookie auth on a separate endpoint +#### Using limited user tokens to access user info ```ts -router.get('/cookie', async (req, res) => { - await httpAuth.issueUserCookie(res); // If this is a service call it'll throw - res.json({ ok: true }); +router.get('/read-data', (req, res) => { + const limitedToken = getCookieFromRequest(req); + if (limitedToken) { + throw new AuthenticationError('Missing user auth cookie'); + } + + const credentials = await auth.authenticate(limitedToken, { + allowLimitedAccess: true, + }); + + const { userEntityRef, ownershipEntityRefs } = await userInfo.getUserInfo( + credentials, + ); + + console.log(`User ref=${userEntityRef} ownership=${ownershipEntityRefs}`); + // ... }); - -// Allowing cookie auth is a separate step where you call the addAuthPolicy method -// of the httpRouter API in your plugin setup code. -httpRouter.addAuthPolicy({ - path: '/static', - allow: 'user-cookie', -}); - -// Separate endpoint that serves static content, allowing user cookie auth as -// well as the default user and service auth methods -router.use('/static', express.static(staticContentDir)); -``` - -#### Passing along user identity from a cookie in an upstream request - -```ts -router.get( - '/read-data', - httpAuth.middleware({ allow: ['user-cookie'] }), - (req, res) => { - const credentials = await httpAuth.credentials(req, { allow: ['user'] }); - const { ownershipEntityRefs } = await userInfo.getUserInfo(credentials); - console.log( - `User ref=${credentials.userEntityRef} ownership=${ownershipEntityRefs}`, - ); - // ... - }, -); ``` ### Access Control Configuration @@ -433,12 +459,12 @@ The new `AuthService` and `HttpAuthService` will need backwards compatible imple The backwards compatibility helpers will have the following behavior for each individual service call: -- `auth.authenticate(token)`: If the decoded token has the `backstage` audience, authenticate the token for a user principal using `identity.getIdentity(...)`, otherwise authenticate it using `tokenManager.authenticate(...)` and return a service principal with the subject `external:backstage-plugin`. If a no-op token manager is used then anything but a user token will be treated as a valid service token, which is consistent with existing behavior. +- `auth.authenticate(token, options)`: If the decoded token has the `backstage` audience, authenticate the token for a user principal using `identity.getIdentity(...)`, otherwise authenticate it using `tokenManager.authenticate(...)` and return a service principal with the subject `external:backstage-plugin`. If a no-op token manager is used then anything but a user token will be treated as a valid service token, which is consistent with existing behavior. The limited access option is ignored. - `auth.getOwnServiceCredentials()`: Use original implementation. - `auth.isPrincipal()`: Use original implementation. - `auth.getPluginRequestToken(options)`: Same behavior as the original implementation, using the `tokenManager` to issue service tokens, with the exception that a `none` principal will translate to an empty token rather than an error in order to properly forward calls with a no-op token manager. +- `auth.getLimitedUserToken(credentials)`: This is a no-op and returns the underlying user token with full scope. - `httpAuth.credentials(...)`: Use original implementation. -- `httpAuth.issueUserCookie(...)`: This is a no-op as we do not need to support cookie auth in the legacy adapter. With this compatibility layer in place all plugins will be refactored to always use the new `AuthService` and `HttpAuthService` internally. The old deprecated services are only accepted at the public API boundaries, i.e. `createRouter` and similar. All plugin code beyond that point uses the new services. @@ -475,9 +501,9 @@ Cons: - Can be extremely confusing because the top-level middleware for more lax access will also apply to the more strict access levels. For example ```ts - const cookieRouter = Router(); - cookieRouter.use(rateLimit()); - http.useWithCookieAuthentication(cookieRouter); + const publicRouter = Router(); + publicRouter.use(rateLimit()); + http.useWithCookieAuthentication(publicRouter); const mainRouter = Router(); // rateLimit() will apply here too @@ -487,7 +513,7 @@ Cons: This applied to any similar way of structuring this API, such as a single `.use()` method with additional options: ```ts -http.use(cookieRouter, { allow: ['user-cookie'] }); +http.use(publicRouter, { allow: ['unauthenticated'] }); ``` #### Separate configuration on different paths for `use` @@ -502,7 +528,7 @@ This does have the benefit of letting the framework know which exact routes are // This isn't too bad, but it's extremely similar to the addAuthPolicy() method since // we're just matching on the path. The benefit of addAuthPolicy is that it allows you // to keep everything in a singe router if desired. -http.use('/static', cookieRouter, { allow: ['user-cookie'] }); +http.use('/static', publicRouter, { allow: ['unauthenticated'] }); ``` #### Complete opt-out From 334c5feb0d5a05107849fa336aa24af42f071d53 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Feb 2024 10:19:52 +0000 Subject: [PATCH 062/176] fix(deps): update dependency marked to v12 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-6a81dd3.md | 5 +++++ plugins/adr-backend/package.json | 2 +- yarn.lock | 11 ++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .changeset/renovate-6a81dd3.md diff --git a/.changeset/renovate-6a81dd3.md b/.changeset/renovate-6a81dd3.md new file mode 100644 index 0000000000..9cf6fee161 --- /dev/null +++ b/.changeset/renovate-6a81dd3.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-adr-backend': patch +--- + +Updated dependency `marked` to `^12.0.0`. diff --git a/plugins/adr-backend/package.json b/plugins/adr-backend/package.json index 73def32c34..ace4f5dbc7 100644 --- a/plugins/adr-backend/package.json +++ b/plugins/adr-backend/package.json @@ -41,7 +41,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "luxon": "^3.0.0", - "marked": "^4.0.14", + "marked": "^12.0.0", "node-fetch": "^2.6.5", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/yarn.lock b/yarn.lock index ef671a6c59..d09104f1bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4230,7 +4230,7 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 luxon: ^3.0.0 - marked: ^4.0.14 + marked: ^12.0.0 node-fetch: ^2.6.5 supertest: ^6.1.3 winston: ^3.2.1 @@ -34176,6 +34176,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^12.0.0": + version: 12.0.0 + resolution: "marked@npm:12.0.0" + bin: + marked: bin/marked.js + checksum: 973e803debf7eca946213cd583792c2968ecab1b6698653604272d5406f9f07375a4865a7a28633a7200c8a4682761f8d866198fb36c10c686e3bda3a9b36fc9 + languageName: node + linkType: hard + "marked@npm:^4.0.14": version: 4.3.0 resolution: "marked@npm:4.3.0" From 20440140d29a4f3e3cddd85a31c3751a531cff2c Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Mon, 19 Feb 2024 16:11:24 -0500 Subject: [PATCH 063/176] fix(adr-backend): import built in types from marked Signed-off-by: Phil Kuang --- plugins/adr-backend/package.json | 1 - plugins/adr-backend/src/search/madrParser.ts | 20 +++++++++----------- yarn.lock | 8 -------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/plugins/adr-backend/package.json b/plugins/adr-backend/package.json index ace4f5dbc7..0216a8d4a1 100644 --- a/plugins/adr-backend/package.json +++ b/plugins/adr-backend/package.json @@ -48,7 +48,6 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@types/marked": "^5.0.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/adr-backend/src/search/madrParser.ts b/plugins/adr-backend/src/search/madrParser.ts index 0dd1437538..bb466d13c3 100644 --- a/plugins/adr-backend/src/search/madrParser.ts +++ b/plugins/adr-backend/src/search/madrParser.ts @@ -15,29 +15,27 @@ */ import { DateTime } from 'luxon'; -import { marked } from 'marked'; +import { marked, Tokens, TokensList } from 'marked'; import { MADR_DATE_FORMAT, parseMadrWithFrontmatter, } from '@backstage/plugin-adr-common'; -const getTitle = (tokens: marked.TokensList): string | undefined => { +const getTitle = (tokens: TokensList): string | undefined => { return ( - tokens.find( - t => t.type === 'heading' && t.depth === 1, - ) as marked.Tokens.Heading + tokens.find(t => t.type === 'heading' && t.depth === 1) as Tokens.Heading )?.text; }; -const getStatusForV2Format = (tokens: marked.TokensList): string | undefined => - (tokens.find(t => t.type === 'list') as marked.Tokens.List)?.items +const getStatusForV2Format = (tokens: TokensList): string | undefined => + (tokens.find(t => t.type === 'list') as Tokens.List)?.items ?.find(t => /^status:/i.test(t.text)) ?.text.replace(/^status:/i, '') .trim() .toLocaleLowerCase('en-US'); -const getDateForV2Format = (tokens: marked.TokensList): string | undefined => { - const listTokens = (tokens.find(t => t.type === 'list') as marked.Tokens.List) +const getDateForV2Format = (tokens: TokensList): string | undefined => { + const listTokens = (tokens.find(t => t.type === 'list') as Tokens.List) ?.items; const adrDateTime = listTokens ?.find(t => /^date:/i.test(t.text)) @@ -47,13 +45,13 @@ const getDateForV2Format = (tokens: marked.TokensList): string | undefined => { }; const getStatus = ( - tokens: marked.TokensList, + tokens: TokensList, frontMatterStatus?: string, ): string | undefined => { return frontMatterStatus ?? getStatusForV2Format(tokens); }; const getDate = ( - tokens: marked.TokensList, + tokens: TokensList, dateFormat: string, frontMatterDate?: string, ): string | undefined => { diff --git a/yarn.lock b/yarn.lock index d09104f1bd..35933fbed2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4225,7 +4225,6 @@ __metadata: "@backstage/plugin-adr-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@types/express": ^4.17.6 - "@types/marked": ^5.0.0 "@types/supertest": ^2.0.8 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -18900,13 +18899,6 @@ __metadata: languageName: node linkType: hard -"@types/marked@npm:^5.0.0": - version: 5.0.2 - resolution: "@types/marked@npm:5.0.2" - checksum: 2875618970bd5aaba472e313c799bbe241fe9e31d1e79782841a0cc04e08ab2a98653166f1fb99bf8bcf140d3878c3ab960a12aa8f0fb949d8277e8a01d3411b - languageName: node - linkType: hard - "@types/mdast@npm:^3.0.0, @types/mdast@npm:^3.0.3": version: 3.0.10 resolution: "@types/mdast@npm:3.0.10" From 432fe068fed4c4c44087322a9eba465321649a43 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 23 Feb 2024 15:38:20 +0100 Subject: [PATCH 064/176] beps/0003: bring back high-level cookie helpers Signed-off-by: Patrik Oldsberg --- .../README.md | 74 +++++++------------ 1 file changed, 25 insertions(+), 49 deletions(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index ff952a4fdb..fe3cc7bdb8 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -70,7 +70,7 @@ The proposed design leaves the decision for how different endpoints are protecte To ensure a secure-by-default design, there is a default access control policy that applies to all plugin routes, known as the "default auth policy". This policy is to only allow access from authenticated users and services, and is implemented in the `HttpRouterService` interface. In order to allow either unauthenticated access or cookie-based access, a plugin must opt-out of the default auth policy for specific path prefixes, effectively leaving the access control implementation to the plugin itself. This is done through the new `addAuthPolicy` method that is added to the `HttpRouterService` interface. -In order to allow for cookie-based authentication of incoming user requests, the `AuthService` is able to issue user tokens with limited scope. These limited scope tokens can still be used to fetch user information and in on-behalf-of service calls, but they are rejected by the default auth policy. The ability to use cookie auth for requests is an advanced use-case where plugins need to handle a lot of the auth logic, for example cookie storage and retrieval. These tokens with limited scope can also be user in other contexts where it is beneficial to avoid storing full user credentials, but instead use credentials that can be upgraded in a controlled manner, such as scaffolder tasks. The `AuthService` implementation can choose to have a longer expiry of the limited tokens compared to the full user tokens, but this is not a requirement. +In order to allow for cookie-based authentication of incoming user requests, the `AuthService` is able to issue user tokens with limited scope. These limited scope tokens can still be used to fetch user information and in on-behalf-of service calls, but they are rejected by the default auth policy. The `HttpAuthService` provides a standardized way of handling cookies, which integrates with the `'user-cookie'` auth policy. The limited tokens can also be used in other contexts where it is beneficial to avoid storing full user credentials, but instead use credentials that can be upgraded in a controlled manner, such as scaffolder tasks. The `AuthService` implementation can choose to have a longer expiry of the limited tokens compared to the full user tokens, but this is not a requirement. For service-to-service communication we will move away from reusing user tokens in upstream requests. We will instead implement an "On-Behalf-Of" flow where incoming user credentials are encapsulated in a service token for the upstream request. In line with this the new auth service interfaces will aim to make it difficult to directly forward credentials from incoming requests, and instead encourage that plugin backends issue new service credentials for upstream requests. @@ -169,13 +169,13 @@ The `UserInfoService` is exported by `@backstage/auth-node`, and the initial imp > Open question: Should this instead be added to the `HttpAuthService`? It may fit a bit better there, but on the other hand it might make sense to add additional policies unrelated to authentication too, such as rate limiting. -The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling unauthenticated access. +The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling cookie or unauthenticated access. ```ts export interface HttpRouterServiceAuthPolicy { // The path matches in the same way as if it was passed to `express.Router.use(path, ...)` path: string; - allow: 'unauthenticated'; + allow: 'unauthenticated' | 'user-cookie'; } export interface HttpRouterService { @@ -211,7 +211,7 @@ export default createBackendPlugin({ This is expected to be the pattern for the vast majority of plugins. -#### A plugin with an endpoint that only allows cookie auth +#### A plugin with a cookie-based authentication endpoint ```ts export default createBackendPlugin({ @@ -228,51 +228,19 @@ export default createBackendPlugin({ // Endpoint that sets the cookie for the user router.get('/cookie', async (req, res) => { - const { token } = await auth.getLimitedUserToken( - await httpAuth.credentials(req, { allow: ['user'] }), - ); + await httpAuth.issueUserCookie(req); - res - .cookie(AUTH_COOKIE_NAME, token, getCookieOptions(req)) - .json({ ok: true }); + res.json({ ok: true }); }); // Endpoint protected by cookie auth - router.get( - '/static', - async (req, res, next) => { - const limitedToken = getCookieFromRequest(req); - - if (limitedToken) { - const credentials = await auth.authenticate(limitedToken, { - allowLimitedAccess: true, - }); - - // In this example this check is redundant, but if the intention is to - // only allow users to access this endpoint then this might be necessary. - // In practice this is likely going to be implicit in the sense that if - // then endpoint wants to access user data, then this check has to be done. - if (!auth.isPrincipal(credentials, 'user')) { - throw new AuthenticationError( - 'Auth cookie is not a user token', - ); - } - } else { - await httpAuth.authenticate(req, { - allow: ['user', 'service'], - }); - } - - next(); - }, - express.static(/* ... */), - ); + router.get('/static', express.static(/* ... */)); // The order of these two calls does not matter http.use(router); http.addAuthPolicy({ path: '/static', - allow: 'unauthenticated', + allow: 'user-cookie', }); }, }); @@ -321,10 +289,21 @@ export interface HttpAuthService { | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', >( req: Request, - options?: { allow?: Array }, + options?: { + allow?: Array; + allowLimitedAccess?: boolean; + }, ): Promise< BackstageCredentials >; + + issueUserCookie( + res: Response, + options?: { + // If credentials are not provided, they will be read from the request + credentials?: BackstageCredentials; + }, + ): Promise; } ``` @@ -422,12 +401,8 @@ router.get('/read-data', (req, res) => { ```ts router.get('/read-data', (req, res) => { - const limitedToken = getCookieFromRequest(req); - if (limitedToken) { - throw new AuthenticationError('Missing user auth cookie'); - } - - const credentials = await auth.authenticate(limitedToken, { + const credentials = await httpAuth.credentials(req, { + allow: ['user'], allowLimitedAccess: true, }); @@ -465,6 +440,7 @@ The backwards compatibility helpers will have the following behavior for each in - `auth.getPluginRequestToken(options)`: Same behavior as the original implementation, using the `tokenManager` to issue service tokens, with the exception that a `none` principal will translate to an empty token rather than an error in order to properly forward calls with a no-op token manager. - `auth.getLimitedUserToken(credentials)`: This is a no-op and returns the underlying user token with full scope. - `httpAuth.credentials(...)`: Use original implementation. +- `httpAuth.issueUserCookie(...)`: This is a no-op as we do not need to support cookie auth in the legacy adapter. With this compatibility layer in place all plugins will be refactored to always use the new `AuthService` and `HttpAuthService` internally. The old deprecated services are only accepted at the public API boundaries, i.e. `createRouter` and similar. All plugin code beyond that point uses the new services. @@ -503,7 +479,7 @@ Cons: ```ts const publicRouter = Router(); publicRouter.use(rateLimit()); - http.useWithCookieAuthentication(publicRouter); + http.useWithoutAuthentication(publicRouter); const mainRouter = Router(); // rateLimit() will apply here too @@ -528,7 +504,7 @@ This does have the benefit of letting the framework know which exact routes are // This isn't too bad, but it's extremely similar to the addAuthPolicy() method since // we're just matching on the path. The benefit of addAuthPolicy is that it allows you // to keep everything in a singe router if desired. -http.use('/static', publicRouter, { allow: ['unauthenticated'] }); +http.use('/static', cookieRouter, { allow: ['user-cookie'] }); ``` #### Complete opt-out From 4ad878b48dd8c68f559e91734a70f0499290b2ee Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Fri, 23 Feb 2024 09:27:50 -0700 Subject: [PATCH 065/176] Remove outdated permission-maintainers Signed-off-by: Tim Hansen --- OWNERS.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/OWNERS.md b/OWNERS.md index 4c47f28253..797592c8dc 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -73,17 +73,14 @@ Team: @backstage/permission-maintainers Scope: The Permission Framework and plugins integrating with the permission framework -| Name | Organization | Team | GitHub | Discord | -| -------------------- | ------------ | --------------- | ----------------------------------------------- | ---------------- | -| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 | -| Claire Casey | Spotify | Imaginary Goats | [clairelcasey](http://github.com/clairelcasey) | clairecasey#2710 | -| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 | -| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 | -| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 | -| Lynette Lopez | Spotify | Imaginary Goats | [lynettelopez](https://github.com/lynettelopez) | lynettelopez | -| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 | -| Tim Hansen | Spotify | Imaginary Goats | [timbonicus](http://github.com/timbonicus) | timbonicus#6871 | -| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 | +| Name | Organization | Team | GitHub | Discord | +| -------------------- | ------------ | --------------- | ------------------------------------------ | ------------- | +| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 | +| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 | +| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 | +| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 | +| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 | +| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 | ### TechDocs From 08bcdf9bbc0c4d08b7460d90ddd2d361907f0046 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Feb 2024 23:08:24 +0000 Subject: [PATCH 066/176] chore(deps): update dependency @types/react to v18.2.58 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 1d2fe96275..63aee59f18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19375,13 +19375,13 @@ __metadata: linkType: hard "@types/react@npm:^18": - version: 18.2.57 - resolution: "@types/react@npm:18.2.57" + version: 18.2.58 + resolution: "@types/react@npm:18.2.58" dependencies: "@types/prop-types": "*" "@types/scheduler": "*" csstype: ^3.0.2 - checksum: 01e7a3424162468428f3b28acec5e5c6cd1e26775ff605d0f46c883dea2d835924873d36b9ea0b75e40c9593aa78ca56a8ccde66bd58dbf6ecb0dd95af28609d + checksum: 42551e30c8a54161a11b2ecd11406782ddba4472a4471d45034c551295263d56f06234f283526d0c0420352ce9ce9675b2a6c65db7a287d9613643d3ceaaf1f0 languageName: node linkType: hard From 341560e4bff1acd0896662a6d0d96f6a1b1f5118 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Feb 2024 23:54:42 +0000 Subject: [PATCH 067/176] chore(deps): update dependency eslint to v8.57.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/yarn.lock b/yarn.lock index 63aee59f18..5aa82df2e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11162,10 +11162,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:8.56.0": - version: 8.56.0 - resolution: "@eslint/js@npm:8.56.0" - checksum: 5804130574ef810207bdf321c265437814e7a26f4e6fac9b496de3206afd52f533e09ec002a3be06cd9adcc9da63e727f1883938e663c4e4751c007d5b58e539 +"@eslint/js@npm:8.57.0": + version: 8.57.0 + resolution: "@eslint/js@npm:8.57.0" + checksum: 315dc65b0e9893e2bff139bddace7ea601ad77ed47b4550e73da8c9c2d2766c7a575c3cddf17ef85b8fd6a36ff34f91729d0dcca56e73ca887c10df91a41b0bb languageName: node linkType: hard @@ -11827,14 +11827,14 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.11.13": - version: 0.11.13 - resolution: "@humanwhocodes/config-array@npm:0.11.13" +"@humanwhocodes/config-array@npm:^0.11.14": + version: 0.11.14 + resolution: "@humanwhocodes/config-array@npm:0.11.14" dependencies: - "@humanwhocodes/object-schema": ^2.0.1 - debug: ^4.1.1 + "@humanwhocodes/object-schema": ^2.0.2 + debug: ^4.3.1 minimatch: ^3.0.5 - checksum: f8ea57b0d7ed7f2d64cd3944654976829d9da91c04d9c860e18804729a33f7681f78166ef4c761850b8c324d362f7d53f14c5c44907a6b38b32c703ff85e4805 + checksum: 861ccce9eaea5de19546653bccf75bf09fe878bc39c3aab00aeee2d2a0e654516adad38dd1098aab5e3af0145bbcbf3f309bdf4d964f8dab9dcd5834ae4c02f2 languageName: node linkType: hard @@ -11845,10 +11845,10 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/object-schema@npm:^2.0.1": - version: 2.0.1 - resolution: "@humanwhocodes/object-schema@npm:2.0.1" - checksum: 24929487b1ed48795d2f08346a0116cc5ee4634848bce64161fb947109352c562310fd159fc64dda0e8b853307f5794605191a9547f7341158559ca3c8262a45 +"@humanwhocodes/object-schema@npm:^2.0.2": + version: 2.0.2 + resolution: "@humanwhocodes/object-schema@npm:2.0.2" + checksum: 2fc11503361b5fb4f14714c700c02a3f4c7c93e9acd6b87a29f62c522d90470f364d6161b03d1cc618b979f2ae02aed1106fd29d302695d8927e2fc8165ba8ee languageName: node linkType: hard @@ -26925,14 +26925,14 @@ __metadata: linkType: hard "eslint@npm:^8.33.0, eslint@npm:^8.6.0": - version: 8.56.0 - resolution: "eslint@npm:8.56.0" + version: 8.57.0 + resolution: "eslint@npm:8.57.0" dependencies: "@eslint-community/eslint-utils": ^4.2.0 "@eslint-community/regexpp": ^4.6.1 "@eslint/eslintrc": ^2.1.4 - "@eslint/js": 8.56.0 - "@humanwhocodes/config-array": ^0.11.13 + "@eslint/js": 8.57.0 + "@humanwhocodes/config-array": ^0.11.14 "@humanwhocodes/module-importer": ^1.0.1 "@nodelib/fs.walk": ^1.2.8 "@ungap/structured-clone": ^1.2.0 @@ -26968,7 +26968,7 @@ __metadata: text-table: ^0.2.0 bin: eslint: bin/eslint.js - checksum: 883436d1e809b4a25d9eb03d42f584b84c408dbac28b0019f6ea07b5177940bf3cca86208f749a6a1e0039b63e085ee47aca1236c30721e91f0deef5cc5a5136 + checksum: 3a48d7ff85ab420a8447e9810d8087aea5b1df9ef68c9151732b478de698389ee656fd895635b5f2871c89ee5a2652b3f343d11e9db6f8486880374ebc74a2d9 languageName: node linkType: hard From fd61d39bc53ebb98af3f43641f6384f09dff88ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 00:48:52 +0000 Subject: [PATCH 068/176] fix(deps): update dependency testcontainers to v10 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-08c5b50.md | 5 + packages/backend-test-utils/package.json | 2 +- yarn.lock | 170 ++++++++++++++++------- 3 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 .changeset/renovate-08c5b50.md diff --git a/.changeset/renovate-08c5b50.md b/.changeset/renovate-08c5b50.md new file mode 100644 index 0000000000..9ccefc8f0e --- /dev/null +++ b/.changeset/renovate-08c5b50.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Updated dependency `testcontainers` to `^10.0.0`. diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 4153fba211..34233a6f54 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -56,7 +56,7 @@ "msw": "^1.0.0", "mysql2": "^3.0.0", "pg": "^8.11.3", - "testcontainers": "^8.1.2", + "testcontainers": "^10.0.0", "textextensions": "^5.16.0", "uuid": "^8.0.0" }, diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..99070769d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3490,7 +3490,7 @@ __metadata: mysql2: ^3.0.0 pg: ^8.11.3 supertest: ^6.1.3 - testcontainers: ^8.1.2 + testcontainers: ^10.0.0 textextensions: ^5.16.0 uuid: ^8.0.0 peerDependencies: @@ -17941,15 +17941,6 @@ __metadata: languageName: node linkType: hard -"@types/archiver@npm:^5.3.1": - version: 5.3.4 - resolution: "@types/archiver@npm:5.3.4" - dependencies: - "@types/readdir-glob": "*" - checksum: 4ef27b99091ada9b8f13017d5b9e6d42a439e35a7858b30e040c408e081d98d8db6307b0762500288b5da38cab9823c4756b6abae1fdd2658d42bfb09eb7c5fb - languageName: node - linkType: hard - "@types/archiver@npm:^6.0.0": version: 6.0.2 resolution: "@types/archiver@npm:6.0.2" @@ -18406,13 +18397,13 @@ __metadata: languageName: node linkType: hard -"@types/dockerode@npm:^3.3.0, @types/dockerode@npm:^3.3.8": - version: 3.3.23 - resolution: "@types/dockerode@npm:3.3.23" +"@types/dockerode@npm:^3.3.0, @types/dockerode@npm:^3.3.21": + version: 3.3.24 + resolution: "@types/dockerode@npm:3.3.24" dependencies: "@types/docker-modem": "*" "@types/node": "*" - checksum: 065e9ae43f13641e0df149335914d10af95e559002efe5a5de8e56df9a21b4309b18dbc04fd4c59a4e893c4fedc7df892db28e9d25457bce0c4fd33d21a67834 + checksum: 00329ba9225f5b57bfc0ba8c4dddb17100ebe13c5192fe8a14fce59eec456d258e814b6c78df26d7d7bb878fc38455f559f278490c9c8efad8f708335643b40e languageName: node linkType: hard @@ -21058,7 +21049,7 @@ __metadata: languageName: node linkType: hard -"archiver@npm:^5.3.1": +"archiver@npm:^5.3.2": version: 5.3.2 resolution: "archiver@npm:5.3.2" dependencies: @@ -21430,10 +21421,10 @@ __metadata: languageName: node linkType: hard -"async-lock@npm:^1.1.0": - version: 1.2.4 - resolution: "async-lock@npm:1.2.4" - checksum: 9b8cf65bb9ac7b58ff95539a03b73d51f64d0aea95cde1ebf787859670a8998f0a5258f118db6b54305bf6ed20cf3a2f923f4dd69a58d472fa78cca436c42342 +"async-lock@npm:^1.1.0, async-lock@npm:^1.4.0": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 29e70cd892932b7c202437786cedc39ff62123cb6941014739bd3cabd6106326416e9e7c21285a5d1dc042cad239a0f7ec9c44658491ee4a615fd36a21c1d10a languageName: node linkType: hard @@ -21899,6 +21890,41 @@ __metadata: languageName: node linkType: hard +"bare-events@npm:^2.0.0, bare-events@npm:^2.2.0": + version: 2.2.0 + resolution: "bare-events@npm:2.2.0" + checksum: b3001d61cbb7e6c91c7e47ed1d5701512f94c68955a88c1fe368ff313ba68f372fd701f422d1604fd6ac6e2237024d99373aa14e43a92696755a1f7ae46a8626 + languageName: node + linkType: hard + +"bare-fs@npm:^2.1.1": + version: 2.2.0 + resolution: "bare-fs@npm:2.2.0" + dependencies: + bare-events: ^2.0.0 + bare-os: ^2.0.0 + bare-path: ^2.0.0 + streamx: ^2.13.0 + checksum: 8832abc6c222bdfc8dcf37253493eefdd153048dd2fd482fe7722d6fea083f9e44574197a47e2b0046057f9fb271078ed799d03663e387ad06d2ab116a64cce4 + languageName: node + linkType: hard + +"bare-os@npm:^2.0.0, bare-os@npm:^2.1.0": + version: 2.2.0 + resolution: "bare-os@npm:2.2.0" + checksum: ed78e2f3ea498e35c7565532ae3aa3b85a7e5e223ab6353de64864823cadff02a2a8b7722e9a6c1a0ff56cb9f21f23ada8e88a085cc0a5d38a7c1bcf65e8f7fd + languageName: node + linkType: hard + +"bare-path@npm:^2.0.0, bare-path@npm:^2.1.0": + version: 2.1.0 + resolution: "bare-path@npm:2.1.0" + dependencies: + bare-os: ^2.1.0 + checksum: 03f260e72bd0ae0df4cd712322a2d3c8c16701ffaa55cf2d517ae62b7f78c64b7ec5bba81ec579367f966472481f5160db282e6663bd0fc8cfb09ebe272d8bba + languageName: node + linkType: hard + "base16@npm:^1.0.0": version: 1.0.0 resolution: "base16@npm:1.0.0" @@ -25476,12 +25502,12 @@ __metadata: languageName: node linkType: hard -"docker-compose@npm:^0.23.17": - version: 0.23.17 - resolution: "docker-compose@npm:0.23.17" +"docker-compose@npm:^0.24.2": + version: 0.24.6 + resolution: "docker-compose@npm:0.24.6" dependencies: - yaml: ^1.10.2 - checksum: c308bf067cabe178d245b3e499119937b1d2a5effdc9fac6227e04be4308a0250ca7bb1471789b3d0492ea2ce83f74e40b7517a9a5cb540a21355a64e4ad5d3c + yaml: ^2.2.2 + checksum: 7926e72d7feb9e7feb9e9d46460e18a61cf759cdb9004d7783b58a815eb22b3fbd6402db903cdb764be289cacb1960ef6d19904a9ee435991d8937b146be590f languageName: node linkType: hard @@ -25509,7 +25535,7 @@ __metadata: languageName: node linkType: hard -"dockerode@npm:^3.3.1": +"dockerode@npm:^3.3.5": version: 3.3.5 resolution: "dockerode@npm:3.3.5" dependencies: @@ -35861,7 +35887,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.5, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": +"node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.5, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -38745,12 +38771,23 @@ __metadata: languageName: node linkType: hard -"properties-reader@npm:^2.2.0": - version: 2.2.0 - resolution: "properties-reader@npm:2.2.0" +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: ^4.2.4 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939 + languageName: node + linkType: hard + +"properties-reader@npm:^2.3.0": + version: 2.3.0 + resolution: "properties-reader@npm:2.3.0" dependencies: mkdirp: ^1.0.4 - checksum: a5c5684b1e16633cb695f4fef5476a63f43298619381e8f7f609448f3bda32b26d7c9042b57a427a6dedd1c7fdca1a01ccbe8771b4311ed534079b676c64eec7 + checksum: cbf59e862dc507f8ce1f8d7641ed9737119f16a1d4dad8e79f17b303aaca1c6af7d36ddfef0f649cab4d200ba4334ac159af0b238f6978a085f5b1b5126b6cc3 languageName: node linkType: hard @@ -42440,13 +42477,17 @@ __metadata: languageName: node linkType: hard -"streamx@npm:^2.15.0": - version: 2.15.5 - resolution: "streamx@npm:2.15.5" +"streamx@npm:^2.13.0, streamx@npm:^2.15.0": + version: 2.16.1 + resolution: "streamx@npm:2.16.1" dependencies: + bare-events: ^2.2.0 fast-fifo: ^1.1.0 queue-tick: ^1.0.1 - checksum: 52e0ec94026d67c9e2e2e1090f05e5b138c2f2822462d9a8ef4a4805625a31d103e55ea5267fcd9bfe041374926424e42aec2dda28a85cb9de42c2a16d416d94 + dependenciesMeta: + bare-events: + optional: true + checksum: 6bbb4c38c0ab6ddbe0857d55e72f71288f308f2a9f4413b7b07391cdf9f94232ffc2bbe40a1212d2e09634ecdbd5052b444c73cc8d67ae1c97e2b7e553dad559 languageName: node linkType: hard @@ -43121,7 +43162,7 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^2.0.0, tar-fs@npm:^2.1.1": +"tar-fs@npm:^2.0.0": version: 2.1.1 resolution: "tar-fs@npm:2.1.1" dependencies: @@ -43133,6 +43174,23 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^3.0.4": + version: 3.0.5 + resolution: "tar-fs@npm:3.0.5" + dependencies: + bare-fs: ^2.1.1 + bare-path: ^2.1.0 + pump: ^3.0.0 + tar-stream: ^3.1.5 + dependenciesMeta: + bare-fs: + optional: true + bare-path: + optional: true + checksum: e31c7e3e525fec0afecdec1cac58071809e396187725f2eba442f08a4c5649c8cd6b7ce25982f9a91bb0f055628df47c08177dd2ea4f5dafd3c22f42f8da8f00 + languageName: node + linkType: hard + "tar-fs@npm:~2.0.1": version: 2.0.1 resolution: "tar-fs@npm:2.0.1" @@ -43158,14 +43216,14 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^3.0.0": - version: 3.1.6 - resolution: "tar-stream@npm:3.1.6" +"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": + version: 3.1.7 + resolution: "tar-stream@npm:3.1.7" dependencies: b4a: ^1.6.4 fast-fifo: ^1.2.0 streamx: ^2.15.0 - checksum: f3627f918581976e954ff03cb8d370551053796b82564f8c7ca8fac84c48e4d042026d0854fc222171a34ff9c682b72fae91be9c9b0a112d4c54f9e4f443e9c5 + checksum: 6393a6c19082b17b8dcc8e7fd349352bb29b4b8bfe1075912b91b01743ba6bb4298f5ff0b499a3bbaf82121830e96a1a59d4f21a43c0df339e54b01789cb8cc6 languageName: node linkType: hard @@ -43315,23 +43373,26 @@ __metadata: languageName: node linkType: hard -"testcontainers@npm:^8.1.2": - version: 8.16.0 - resolution: "testcontainers@npm:8.16.0" +"testcontainers@npm:^10.0.0": + version: 10.7.1 + resolution: "testcontainers@npm:10.7.1" dependencies: "@balena/dockerignore": ^1.0.2 - "@types/archiver": ^5.3.1 - "@types/dockerode": ^3.3.8 - archiver: ^5.3.1 + "@types/dockerode": ^3.3.21 + archiver: ^5.3.2 + async-lock: ^1.4.0 byline: ^5.0.0 debug: ^4.3.4 - docker-compose: ^0.23.17 - dockerode: ^3.3.1 + docker-compose: ^0.24.2 + dockerode: ^3.3.5 get-port: ^5.1.1 - properties-reader: ^2.2.0 + node-fetch: ^2.7.0 + proper-lockfile: ^4.1.2 + properties-reader: ^2.3.0 ssh-remote-port-forward: ^1.0.4 - tar-fs: ^2.1.1 - checksum: 2fb8250591691a4bd86640b53e13236ad507ba9e03ac3043683de5e9dd632bc29d52827c22ccfe2b0d28dec6896cbaa56dcb153ce65f7f74212ddefc204e8d6a + tar-fs: ^3.0.4 + tmp: ^0.2.1 + checksum: 3ecb439914fab1147943d7d97e4021309fa69f3fcdbc153f4cf3fcf1feb03415da402fb914811189a3606409d58de1174910e144fb4c6c7e3510cc2cb59911b2 languageName: node linkType: hard @@ -43474,6 +43535,15 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.1": + version: 0.2.1 + resolution: "tmp@npm:0.2.1" + dependencies: + rimraf: ^3.0.0 + checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" From 75f686bad9173290b809ceabaf9a2e95f3b659db Mon Sep 17 00:00:00 2001 From: rui ma Date: Sun, 25 Feb 2024 18:53:16 +0800 Subject: [PATCH 069/176] fix: view component url use LowerCase Signed-off-by: rui ma --- .changeset/silver-impalas-run.md | 5 +++++ .../StepFinishImportLocation.tsx | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/silver-impalas-run.md diff --git a/.changeset/silver-impalas-run.md b/.changeset/silver-impalas-run.md new file mode 100644 index 0000000000..22df521680 --- /dev/null +++ b/.changeset/silver-impalas-run.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Fixed an issue generating a wrong entity link at the end of the import process diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx index 38372e59ba..cf5427517a 100644 --- a/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx +++ b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx @@ -22,7 +22,7 @@ import { EntityListComponent } from '../EntityListComponent'; import { PrepareResult } from '../useImportState'; import { Link } from '@backstage/core-components'; import partition from 'lodash/partition'; -import { CompoundEntityRef } from '@backstage/catalog-model'; +import { CompoundEntityRef, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { useRouteRef } from '@backstage/core-plugin-api'; @@ -46,7 +46,12 @@ const filterComponentEntity = ( entity.kind.toLocaleLowerCase('en-US'), ) ) { - return entity; + return { + kind: entity.kind.toLocaleLowerCase('en-US'), + namespace: + entity.namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE, + name: entity.name, + }; } } } From 32eee9dd9f5d7ae20f75db73796ae4b9634d34ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 25 Feb 2024 23:13:51 +0100 Subject: [PATCH 070/176] fixup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/backend-test-utils/src/database/startMysqlContainer.ts | 2 +- .../backend-test-utils/src/database/startPostgresContainer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-test-utils/src/database/startMysqlContainer.ts b/packages/backend-test-utils/src/database/startMysqlContainer.ts index fa99389cbc..2b4c917393 100644 --- a/packages/backend-test-utils/src/database/startMysqlContainer.ts +++ b/packages/backend-test-utils/src/database/startMysqlContainer.ts @@ -54,7 +54,7 @@ export async function startMysqlContainer(image: string) { const container = await new GenericContainer(image) .withExposedPorts(3306) - .withEnv('MYSQL_ROOT_PASSWORD', password) + .withEnvironment({ MYSQL_ROOT_PASSWORD: password }) .withTmpFs({ '/var/lib/mysql': 'rw' }) .start(); diff --git a/packages/backend-test-utils/src/database/startPostgresContainer.ts b/packages/backend-test-utils/src/database/startPostgresContainer.ts index 81358e01d3..7a1c3f89c5 100644 --- a/packages/backend-test-utils/src/database/startPostgresContainer.ts +++ b/packages/backend-test-utils/src/database/startPostgresContainer.ts @@ -54,7 +54,7 @@ export async function startPostgresContainer(image: string) { const container = await new GenericContainer(image) .withExposedPorts(5432) - .withEnv('POSTGRES_PASSWORD', password) + .withEnvironment({ POSTGRES_PASSWORD: password }) .withTmpFs({ '/var/lib/postgresql/data': 'rw' }) .start(); From 9802004e10d4f97bdec40f49e50ea090498c5146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 23 Feb 2024 17:12:27 +0100 Subject: [PATCH 071/176] auth: convert permission-backend to the new auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/fifty-insects-yell.md | 5 ++ .changeset/loud-dolls-exist.md | 5 ++ .changeset/sour-olives-carry.md | 5 ++ .changeset/unlucky-jobs-report.md | 7 ++ .../userInfo/userInfoServiceFactory.ts | 7 +- packages/backend-common/api-report.md | 29 +++---- .../src/auth/createLegacyAuthAdapters.test.ts | 18 ++++- .../src/auth/createLegacyAuthAdapters.ts | 61 ++++++++++++--- packages/backend-test-utils/api-report.md | 13 ++++ .../next/services/MockUserInfoService.test.ts | 55 ++++++++++++++ .../src/next/services/MockUserInfoService.ts | 55 ++++++++++++++ .../src/next/services/mockServices.ts | 43 +++++++++++ .../src/next/wiring/TestBackend.ts | 1 + plugins/permission-backend/api-report.md | 15 +++- plugins/permission-backend/package.json | 1 + plugins/permission-backend/src/plugin.ts | 18 ++++- .../PermissionIntegrationClient.test.ts | 76 +++++++++++-------- .../service/PermissionIntegrationClient.ts | 30 ++++++-- .../src/service/router.test.ts | 67 +++++++--------- .../permission-backend/src/service/router.ts | 57 +++++++++++--- yarn.lock | 1 + 21 files changed, 448 insertions(+), 121 deletions(-) create mode 100644 .changeset/fifty-insects-yell.md create mode 100644 .changeset/loud-dolls-exist.md create mode 100644 .changeset/sour-olives-carry.md create mode 100644 .changeset/unlucky-jobs-report.md create mode 100644 packages/backend-test-utils/src/next/services/MockUserInfoService.test.ts create mode 100644 packages/backend-test-utils/src/next/services/MockUserInfoService.ts diff --git a/.changeset/fifty-insects-yell.md b/.changeset/fifty-insects-yell.md new file mode 100644 index 0000000000..feedd844a5 --- /dev/null +++ b/.changeset/fifty-insects-yell.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Added `mockServices.userInfo`, which now also automatically is made available in test backends. diff --git a/.changeset/loud-dolls-exist.md b/.changeset/loud-dolls-exist.md new file mode 100644 index 0000000000..5dce8d71f2 --- /dev/null +++ b/.changeset/loud-dolls-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added the `UserInfoApi` as both an optional input and as an output for `createLegacyAuthAdapters` diff --git a/.changeset/sour-olives-carry.md b/.changeset/sour-olives-carry.md new file mode 100644 index 0000000000..d0c3bdd6d0 --- /dev/null +++ b/.changeset/sour-olives-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Made the `DefaultUserInfoService` claims check stricter diff --git a/.changeset/unlucky-jobs-report.md b/.changeset/unlucky-jobs-report.md new file mode 100644 index 0000000000..80e5ecbea0 --- /dev/null +++ b/.changeset/unlucky-jobs-report.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-permission-backend': patch +--- + +Migrated to use the new auth services introduced in [BEP-0003](https://github.com/backstage/backstage/blob/master/beps/0003-auth-architecture-evolution/README.md). + +The `createRouter` function now has an optional `identity` argument, and instead gained the new `auth`, `httpAuth`, and `userInfo` arguments that should be set to the values of those respective `coreServices`. For users of the new backend system, this happens automatically without code changes. diff --git a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts index a74b8b7002..7d3a2af7b5 100644 --- a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts @@ -43,8 +43,11 @@ export class DefaultUserInfoService implements UserInfoService { if (typeof userEntityRef !== 'string') { throw new Error('User entity ref must be a string'); } - if (!Array.isArray(ownershipEntityRefs)) { - throw new Error('Ownership entity refs must be an array'); + if ( + !Array.isArray(ownershipEntityRefs) || + ownershipEntityRefs.some(ref => typeof ref !== 'string') + ) { + throw new Error('Ownership entity refs must be an array of strings'); } return { userEntityRef, ownershipEntityRefs }; diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 1940a79b78..55cb3b34dc 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -67,6 +67,7 @@ import { ServiceRef } from '@backstage/backend-plugin-api'; import { TokenManagerService as TokenManager } from '@backstage/backend-plugin-api'; import { TransportStreamOptions } from 'winston-transport'; import { UrlReaderService as UrlReader } from '@backstage/backend-plugin-api'; +import { UserInfoService } from '@backstage/backend-plugin-api'; import { V1PodTemplateSpec } from '@kubernetes/client-node'; import * as winston from 'winston'; import { Writable } from 'stream'; @@ -239,30 +240,32 @@ export function createLegacyAuthAdapters< TOptions extends { auth?: AuthService; httpAuth?: HttpAuthService; + userInfo?: UserInfoService; identity?: IdentityService; tokenManager?: TokenManager; discovery: PluginEndpointDiscovery; }, - TAdapters = TOptions extends { + TAdapters = (TOptions extends { auth?: AuthService; } - ? TOptions extends { - httpAuth?: HttpAuthService; + ? { + auth: AuthService; } + : {}) & + (TOptions extends { + httpAuth?: HttpAuthService; + } ? { - auth: AuthService; httpAuth: HttpAuthService; } - : { - auth: AuthService; + : {}) & + (TOptions extends { + userInfo?: UserInfoService; + } + ? { + userInfo: UserInfoService; } - : TOptions extends { - httpAuth?: HttpAuthService; - } - ? { - httpAuth: HttpAuthService; - } - : 'error: at least one of auth and/or httpAuth must be provided', + : {}), >(options: TOptions): TAdapters; // @public diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts index 7e1f1be858..db4461781b 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts @@ -56,7 +56,22 @@ describe('createLegacyAuthAdapters', () => { expect(ret.httpAuth).toBe(httpAuth); }); - it('should adapt both auth and httpAuth if neither are provided', () => { + it('should pass through userInfo if it provided', () => { + const auth = {}; + const userInfo = {}; + const ret = createLegacyAuthAdapters({ + auth: auth as any, + userInfo: userInfo as any, + tokenManager: mockServices.tokenManager(), + discovery: {} as any, + identity: mockServices.identity(), + }); + + expect(ret.auth).toBe(auth); + expect(ret.userInfo).toBe(userInfo); + }); + + it('should adapt all services if none are provided', () => { const ret = createLegacyAuthAdapters({ auth: undefined, httpAuth: undefined, @@ -68,6 +83,7 @@ describe('createLegacyAuthAdapters', () => { expect(ret).toEqual({ auth: expect.any(Object), httpAuth: expect.any(Object), + userInfo: expect.any(Object), }); }); }); diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 98d21ba559..d12dd8b9fc 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -19,10 +19,12 @@ import { BackstageCredentials, BackstagePrincipalTypes, BackstageServicePrincipal, + BackstageUserInfo, BackstageUserPrincipal, HttpAuthService, IdentityService, TokenManagerService, + UserInfoService, } from '@backstage/backend-plugin-api'; import { ServerTokenManager, TokenManager } from '../tokens'; import { AuthenticationError, NotAllowedError } from '@backstage/errors'; @@ -203,6 +205,35 @@ class HttpAuthCompat implements HttpAuthService { async issueUserCookie(_res: Response): Promise {} } +export class UserInfoCompat implements UserInfoService { + async getUserInfo( + credentials: BackstageCredentials, + ): Promise { + const internalCredentials = toInternalBackstageCredentials(credentials); + if (internalCredentials.principal.type !== 'user') { + throw new Error('Only user credentials are supported'); + } + if (!internalCredentials.token) { + throw new Error('User credentials is unexpectedly missing token'); + } + const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt( + internalCredentials.token, + ); + + if (typeof userEntityRef !== 'string') { + throw new Error('User entity ref must be a string'); + } + if ( + !Array.isArray(ownershipEntityRefs) || + ownershipEntityRefs.some(ref => typeof ref !== 'string') + ) { + throw new Error('Ownership entity refs must be an array of strings'); + } + + return { userEntityRef, ownershipEntityRefs }; + } +} + /** * An adapter that ensures presence of the auth and/or httpAuth services. * @public @@ -211,38 +242,47 @@ export function createLegacyAuthAdapters< TOptions extends { auth?: AuthService; httpAuth?: HttpAuthService; + userInfo?: UserInfoService; identity?: IdentityService; tokenManager?: TokenManager; discovery: PluginEndpointDiscovery; }, - TAdapters = TOptions extends { - auth?: AuthService; - } - ? TOptions extends { httpAuth?: HttpAuthService } - ? { auth: AuthService; httpAuth: HttpAuthService } - : { auth: AuthService } - : TOptions extends { httpAuth?: HttpAuthService } - ? { httpAuth: HttpAuthService } - : 'error: at least one of auth and/or httpAuth must be provided', + TAdapters = (TOptions extends { auth?: AuthService } + ? { auth: AuthService } + : {}) & + (TOptions extends { httpAuth?: HttpAuthService } + ? { httpAuth: HttpAuthService } + : {}) & + (TOptions extends { userInfo?: UserInfoService } + ? { userInfo: UserInfoService } + : {}), >(options: TOptions): TAdapters { - const { auth, httpAuth, discovery } = options; + const { + auth, + httpAuth, + userInfo = new UserInfoCompat(), + discovery, + } = options; if (auth && httpAuth) { return { auth, httpAuth, + userInfo, } as TAdapters; } if (auth) { return { auth, + userInfo, } as TAdapters; } if (httpAuth) { return { httpAuth, + userInfo, } as TAdapters; } @@ -257,5 +297,6 @@ export function createLegacyAuthAdapters< return { auth: authImpl, httpAuth: httpAuthImpl, + userInfo, } as TAdapters; } diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index a9ebb402c4..d0e37a476d 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -12,6 +12,7 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { BackstageNonePrincipal } from '@backstage/backend-plugin-api'; import { BackstageServicePrincipal } from '@backstage/backend-plugin-api'; +import { BackstageUserInfo } from '@backstage/backend-plugin-api'; import { BackstageUserPrincipal } from '@backstage/backend-plugin-api'; import { CacheService } from '@backstage/backend-plugin-api'; import { DatabaseService } from '@backstage/backend-plugin-api'; @@ -37,6 +38,7 @@ import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { TokenManagerService } from '@backstage/backend-plugin-api'; import { UrlReaderService } from '@backstage/backend-plugin-api'; +import { UserInfoService } from '@backstage/backend-plugin-api'; // @public export function createMockDirectory( @@ -316,6 +318,17 @@ export namespace mockServices { partialImpl?: Partial | undefined, ) => ServiceMock; } + export function userInfo( + customInfo?: Partial, + ): UserInfoService; + // (undocumented) + export namespace userInfo { + const factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } } // @public diff --git a/packages/backend-test-utils/src/next/services/MockUserInfoService.test.ts b/packages/backend-test-utils/src/next/services/MockUserInfoService.test.ts new file mode 100644 index 0000000000..13c8a43213 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockUserInfoService.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { MockUserInfoService } from './MockUserInfoService'; +import { mockCredentials } from './mockCredentials'; + +describe('MockUserInfoService', () => { + it('works without constructor parameters', async () => { + const service = new MockUserInfoService(); + const user = mockCredentials.user(); + await expect(service.getUserInfo(user)).resolves.toEqual({ + userEntityRef: user.principal.userEntityRef, + ownershipEntityRefs: [user.principal.userEntityRef], + }); + }); + + it('works with custom constructor parameters', async () => { + const service = new MockUserInfoService({ + userEntityRef: 'user:default/not-the-mock-1', + ownershipEntityRefs: ['user:default/not-the-mock-2'], + }); + const user = mockCredentials.user(); + await expect(service.getUserInfo(user)).resolves.toEqual({ + userEntityRef: 'user:default/not-the-mock-1', + ownershipEntityRefs: ['user:default/not-the-mock-2'], + }); + }); + + it('rejects non-users', async () => { + const service = new MockUserInfoService(); + await expect( + service.getUserInfo(mockCredentials.none()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"User info not available for principal type 'none'"`, + ); + await expect( + service.getUserInfo(mockCredentials.service()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"User info not available for principal type 'service'"`, + ); + }); +}); diff --git a/packages/backend-test-utils/src/next/services/MockUserInfoService.ts b/packages/backend-test-utils/src/next/services/MockUserInfoService.ts new file mode 100644 index 0000000000..68c2a8acae --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockUserInfoService.ts @@ -0,0 +1,55 @@ +/* + * 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 { + BackstageCredentials, + BackstageNonePrincipal, + BackstageServicePrincipal, + BackstageUserInfo, + BackstageUserPrincipal, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import { InputError } from '@backstage/errors'; + +/** @internal */ +export class MockUserInfoService implements UserInfoService { + private readonly customInfo: Partial; + + constructor(customInfo?: Partial) { + this.customInfo = customInfo ?? {}; + } + + async getUserInfo( + credentials: BackstageCredentials, + ): Promise { + const principal = credentials.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal + | BackstageNonePrincipal; + + if (principal.type !== 'user') { + throw new InputError( + `User info not available for principal type '${principal.type}'`, + ); + } + + return { + userEntityRef: principal.userEntityRef, + ownershipEntityRefs: [principal.userEntityRef], + ...this.customInfo, + }; + } +} diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index f72f229434..7300b34105 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -27,6 +27,8 @@ import { DiscoveryService, HttpAuthService, BackstageCredentials, + BackstageUserInfo, + UserInfoService, } from '@backstage/backend-plugin-api'; import { cacheServiceFactory, @@ -49,6 +51,7 @@ import { MockRootLoggerService } from './MockRootLoggerService'; import { MockAuthService } from './MockAuthService'; import { MockHttpAuthService } from './MockHttpAuthService'; import { mockCredentials } from './mockCredentials'; +import { MockUserInfoService } from './MockUserInfoService'; /** @internal */ function simpleFactory< @@ -272,6 +275,37 @@ export namespace mockServices { })); } + /** + * Creates a mock implementation of the `UserInfoService`. + * + * By default it extracts the user's entity ref from a user principal and + * returns that as the only ownership entity ref, but this can be overridden + * by passing in a custom set of user info. + */ + export function userInfo( + customInfo?: Partial, + ): UserInfoService { + return new MockUserInfoService(customInfo); + } + export namespace userInfo { + /** + * Creates a mock service factory for the `UserInfoService`. + * + * By default it extracts the user's entity ref from a user principal and + * returns that as the only ownership entity ref. + */ + export const factory = createServiceFactory({ + service: coreServices.userInfo, + deps: {}, + factory() { + return new MockUserInfoService(); + }, + }); + export const mock = simpleMock(coreServices.userInfo, () => ({ + getUserInfo: jest.fn(), + })); + } + // TODO(Rugvip): Not all core services have implementations available here yet. // some may need a bit more refactoring for it to be simpler to // re-implement functioning mock versions here. @@ -284,12 +318,14 @@ export namespace mockServices { withOptions: jest.fn(), })); } + export namespace database { export const factory = databaseServiceFactory; export const mock = simpleMock(coreServices.database, () => ({ getClient: jest.fn(), })); } + export namespace httpRouter { export const factory = httpRouterServiceFactory; export const mock = simpleMock(coreServices.httpRouter, () => ({ @@ -297,12 +333,14 @@ export namespace mockServices { addAuthPolicy: jest.fn(), })); } + export namespace rootHttpRouter { export const factory = rootHttpRouterServiceFactory; export const mock = simpleMock(coreServices.rootHttpRouter, () => ({ use: jest.fn(), })); } + export namespace lifecycle { export const factory = lifecycleServiceFactory; export const mock = simpleMock(coreServices.lifecycle, () => ({ @@ -310,6 +348,7 @@ export namespace mockServices { addStartupHook: jest.fn(), })); } + export namespace logger { export const factory = loggerServiceFactory; export const mock = simpleMock(coreServices.logger, () => ({ @@ -320,6 +359,7 @@ export namespace mockServices { warn: jest.fn(), })); } + export namespace permissions { export const factory = permissionsServiceFactory; export const mock = simpleMock(coreServices.permissions, () => ({ @@ -327,6 +367,7 @@ export namespace mockServices { authorizeConditional: jest.fn(), })); } + export namespace rootLifecycle { export const factory = rootLifecycleServiceFactory; export const mock = simpleMock(coreServices.rootLifecycle, () => ({ @@ -334,6 +375,7 @@ export namespace mockServices { addStartupHook: jest.fn(), })); } + export namespace scheduler { export const factory = schedulerServiceFactory; export const mock = simpleMock(coreServices.scheduler, () => ({ @@ -343,6 +385,7 @@ export namespace mockServices { triggerTask: jest.fn(), })); } + export namespace urlReader { export const factory = urlReaderServiceFactory; export const mock = simpleMock(coreServices.urlReader, () => ({ diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 72a2ed8edb..6b58ebc5ea 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -80,6 +80,7 @@ export const defaultServiceFactories = [ mockServices.rootLogger.factory(), mockServices.scheduler.factory(), mockServices.tokenManager.factory(), + mockServices.userInfo.factory(), mockServices.urlReader.factory(), ]; diff --git a/plugins/permission-backend/api-report.md b/plugins/permission-backend/api-report.md index e9cc4e6272..8b4336ff23 100644 --- a/plugins/permission-backend/api-report.md +++ b/plugins/permission-backend/api-report.md @@ -3,27 +3,36 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { UserInfoService } from '@backstage/backend-plugin-api'; // @public export function createRouter(options: RouterOptions): Promise; // @public export interface RouterOptions { + // (undocumented) + auth?: AuthService; // (undocumented) config: Config; // (undocumented) - discovery: PluginEndpointDiscovery; + discovery: DiscoveryService; // (undocumented) - identity: IdentityApi; + httpAuth?: HttpAuthService; + // (undocumented) + identity?: IdentityApi; // (undocumented) logger: Logger; // (undocumented) policy: PermissionPolicy; + // (undocumented) + userInfo?: UserInfoService; } ``` diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json index cc986c4cc8..4131dc29b9 100644 --- a/plugins/permission-backend/package.json +++ b/plugins/permission-backend/package.json @@ -62,6 +62,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", diff --git a/plugins/permission-backend/src/plugin.ts b/plugins/permission-backend/src/plugin.ts index 9cf0024e47..cc7f31dfdd 100644 --- a/plugins/permission-backend/src/plugin.ts +++ b/plugins/permission-backend/src/plugin.ts @@ -55,9 +55,19 @@ export const permissionPlugin = createBackendPlugin({ config: coreServices.rootConfig, logger: coreServices.logger, discovery: coreServices.discovery, - identity: coreServices.identity, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, + userInfo: coreServices.userInfo, }, - async init({ http, config, logger, discovery, identity }) { + async init({ + http, + config, + logger, + discovery, + auth, + httpAuth, + userInfo, + }) { const winstonLogger = loggerToWinstonLogger(logger); if (!policies.policy) { throw new Error( @@ -69,9 +79,11 @@ export const permissionPlugin = createBackendPlugin({ await createRouter({ config, discovery, - identity, logger: winstonLogger, policy: policies.policy, + auth, + httpAuth, + userInfo, }), ); }, diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts index c7a412547d..a36dadd2b5 100644 --- a/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.test.ts @@ -19,7 +19,7 @@ import { Server } from 'http'; import express, { Router, RequestHandler } from 'express'; import { RestContext, rest } from 'msw'; import { setupServer, SetupServer } from 'msw/node'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { AuthorizeResult, PermissionCondition, @@ -31,10 +31,12 @@ import { } from '@backstage/plugin-permission-node'; import { PermissionIntegrationClient } from './PermissionIntegrationClient'; import { z } from 'zod'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; describe('PermissionIntegrationClient', () => { describe('applyConditions', () => { let server: SetupServer; + const auth = mockServices.auth(); const mockConditions: PermissionCriteria = { not: { @@ -58,7 +60,7 @@ describe('PermissionIntegrationClient', () => { ); const mockBaseUrl = 'http://backstage:9191'; - const discovery: PluginEndpointDiscovery = { + const discovery: DiscoveryService = { async getBaseUrl(pluginId) { return `${mockBaseUrl}/${pluginId}`; }, @@ -70,6 +72,7 @@ describe('PermissionIntegrationClient', () => { const client: PermissionIntegrationClient = new PermissionIntegrationClient( { discovery, + auth, }, ); @@ -91,7 +94,7 @@ describe('PermissionIntegrationClient', () => { }); it('should make a POST request to the correct endpoint', async () => { - await client.applyConditions('plugin-1', [ + await client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -104,7 +107,7 @@ describe('PermissionIntegrationClient', () => { }); it('should include a request body', async () => { - await client.applyConditions('plugin-1', [ + await client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -132,14 +135,18 @@ describe('PermissionIntegrationClient', () => { }); it('should return the response from the fetch request', async () => { - const response = await client.applyConditions('plugin-1', [ - { - id: '123', - resourceRef: 'testResource1', - resourceType: 'test-resource', - conditions: mockConditions, - }, - ]); + const response = await client.applyConditions( + 'plugin-1', + mockCredentials.none(), + [ + { + id: '123', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + ], + ); expect(response).toEqual( expect.objectContaining([{ id: '123', result: AuthorizeResult.ALLOW }]), @@ -147,7 +154,7 @@ describe('PermissionIntegrationClient', () => { }); it('should not include authorization headers if no token is supplied', async () => { - await client.applyConditions('plugin-1', [ + await client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -161,21 +168,22 @@ describe('PermissionIntegrationClient', () => { }); it('should include correctly-constructed authorization header if token is supplied', async () => { - await client.applyConditions( - 'plugin-1', - [ - { - id: '123', - resourceRef: 'testResource1', - resourceType: 'test-resource', - conditions: mockConditions, - }, - ], - 'Bearer fake-token', - ); + await client.applyConditions('plugin-1', mockCredentials.user(), [ + { + id: '123', + resourceRef: 'testResource1', + resourceType: 'test-resource', + conditions: mockConditions, + }, + ]); const request = mockApplyConditionsHandler.mock.calls[0][0]; - expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + expect(request.headers.get('authorization')).toEqual( + mockCredentials.service.header({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'plugin-1', + }), + ); }); it('should forward response errors', async () => { @@ -186,7 +194,7 @@ describe('PermissionIntegrationClient', () => { ); await expect( - client.applyConditions('plugin-1', [ + client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -194,7 +202,7 @@ describe('PermissionIntegrationClient', () => { conditions: mockConditions, }, ]), - ).rejects.toThrow(/401/i); + ).rejects.toThrow(/401/); }); it('should reject invalid responses', async () => { @@ -207,7 +215,7 @@ describe('PermissionIntegrationClient', () => { ); await expect( - client.applyConditions('plugin-1', [ + client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -234,7 +242,7 @@ describe('PermissionIntegrationClient', () => { ); await expect( - client.applyConditions('plugin-1', [ + client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -268,6 +276,7 @@ describe('PermissionIntegrationClient', () => { let server: Server; let client: PermissionIntegrationClient; let routerSpy: RequestHandler; + const auth = mockServices.auth(); beforeAll(async () => { const router = Router(); @@ -319,7 +328,7 @@ describe('PermissionIntegrationClient', () => { server = app.listen(resolve); }); - const discovery: PluginEndpointDiscovery = { + const discovery: DiscoveryService = { async getBaseUrl(pluginId: string) { const listenPort = (server.address()! as AddressInfo).port; @@ -332,6 +341,7 @@ describe('PermissionIntegrationClient', () => { client = new PermissionIntegrationClient({ discovery, + auth, }); }); @@ -348,7 +358,7 @@ describe('PermissionIntegrationClient', () => { it('works for simple conditions', async () => { await expect( - client.applyConditions('plugin-1', [ + client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', @@ -367,7 +377,7 @@ describe('PermissionIntegrationClient', () => { it('works for complex criteria', async () => { await expect( - client.applyConditions('plugin-1', [ + client.applyConditions('plugin-1', mockCredentials.none(), [ { id: '123', resourceRef: 'testResource1', diff --git a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts index 2c31d371c3..7567dc8fbb 100644 --- a/plugins/permission-backend/src/service/PermissionIntegrationClient.ts +++ b/plugins/permission-backend/src/service/PermissionIntegrationClient.ts @@ -16,7 +16,6 @@ import fetch from 'node-fetch'; import { z } from 'zod'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { AuthorizeResult, ConditionalPolicyDecision, @@ -25,6 +24,11 @@ import { ApplyConditionsRequestEntry, ApplyConditionsResponseEntry, } from '@backstage/plugin-permission-node'; +import { + AuthService, + BackstageCredentials, + DiscoveryService, +} from '@backstage/backend-plugin-api'; const responseSchema = z.object({ items: z.array( @@ -42,20 +46,30 @@ export type ResourcePolicyDecision = ConditionalPolicyDecision & { }; export class PermissionIntegrationClient { - private readonly discovery: PluginEndpointDiscovery; + private readonly discovery: DiscoveryService; + private readonly auth: AuthService; - constructor(options: { discovery: PluginEndpointDiscovery }) { + constructor(options: { discovery: DiscoveryService; auth: AuthService }) { this.discovery = options.discovery; + this.auth = options.auth; } async applyConditions( pluginId: string, + credentials: BackstageCredentials, decisions: readonly ApplyConditionsRequestEntry[], - authHeader?: string, ): Promise { - const endpoint = `${await this.discovery.getBaseUrl( - pluginId, - )}/.well-known/backstage/permissions/apply-conditions`; + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const endpoint = `${baseUrl}/.well-known/backstage/permissions/apply-conditions`; + + const token = this.auth.isPrincipal(credentials, 'none') + ? undefined + : await this.auth + .getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: pluginId, + }) + .then(t => t.token); const response = await fetch(endpoint, { method: 'POST', @@ -70,7 +84,7 @@ export class PermissionIntegrationClient { ), }), headers: { - ...(authHeader ? { authorization: authHeader } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json', }, }); diff --git a/plugins/permission-backend/src/service/router.test.ts b/plugins/permission-backend/src/service/router.test.ts index 7278835c5c..0da9ecd748 100644 --- a/plugins/permission-backend/src/service/router.test.ts +++ b/plugins/permission-backend/src/service/router.test.ts @@ -26,12 +26,15 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient'; import { createRouter } from './router'; import { ConfigReader } from '@backstage/config'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; const mockApplyConditions: jest.MockedFunction< InstanceType['applyConditions'] > = jest.fn( async ( _pluginId: string, + _credentials: BackstageCredentials, decisions: readonly ApplyConditionsRequestEntry[], ) => decisions.map(decision => ({ @@ -65,28 +68,12 @@ describe('createRouter', () => { const router = await createRouter({ config: new ConfigReader({ permission: { enabled: true } }), logger: getVoidLogger(), - discovery: { - getBaseUrl: jest.fn(), - getExternalBaseUrl: jest.fn(), - }, - identity: { - getIdentity: jest.fn(({ request: req }) => { - const token = req.headers.authorization?.replace(/^Bearer[ ]+/, ''); - - if (!token) { - return Promise.resolve(undefined); - } - - return Promise.resolve({ - identity: { - type: 'user', - userEntityRef: 'test-user', - ownershipEntityRefs: ['blah'], - }, - token, - }); - }), - }, + discovery: mockServices.discovery(), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth({ + defaultCredentials: mockCredentials.none(), + }), + userInfo: mockServices.userInfo(), policy, }); @@ -163,10 +150,9 @@ describe('createRouter', () => { }); it('resolves identity from the Authorization header', async () => { - const token = 'test-token'; const response = await request(app) .post('/authorize') - .auth(token, { type: 'bearer' }) + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ items: [ { @@ -190,11 +176,16 @@ describe('createRouter', () => { }, }, { - token: 'test-token', + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'catalog', + }), identity: { type: 'user', - userEntityRef: 'test-user', - ownershipEntityRefs: ['blah'], + userEntityRef: mockCredentials.user().principal.userEntityRef, + ownershipEntityRefs: [ + mockCredentials.user().principal.userEntityRef, + ], }, }, ); @@ -271,7 +262,7 @@ describe('createRouter', () => { const response = await request(app) .post('/authorize') - .auth('test-token', { type: 'bearer' }) + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ items: [ { @@ -319,6 +310,7 @@ describe('createRouter', () => { expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-1', + mockCredentials.user(), [ expect.objectContaining({ id: '123', @@ -333,11 +325,11 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['no'] }, }), ], - 'Bearer test-token', ); expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-2', + mockCredentials.user(), [ expect.objectContaining({ id: '234', @@ -352,7 +344,6 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['no'] }, }), ], - 'Bearer test-token', ); expect(response.status).toEqual(200); @@ -401,7 +392,7 @@ describe('createRouter', () => { const response = await request(app) .post('/authorize') - .auth('test-token', { type: 'bearer' }) + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ items: [ { @@ -467,6 +458,7 @@ describe('createRouter', () => { expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-1', + mockCredentials.user(), [ expect.objectContaining({ id: '123', @@ -481,11 +473,11 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['yes'] }, }), ], - 'Bearer test-token', ); expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-2', + mockCredentials.user(), [ expect.objectContaining({ id: '234', @@ -500,7 +492,6 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['yes'] }, }), ], - 'Bearer test-token', ); expect(response.status).toEqual(200); @@ -542,7 +533,7 @@ describe('createRouter', () => { const response = await request(app) .post('/authorize') - .auth('test-token', { type: 'bearer' }) + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ items: [ { @@ -589,6 +580,7 @@ describe('createRouter', () => { expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-1', + mockCredentials.user(), [ expect.objectContaining({ id: '123', @@ -597,11 +589,11 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['yes'] }, }), ], - 'Bearer test-token', ); expect(mockApplyConditions).toHaveBeenCalledWith( 'plugin-2', + mockCredentials.user(), [ expect.objectContaining({ id: '234', @@ -610,7 +602,6 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params: ['yes'] }, }), ], - 'Bearer test-token', ); expect(response.status).toEqual(200); @@ -656,7 +647,7 @@ describe('createRouter', () => { const response = await request(app) .post('/authorize') - .auth('test-token', { type: 'bearer' }) + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ items: [ { @@ -684,6 +675,7 @@ describe('createRouter', () => { expect(mockApplyConditions).toHaveBeenCalledWith( 'test-plugin', + mockCredentials.user(), [ expect.objectContaining({ id: '123', @@ -698,7 +690,6 @@ describe('createRouter', () => { conditions: { rule: 'test-rule', params }, }), ], - 'Bearer test-token', ); expect(response.status).toEqual(200); diff --git a/plugins/permission-backend/src/service/router.ts b/plugins/permission-backend/src/service/router.ts index b7e77fdba9..cd7c71fd87 100644 --- a/plugins/permission-backend/src/service/router.ts +++ b/plugins/permission-backend/src/service/router.ts @@ -19,8 +19,8 @@ import express, { Request, Response } from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { + createLegacyAuthAdapters, errorHandler, - PluginEndpointDiscovery, } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { @@ -46,6 +46,15 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient'; import { memoize } from 'lodash'; import DataLoader from 'dataloader'; import { Config } from '@backstage/config'; +import { + AuthService, + BackstageCredentials, + BackstageNonePrincipal, + BackstageUserPrincipal, + DiscoveryService, + HttpAuthService, + UserInfoService, +} from '@backstage/backend-plugin-api'; const attributesSchema: z.ZodSchema = z.object({ action: z @@ -93,28 +102,51 @@ const evaluatePermissionRequestBatchSchema: z.ZodSchema[], - user: BackstageIdentityResponse | undefined, policy: PermissionPolicy, permissionIntegrationClient: PermissionIntegrationClient, - authHeader?: string, + credentials: BackstageCredentials< + BackstageNonePrincipal | BackstageUserPrincipal + >, + auth: AuthService, + userInfo: UserInfoService, ): Promise[]> => { const applyConditionsLoaderFor = memoize((pluginId: string) => { return new DataLoader< ApplyConditionsRequestEntry, ApplyConditionsResponseEntry >(batch => - permissionIntegrationClient.applyConditions(pluginId, batch, authHeader), + permissionIntegrationClient.applyConditions(pluginId, credentials, batch), ); }); + let user: BackstageIdentityResponse | undefined; + if (auth.isPrincipal(credentials, 'user')) { + const { ownershipEntityRefs } = await userInfo.getUserInfo(credentials); + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', // TODO: unknown at this point + }); + user = { + identity: { + type: 'user', + userEntityRef: credentials.principal.userEntityRef, + ownershipEntityRefs, + }, + token, + }; + } + return Promise.all( requests.map(({ id, resourceRef, ...request }) => policy.handle(request, user).then(decision => { @@ -163,7 +195,8 @@ const handleRequest = async ( export async function createRouter( options: RouterOptions, ): Promise { - const { policy, discovery, identity, config, logger } = options; + const { policy, discovery, config, logger } = options; + const { auth, httpAuth, userInfo } = createLegacyAuthAdapters(options); if (!config.getOptionalBoolean('permission.enabled')) { logger.warn( @@ -173,6 +206,7 @@ export async function createRouter( const permissionIntegrationClient = new PermissionIntegrationClient({ discovery, + auth, }); const router = Router(); @@ -188,7 +222,9 @@ export async function createRouter( req: Request, res: Response, ) => { - const user = await identity.getIdentity({ request: req }); + const credentials = await httpAuth.credentials(req, { + allow: ['user', 'none'], + }); const parseResult = evaluatePermissionRequestBatchSchema.safeParse( req.body, @@ -203,10 +239,11 @@ export async function createRouter( res.json({ items: await handleRequest( body.items, - user, policy, permissionIntegrationClient, - req.header('authorization'), + credentials, + auth, + userInfo, ), }); }, diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..1dc69f711f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7917,6 +7917,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" From 1d6764940b43da524d0b23016f1bd2e18d4a43eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:27:10 +0000 Subject: [PATCH 072/176] chore(deps): update dependency @types/pg to v8.11.1 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 5aa82df2e2..491ee4faab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19186,13 +19186,13 @@ __metadata: linkType: hard "@types/pg@npm:^8.6.6": - version: 8.11.0 - resolution: "@types/pg@npm:8.11.0" + version: 8.11.1 + resolution: "@types/pg@npm:8.11.1" dependencies: "@types/node": "*" pg-protocol: "*" pg-types: ^4.0.1 - checksum: 8ae18abce86a012afdd68b2fb85a9fd0e9529f2dae8ca64311a4804fc8423441d605df51f547170efa4584c6ee9f919b4f5f731d5a6221386c5d04560de4334c + checksum: 3d8672800cc96ffeec934c0f7c652d699a1c5a891804e89b6783325b04c496c08ce32237a93da64e3a83540f0f2c3d20d344313716e6f1ea7f335da38a4fd241 languageName: node linkType: hard From 3cd77cb85593c7749819689cbc6a46f093f137d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:28:07 +0000 Subject: [PATCH 073/176] chore(deps): update dependency @types/semver to v7.5.8 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 5aa82df2e2..85c5da11b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19498,9 +19498,9 @@ __metadata: linkType: hard "@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.8, @types/semver@npm:^7.5.0": - version: 7.5.7 - resolution: "@types/semver@npm:7.5.7" - checksum: 5af9b13e3d74d86d4b618f6506ccbded801fb35dbc28608cd5a7bfb8bcac0021dd35ef305a72a0c2a8def0cff60acd706bfee16a9ed1c39a893d2a175e778ea7 + version: 7.5.8 + resolution: "@types/semver@npm:7.5.8" + checksum: ea6f5276f5b84c55921785a3a27a3cd37afee0111dfe2bcb3e03c31819c197c782598f17f0b150a69d453c9584cd14c4c4d7b9a55d2c5e6cacd4d66fdb3b3663 languageName: node linkType: hard From 68133666c6154dc4cd346c54582cb3c8795fde3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 18:38:00 +0100 Subject: [PATCH 074/176] playlist-backend: migrate to support new auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .changeset/eight-fireants-crash.md | 5 + plugins/playlist-backend/api-report.md | 10 +- .../src/service/DatabaseHandler.ts | 10 +- .../src/service/router.test.ts | 97 +++++++++---------- .../playlist-backend/src/service/router.ts | 43 ++++---- 5 files changed, 88 insertions(+), 77 deletions(-) create mode 100644 .changeset/eight-fireants-crash.md diff --git a/.changeset/eight-fireants-crash.md b/.changeset/eight-fireants-crash.md new file mode 100644 index 0000000000..a4eb903585 --- /dev/null +++ b/.changeset/eight-fireants-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-playlist-backend': patch +--- + +Migrated to support new auth services. diff --git a/plugins/playlist-backend/api-report.md b/plugins/playlist-backend/api-report.md index 40b05c25d4..0a6aa3e3bf 100644 --- a/plugins/playlist-backend/api-report.md +++ b/plugins/playlist-backend/api-report.md @@ -3,19 +3,21 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; import { Conditions } from '@backstage/plugin-permission-node'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; import { PermissionRule } from '@backstage/plugin-permission-node'; +import { PermissionsService } from '@backstage/backend-plugin-api'; import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -87,15 +89,19 @@ export default playlistPlugin; // @public (undocumented) export interface RouterOptions { + // (undocumented) + auth?: AuthService; // (undocumented) database: PluginDatabaseManager; // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) + httpAuth?: HttpAuthService; + // (undocumented) identity: IdentityApi; // (undocumented) logger: Logger; // (undocumented) - permissions: PermissionEvaluator; + permissions: PermissionsService; } ``` diff --git a/plugins/playlist-backend/src/service/DatabaseHandler.ts b/plugins/playlist-backend/src/service/DatabaseHandler.ts index 6118f22ff9..afcaf92d24 100644 --- a/plugins/playlist-backend/src/service/DatabaseHandler.ts +++ b/plugins/playlist-backend/src/service/DatabaseHandler.ts @@ -15,7 +15,7 @@ */ import { resolvePackagePath } from '@backstage/backend-common'; -import { BackstageUserIdentity } from '@backstage/plugin-auth-node'; +import { BackstageUserPrincipal } from '@backstage/backend-plugin-api'; import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; @@ -108,7 +108,7 @@ export class DatabaseHandler { private playlistColumns = ['id', 'name', 'description', 'owner', 'public']; async listPlaylists( - user: BackstageUserIdentity, + user: BackstageUserPrincipal, filter?: ListPlaylistsFilter, ): Promise { let playlistQuery = this.database>( @@ -177,7 +177,7 @@ export class DatabaseHandler { async getPlaylist( id: string, - user?: BackstageUserIdentity, + user?: BackstageUserPrincipal, ): Promise { const playlist = await this.database>( 'playlists', @@ -263,14 +263,14 @@ export class DatabaseHandler { .del(); } - async followPlaylist(playlistId: string, user: BackstageUserIdentity) { + async followPlaylist(playlistId: string, user: BackstageUserPrincipal) { await this.database('followers') .insert({ playlist_id: playlistId, user_ref: user.userEntityRef }) .onConflict(['playlist_id', 'user_ref']) .ignore(); } - async unfollowPlaylist(playlistId: string, user: BackstageUserIdentity) { + async unfollowPlaylist(playlistId: string, user: BackstageUserPrincipal) { await this.database('followers') .where({ playlist_id: playlistId, user_ref: user.userEntityRef }) .del(); diff --git a/plugins/playlist-backend/src/service/router.test.ts b/plugins/playlist-backend/src/service/router.test.ts index 3f90a8b8af..d28eae9041 100644 --- a/plugins/playlist-backend/src/service/router.test.ts +++ b/plugins/playlist-backend/src/service/router.test.ts @@ -20,13 +20,13 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { IdentityApi } from '@backstage/plugin-auth-node'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { permissions } from '@backstage/plugin-playlist-common'; import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; const sampleEntities = [ { @@ -60,10 +60,6 @@ jest.mock('@backstage/catalog-client', () => ({ .mockImplementation(() => ({ getEntities: mockGetEntties })), })); -jest.mock('@backstage/plugin-auth-node', () => ({ - getBearerTokenFromAuthorizationHeader: () => 'token', -})); - const mockConditionFilter = { key: 'test', values: ['test-val'] }; jest.mock('../permissions', () => ({ ...jest.requireActual('../permissions'), @@ -128,17 +124,6 @@ describe('createRouter', () => { authorizeConditional: mockedAuthorizeConditional, }; - const mockUser = { - type: 'user', - ownershipEntityRefs: ['user:default/me', 'group:default/owner'], - userEntityRef: 'user:default/me', - }; - const mockIdentityClient = { - getIdentity: jest - .fn() - .mockImplementation(async () => ({ identity: mockUser })), - } as unknown as IdentityApi; - const discovery: jest.Mocked = { getBaseUrl: jest.fn(), getExternalBaseUrl: jest.fn(), @@ -148,9 +133,11 @@ describe('createRouter', () => { const router = await createRouter({ database: createDatabase(), discovery, - identity: mockIdentityClient, + identity: mockServices.identity(), logger: getVoidLogger(), permissions: mockPermissionEvaluator, + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), }); app = express().use(router); @@ -173,7 +160,7 @@ describe('createRouter', () => { expect(mockedAuthorizeConditional).toHaveBeenCalledWith( [{ permission: permissions.playlistListRead }], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.listPlaylists).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -182,7 +169,7 @@ describe('createRouter', () => { it('should get playlists correctly', async () => { let response = await request(app).get('/').send(); expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( - mockUser, + mockCredentials.user().principal, undefined, ); expect(response.status).toEqual(200); @@ -193,7 +180,7 @@ describe('createRouter', () => { ]); response = await request(app).get('/').send(); expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( - mockUser, + mockCredentials.user().principal, mockConditionFilter, ); expect(response.status).toEqual(200); @@ -205,7 +192,7 @@ describe('createRouter', () => { .get('/?filter=mock=test&filter=foo=bar') .send(); expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( - mockUser, + mockCredentials.user().principal, mockRequestFilter, ); expect(response.status).toEqual(200); @@ -217,9 +204,12 @@ describe('createRouter', () => { response = await request(app) .get('/?filter=mock=test&filter=foo=bar') .send(); - expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { - allOf: [mockRequestFilter, mockConditionFilter], - }); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockCredentials.user().principal, + { + allOf: [mockRequestFilter, mockConditionFilter], + }, + ); expect(response.status).toEqual(200); expect(response.body).toEqual([mockPlaylist]); }); @@ -228,10 +218,10 @@ describe('createRouter', () => { let response = await request(app).get('/?editable=true').send(); expect(mockedAuthorizeConditional).toHaveBeenCalledWith( [{ permission: permissions.playlistListUpdate }], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( - mockUser, + mockCredentials.user().principal, undefined, ); expect(response.status).toEqual(200); @@ -241,7 +231,7 @@ describe('createRouter', () => { .get('/?editable=true&filter=mock=test&filter=foo=bar') .send(); expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( - mockUser, + mockCredentials.user().principal, mockRequestFilter, ); expect(response.status).toEqual(200); @@ -251,9 +241,10 @@ describe('createRouter', () => { { result: AuthorizeResult.CONDITIONAL }, ]); response = await request(app).get('/?editable=true').send(); - expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { - allOf: [mockConditionFilter, mockConditionFilter], - }); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockCredentials.user().principal, + { allOf: [mockConditionFilter, mockConditionFilter] }, + ); expect(response.status).toEqual(200); expect(response.body).toEqual([mockPlaylist]); @@ -263,12 +254,15 @@ describe('createRouter', () => { response = await request(app) .get('/?editable=true&filter=mock=test&filter=foo=bar') .send(); - expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { - allOf: [ - { allOf: [mockRequestFilter, mockConditionFilter] }, - mockConditionFilter, - ], - }); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockCredentials.user().principal, + { + allOf: [ + { allOf: [mockRequestFilter, mockConditionFilter] }, + mockConditionFilter, + ], + }, + ); expect(response.status).toEqual(200); expect(response.body).toEqual([mockPlaylist]); }); @@ -285,7 +279,7 @@ describe('createRouter', () => { expect(mockedAuthorize).toHaveBeenCalledWith( [{ permission: permissions.playlistListCreate }], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.createPlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -313,7 +307,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.getPlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -323,7 +317,7 @@ describe('createRouter', () => { const response = await request(app).get('/playlist-id').send(); expect(mockDbHandler.getPlaylist).toHaveBeenCalledWith( 'playlist-id', - mockUser, + mockCredentials.user().principal, ); expect(response.status).toEqual(200); expect(response.body).toEqual(mockPlaylist); @@ -346,7 +340,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.updatePlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -375,7 +369,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.deletePlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -404,7 +398,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.addPlaylistEntities).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -436,7 +430,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.getPlaylistEntities).not.toHaveBeenCalled(); expect(mockGetEntties).not.toHaveBeenCalled(); @@ -463,7 +457,12 @@ describe('createRouter', () => { }, ], }, - { token: 'token' }, + { + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'catalog', + }), + }, ); expect(response.status).toEqual(200); expect(response.body).toEqual(sampleEntities); @@ -486,7 +485,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.removePlaylistEntities).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -518,7 +517,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.followPlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -528,7 +527,7 @@ describe('createRouter', () => { const response = await request(app).post('/playlist-id/followers').send(); expect(mockDbHandler.followPlaylist).toHaveBeenCalledWith( 'playlist-id', - mockUser, + mockCredentials.user().principal, ); expect(response.status).toEqual(200); }); @@ -550,7 +549,7 @@ describe('createRouter', () => { resourceRef: 'playlist-id', }, ], - { token: 'token' }, + { credentials: mockCredentials.user() }, ); expect(mockDbHandler.unfollowPlaylist).not.toHaveBeenCalled(); expect(response.status).toEqual(403); @@ -562,7 +561,7 @@ describe('createRouter', () => { .send(); expect(mockDbHandler.unfollowPlaylist).toHaveBeenCalledWith( 'playlist-id', - mockUser, + mockCredentials.user().principal, ); expect(response.status).toEqual(200); }); diff --git a/plugins/playlist-backend/src/service/router.ts b/plugins/playlist-backend/src/service/router.ts index 0397bff2ff..8fa9983d0b 100644 --- a/plugins/playlist-backend/src/service/router.ts +++ b/plugins/playlist-backend/src/service/router.ts @@ -15,6 +15,7 @@ */ import { + createLegacyAuthAdapters, errorHandler, PluginDatabaseManager, PluginEndpointDiscovery, @@ -22,14 +23,10 @@ import { import { CatalogClient } from '@backstage/catalog-client'; import { parseEntityRef } from '@backstage/catalog-model'; import { NotAllowedError } from '@backstage/errors'; -import { - getBearerTokenFromAuthorizationHeader, - IdentityApi, -} from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { AuthorizePermissionRequest, AuthorizeResult, - PermissionEvaluator, QueryPermissionRequest, } from '@backstage/plugin-permission-common'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; @@ -44,6 +41,11 @@ import { Logger } from 'winston'; import { rules, transformConditions } from '../permissions'; import { DatabaseHandler } from './DatabaseHandler'; import { parseListPlaylistsFilterParams } from './ListPlaylistsFilter'; +import { + AuthService, + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; /** * @public @@ -53,7 +55,9 @@ export interface RouterOptions { discovery: PluginEndpointDiscovery; identity: IdentityApi; logger: Logger; - permissions: PermissionEvaluator; + permissions: PermissionsService; + auth?: AuthService; + httpAuth?: HttpAuthService; } /** @@ -65,11 +69,12 @@ export async function createRouter( const { database, discovery, - identity, logger, permissions: permissionEvaluator, } = options; + const { auth, httpAuth } = createLegacyAuthAdapters(options); + logger.info('Initializing Playlist backend'); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -81,26 +86,21 @@ export async function createRouter( permission: AuthorizePermissionRequest | QueryPermissionRequest, conditional: boolean = false, ) => { - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); - - const user = await identity.getIdentity({ request }); - if (!user) { - throw new NotAllowedError('Unauthorized'); - } + const credentials = await httpAuth.credentials(request, { + allow: ['user'], + }); const decision = conditional ? ( await permissionEvaluator.authorizeConditional( [permission as QueryPermissionRequest], - { token }, + { credentials }, ) )[0] : ( await permissionEvaluator.authorize( [permission as AuthorizePermissionRequest], - { token }, + { credentials }, ) )[0]; @@ -108,7 +108,7 @@ export async function createRouter( throw new NotAllowedError('Unauthorized'); } - return { decision, user: user.identity }; + return { decision, user: credentials.principal }; }; const permissionIntegrationRouter = createPermissionIntegrationRouter({ @@ -227,9 +227,10 @@ export async function createRouter( }; }); - const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', + }); // TODO(kuanpg): entities in this playlist that no longer exist in the catalog will be // excluded from this response, we need a way to clean up these orphaned refs potentially From 6b802a2da2064cd3ad84244f2370f4f4afb67da4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 17:47:52 +0100 Subject: [PATCH 075/176] azure-stes-backend: migrate to support new auth services Signed-off-by: Patrik Oldsberg --- .changeset/healthy-experts-rhyme.md | 5 ++ plugins/azure-sites-backend/api-report.md | 13 ++++- plugins/azure-sites-backend/package.json | 1 + .../azure-sites-backend/src/service/router.ts | 47 +++++++++++-------- .../src/service/standaloneServer.ts | 1 + yarn.lock | 1 + 6 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 .changeset/healthy-experts-rhyme.md diff --git a/.changeset/healthy-experts-rhyme.md b/.changeset/healthy-experts-rhyme.md new file mode 100644 index 0000000000..ca4618f1f5 --- /dev/null +++ b/.changeset/healthy-experts-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-sites-backend': minor +--- + +**BREAKING**: The `createRouter` method now requires the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services. diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index 04cd22596a..fa6e8f56c6 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -3,14 +3,17 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { AzureSiteListRequest } from '@backstage/plugin-azure-sites-common'; import { AzureSiteListResponse } from '@backstage/plugin-azure-sites-common'; import { AzureSiteStartStopRequest } from '@backstage/plugin-azure-sites-common'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Logger } from 'winston'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { PermissionsService } from '@backstage/backend-plugin-api'; // @public (undocumented) export class AzureSitesApi { @@ -55,14 +58,20 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { + // (undocumented) + auth?: AuthService; // (undocumented) azureSitesApi: AzureSitesApi; // (undocumented) catalogApi: CatalogApi; // (undocumented) + discovery: DiscoveryService; + // (undocumented) + httpAuth?: HttpAuthService; + // (undocumented) logger: Logger; // (undocumented) - permissions: PermissionEvaluator; + permissions: PermissionsService; } // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-backend/package.json b/plugins/azure-sites-backend/package.json index 2e9a45a33c..fdaf8825ae 100644 --- a/plugins/azure-sites-backend/package.json +++ b/plugins/azure-sites-backend/package.json @@ -36,6 +36,7 @@ "@azure/arm-resourcegraph": "^4.2.1", "@azure/identity": "^4.0.0", "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index bd5d20c05e..79e4f46c04 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -14,17 +14,16 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { + createLegacyAuthAdapters, + errorHandler, +} from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { - PermissionEvaluator, - AuthorizeResult, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { azureSitesActionPermission, azureSitesPermissions, @@ -34,13 +33,22 @@ import { createPermissionIntegrationRouter } from '@backstage/plugin-permission- import { CatalogApi } from '@backstage/catalog-client'; import { AzureSitesApi } from '../api'; +import { + DiscoveryService, + AuthService, + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; /** @public */ export interface RouterOptions { logger: Logger; azureSitesApi: AzureSitesApi; catalogApi: CatalogApi; - permissions: PermissionEvaluator; + permissions: PermissionsService; + discovery: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; } /** @public */ @@ -48,6 +56,7 @@ export async function createRouter( options: RouterOptions, ): Promise { const { logger, azureSitesApi, permissions, catalogApi } = options; + const { auth, httpAuth } = createLegacyAuthAdapters(options); const permissionIntegrationRouter = createPermissionIntegrationRouter({ permissions: azureSitesPermissions, @@ -73,13 +82,15 @@ export async function createRouter( '/:subscription/:resourceGroup/:name/start', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); + const credentials = await httpAuth.credentials(request); const entityRef = request.body.entityRef; if (typeof entityRef !== 'string') { throw new InputError('Invalid entityRef, not a string'); } + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await catalogApi.getEntityByRef(entityRef, { token }); if (entity) { @@ -101,9 +112,7 @@ export async function createRouter( resourceRef: entityRef, }, ], - { - token, - }, + { credentials }, ) )[0] : undefined; @@ -130,14 +139,16 @@ export async function createRouter( '/:subscription/:resourceGroup/:name/stop', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); + const credentials = await httpAuth.credentials(request); const entityRef = request.body.entityRef; if (typeof entityRef !== 'string') { throw new InputError('Invalid entityRef, not a string'); } + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); const entity = await catalogApi.getEntityByRef(entityRef, { token }); if (entity) { @@ -160,9 +171,7 @@ export async function createRouter( resourceRef: entityRef, }, ], - { - token, - }, + { credentials }, ) )[0] : undefined; diff --git a/plugins/azure-sites-backend/src/service/standaloneServer.ts b/plugins/azure-sites-backend/src/service/standaloneServer.ts index 202c1a128d..c0961f0359 100644 --- a/plugins/azure-sites-backend/src/service/standaloneServer.ts +++ b/plugins/azure-sites-backend/src/service/standaloneServer.ts @@ -53,6 +53,7 @@ export async function startStandaloneServer( permissions, azureSitesApi: AzureSitesApi.fromConfig(config), catalogApi, + discovery, }); let service = createServiceBuilder(module) diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..2089595fc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5001,6 +5001,7 @@ __metadata: "@azure/arm-resourcegraph": ^4.2.1 "@azure/identity": ^4.0.0 "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" From bb368a598beb1b667181f80f9d44ae201cae6c6d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 13:57:03 +0100 Subject: [PATCH 076/176] search-backend-module-{catalog,explore,techdocs}: migrate to support new auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .changeset/nice-beans-wait.md | 7 +++++ .../api-report.md | 2 ++ .../DefaultCatalogCollatorFactory.ts | 21 ++++++++++--- .../api-report.md | 2 ++ .../collators/ToolDocumentCollatorFactory.ts | 25 ++++++++-------- .../api-report.md | 4 +++ .../src/alpha.ts | 6 ++++ .../DefaultTechDocsCollatorFactory.ts | 30 +++++++++++++++---- 8 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 .changeset/nice-beans-wait.md diff --git a/.changeset/nice-beans-wait.md b/.changeset/nice-beans-wait.md new file mode 100644 index 0000000000..7cbdbc0fdf --- /dev/null +++ b/.changeset/nice-beans-wait.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-search-backend-module-techdocs': patch +'@backstage/plugin-search-backend-module-catalog': patch +'@backstage/plugin-search-backend-module-explore': patch +--- + +Migrated to support new auth services. diff --git a/plugins/search-backend-module-catalog/api-report.md b/plugins/search-backend-module-catalog/api-report.md index 6f5813ca9e..fc19ede2d4 100644 --- a/plugins/search-backend-module-catalog/api-report.md +++ b/plugins/search-backend-module-catalog/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { AuthService } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; import { Config } from '@backstage/config'; @@ -41,6 +42,7 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { // @public (undocumented) export type DefaultCatalogCollatorFactoryOptions = { + auth?: AuthService; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; locationTemplate?: string; diff --git a/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts b/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts index 586346c385..166985f2d5 100644 --- a/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts +++ b/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts @@ -17,6 +17,7 @@ import { PluginEndpointDiscovery, TokenManager, + createLegacyAuthAdapters, } from '@backstage/backend-common'; import { CatalogApi, @@ -33,9 +34,11 @@ import { Readable } from 'stream'; import { CatalogCollatorEntityTransformer } from './CatalogCollatorEntityTransformer'; import { readCollatorConfigOptions } from './config'; import { defaultCatalogCollatorEntityTransformer } from './defaultCatalogCollatorEntityTransformer'; +import { AuthService } from '@backstage/backend-plugin-api'; /** @public */ export type DefaultCatalogCollatorFactoryOptions = { + auth?: AuthService; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; /** @@ -71,20 +74,26 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { private filter?: GetEntitiesRequest['filter']; private batchSize: number; private readonly catalogClient: CatalogApi; - private tokenManager: TokenManager; private entityTransformer: CatalogCollatorEntityTransformer; + private auth: AuthService; static fromConfig( configRoot: Config, options: DefaultCatalogCollatorFactoryOptions, ) { const configOptions = readCollatorConfigOptions(configRoot); + const { auth: adaptedAuth } = createLegacyAuthAdapters({ + auth: options.auth, + discovery: options.discovery, + tokenManager: options.tokenManager, + }); return new DefaultCatalogCollatorFactory({ locationTemplate: options.locationTemplate ?? configOptions.locationTemplate, filter: options.filter ?? configOptions.filter, batchSize: options.batchSize ?? configOptions.batchSize, entityTransformer: options.entityTransformer, + auth: adaptedAuth, discovery: options.discovery, tokenManager: options.tokenManager, catalogClient: options.catalogClient, @@ -96,17 +105,18 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { filter: GetEntitiesRequest['filter']; batchSize: number; entityTransformer?: CatalogCollatorEntityTransformer; + auth: AuthService; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; catalogClient?: CatalogApi; }) { const { + auth, batchSize, discovery, locationTemplate, filter, catalogClient, - tokenManager, entityTransformer, } = options; @@ -115,9 +125,9 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { this.batchSize = batchSize; this.catalogClient = catalogClient || new CatalogClient({ discoveryApi: discovery }); - this.tokenManager = tokenManager; this.entityTransformer = entityTransformer ?? defaultCatalogCollatorEntityTransformer; + this.auth = auth; } async getCollator(): Promise { @@ -125,7 +135,6 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { } private async *execute(): AsyncGenerator { - const { token } = await this.tokenManager.getToken(); let entitiesRetrieved = 0; let moreEntitiesToGet = true; @@ -133,6 +142,10 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { // limit (and allow some control over) memory used by the search backend // at index-time. while (moreEntitiesToGet) { + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); const entities = ( await this.catalogClient.getEntities( { diff --git a/plugins/search-backend-module-explore/api-report.md b/plugins/search-backend-module-explore/api-report.md index 50cda184a9..43788340c0 100644 --- a/plugins/search-backend-module-explore/api-report.md +++ b/plugins/search-backend-module-explore/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { AuthService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { ExploreTool } from '@backstage/plugin-explore-common'; @@ -37,5 +38,6 @@ export type ToolDocumentCollatorFactoryOptions = { discovery: PluginEndpointDiscovery; logger: Logger; tokenManager?: TokenManager; + auth?: AuthService; }; ``` diff --git a/plugins/search-backend-module-explore/src/collators/ToolDocumentCollatorFactory.ts b/plugins/search-backend-module-explore/src/collators/ToolDocumentCollatorFactory.ts index bf6f07839b..daf83ec02b 100644 --- a/plugins/search-backend-module-explore/src/collators/ToolDocumentCollatorFactory.ts +++ b/plugins/search-backend-module-explore/src/collators/ToolDocumentCollatorFactory.ts @@ -17,7 +17,9 @@ import { PluginEndpointDiscovery, TokenManager, + createLegacyAuthAdapters, } from '@backstage/backend-common'; +import { AuthService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { ExploreTool } from '@backstage/plugin-explore-common'; import { @@ -44,6 +46,7 @@ export type ToolDocumentCollatorFactoryOptions = { discovery: PluginEndpointDiscovery; logger: Logger; tokenManager?: TokenManager; + auth?: AuthService; }; /** @@ -56,12 +59,13 @@ export class ToolDocumentCollatorFactory implements DocumentCollatorFactory { private readonly discovery: PluginEndpointDiscovery; private readonly logger: Logger; - private readonly tokenManager?: TokenManager; + private readonly auth: AuthService; private constructor(options: ToolDocumentCollatorFactoryOptions) { this.discovery = options.discovery; this.logger = options.logger; - this.tokenManager = options.tokenManager; + + this.auth = createLegacyAuthAdapters(options).auth; } static fromConfig( @@ -94,16 +98,13 @@ export class ToolDocumentCollatorFactory implements DocumentCollatorFactory { private async fetchTools() { const baseUrl = await this.discovery.getBaseUrl('explore'); - let headers = {}; - - if (this.tokenManager) { - const { token } = await this.tokenManager.getToken(); - headers = { - Authorization: `Bearer ${token}`, - }; - } - - const response = await fetch(`${baseUrl}/tools`, headers); + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'explore', + }); + const response = await fetch(`${baseUrl}/tools`, { + headers: { Authorization: `Bearer ${token}` }, + }); if (!response.ok) { throw new Error( diff --git a/plugins/search-backend-module-techdocs/api-report.md b/plugins/search-backend-module-techdocs/api-report.md index 592cf5065a..1a8c92e5bc 100644 --- a/plugins/search-backend-module-techdocs/api-report.md +++ b/plugins/search-backend-module-techdocs/api-report.md @@ -5,10 +5,12 @@ ```ts /// +import { AuthService } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { Entity } from '@backstage/catalog-model'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -44,6 +46,8 @@ export type TechDocsCollatorFactoryOptions = { discovery: PluginEndpointDiscovery; logger: Logger; tokenManager: TokenManager; + auth?: AuthService; + httpAuth?: HttpAuthService; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; diff --git a/plugins/search-backend-module-techdocs/src/alpha.ts b/plugins/search-backend-module-techdocs/src/alpha.ts index e8be7cc864..f7a6bb0d36 100644 --- a/plugins/search-backend-module-techdocs/src/alpha.ts +++ b/plugins/search-backend-module-techdocs/src/alpha.ts @@ -74,6 +74,8 @@ export default createBackendModule({ deps: { config: coreServices.rootConfig, logger: coreServices.logger, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, scheduler: coreServices.scheduler, @@ -83,6 +85,8 @@ export default createBackendModule({ async init({ config, logger, + auth, + httpAuth, discovery, tokenManager, scheduler, @@ -106,6 +110,8 @@ export default createBackendModule({ factory: DefaultTechDocsCollatorFactory.fromConfig(config, { discovery, tokenManager, + auth, + httpAuth, logger: loggerToWinstonLogger(logger), catalogClient: catalog, entityTransformer: transformer, diff --git a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts index b76fedbdf9..1234c5c6fa 100644 --- a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts +++ b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts @@ -17,6 +17,7 @@ import { PluginEndpointDiscovery, TokenManager, + createLegacyAuthAdapters, } from '@backstage/backend-common'; import { CatalogApi, @@ -41,6 +42,7 @@ import { Readable } from 'stream'; import { Logger } from 'winston'; import { TechDocsCollatorEntityTransformer } from './TechDocsCollatorEntityTransformer'; import { defaultTechDocsCollatorEntityTransformer } from './defaultTechDocsCollatorEntityTransformer'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; interface MkSearchIndexDoc { title: string; @@ -57,6 +59,8 @@ export type TechDocsCollatorFactoryOptions = { discovery: PluginEndpointDiscovery; logger: Logger; tokenManager: TokenManager; + auth?: AuthService; + httpAuth?: HttpAuthService; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; @@ -84,8 +88,8 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { private discovery: PluginEndpointDiscovery; private locationTemplate: string; private readonly logger: Logger; + private readonly auth: AuthService; private readonly catalogClient: CatalogApi; - private readonly tokenManager: TokenManager; private readonly parallelismLimit: number; private readonly legacyPathCasing: boolean; private entityTransformer: TechDocsCollatorEntityTransformer; @@ -100,9 +104,14 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { new CatalogClient({ discoveryApi: options.discovery }); this.parallelismLimit = options.parallelismLimit ?? 10; this.legacyPathCasing = options.legacyPathCasing ?? false; - this.tokenManager = options.tokenManager; this.entityTransformer = options.entityTransformer ?? defaultTechDocsCollatorEntityTransformer; + + this.auth = createLegacyAuthAdapters({ + auth: options.auth, + discovery: options.discovery, + tokenManager: options.tokenManager, + }).auth; } static fromConfig(config: Config, options: TechDocsCollatorFactoryOptions) { @@ -131,7 +140,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { private async *execute(): AsyncGenerator { const limit = pLimit(this.parallelismLimit); const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs'); - const { token } = await this.tokenManager.getToken(); + let entitiesRetrieved = 0; let moreEntitiesToGet = true; @@ -141,6 +150,11 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { // parallelism limit to simplify configuration. const batchSize = this.parallelismLimit * 50; while (moreEntitiesToGet) { + const { token: catalogToken } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + const entities = ( await this.catalogClient.getEntities( { @@ -151,7 +165,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { limit: batchSize, offset: entitiesRetrieved, }, - { token }, + { token: catalogToken }, ) ).items; @@ -174,6 +188,12 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { ); try { + const { token: techdocsToken } = + await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'techdocs', + }); + const searchIndexResponse = await fetch( DefaultTechDocsCollatorFactory.constructDocsIndexUrl( techDocsBaseUrl, @@ -181,7 +201,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { ), { headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${techdocsToken}`, }, }, ); From 8efe690204d2a58621aa734c361b728b50e3845b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 19:06:49 +0100 Subject: [PATCH 077/176] code-coverage-backend: migrate to support new auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .changeset/friendly-coats-travel.md | 5 ++ plugins/code-coverage-backend/api-report.md | 6 +++ plugins/code-coverage-backend/package.json | 1 + .../src/service/router.test.ts | 25 +++++----- .../src/service/router.ts | 46 +++++++++++++------ yarn.lock | 1 + 6 files changed, 60 insertions(+), 24 deletions(-) create mode 100644 .changeset/friendly-coats-travel.md diff --git a/.changeset/friendly-coats-travel.md b/.changeset/friendly-coats-travel.md new file mode 100644 index 0000000000..70d684a3da --- /dev/null +++ b/.changeset/friendly-coats-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage-backend': patch +--- + +Migrated to support new auth services. diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md index 8a64361391..fef869a302 100644 --- a/plugins/code-coverage-backend/api-report.md +++ b/plugins/code-coverage-backend/api-report.md @@ -3,10 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -21,6 +23,8 @@ export function createRouter(options: RouterOptions): Promise; // @public export interface RouterOptions { + // (undocumented) + auth?: AuthService; // (undocumented) catalogApi?: CatalogApi; // (undocumented) @@ -30,6 +34,8 @@ export interface RouterOptions { // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) + httpAuth?: HttpAuthService; + // (undocumented) logger: Logger; // (undocumented) urlReader: UrlReader; diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index b3c3aae2d7..acdeab5a23 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -48,6 +48,7 @@ "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/body-parser-xml": "^2.0.2", "@types/supertest": "^2.0.8", diff --git a/plugins/code-coverage-backend/src/service/router.test.ts b/plugins/code-coverage-backend/src/service/router.test.ts index 2032a5470d..64ea515ac0 100644 --- a/plugins/code-coverage-backend/src/service/router.test.ts +++ b/plugins/code-coverage-backend/src/service/router.test.ts @@ -26,6 +26,7 @@ import { import { ConfigReader } from '@backstage/config'; import { createRouter } from './router'; import { CatalogRequestOptions } from '@backstage/catalog-client'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; jest.mock('./CodeCoverageDatabase'); @@ -96,6 +97,8 @@ describe('createRouter', () => { discovery: testDiscovery, urlReader: mockUrlReader, logger: getVoidLogger(), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), }); app = express().use(router); }); @@ -118,21 +121,21 @@ describe('createRouter', () => { '/history?entity=component:default/mycomponent', ].forEach(uri => { describe(`GET ${uri}`, () => { - it('does not send token when calling catalog api and request is unauthenticated', async () => { - const response = await request(app).get(uri); - - expect(response.status).toEqual(200); - expect(catalogRequestOptions.token).toBeUndefined(); - }); - - it('includes auth token when calling catalog api', async () => { - const token = 'my-auth-token'; + it('forwards request credentials to the catalog api call', async () => { const response = await request(app) .get(uri) - .set('Authorization', `Bearer ${token}`); + .set( + 'Authorization', + mockCredentials.user.header('user:default/other'), + ); expect(response.status).toEqual(200); - expect(catalogRequestOptions.token).toEqual(token); + expect(catalogRequestOptions.token).toEqual( + mockCredentials.service.token({ + onBehalfOf: mockCredentials.user('user:default/other'), + targetPluginId: 'catalog', + }), + ); }); }); }); diff --git a/plugins/code-coverage-backend/src/service/router.ts b/plugins/code-coverage-backend/src/service/router.ts index 47bc285d62..d1a0a1703c 100644 --- a/plugins/code-coverage-backend/src/service/router.ts +++ b/plugins/code-coverage-backend/src/service/router.ts @@ -21,6 +21,7 @@ import BodyParser from 'body-parser'; import bodyParserXml from 'body-parser-xml'; import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; import { + createLegacyAuthAdapters, errorHandler, PluginDatabaseManager, PluginEndpointDiscovery, @@ -33,7 +34,7 @@ import { CodeCoverageDatabase } from './CodeCoverageDatabase'; import { aggregateCoverage, CoverageUtils } from './CoverageUtils'; import { Converter, Jacoco, Cobertura, Lcov } from './converter'; import { getEntitySourceLocation } from '@backstage/catalog-model'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; /** * Options for {@link createRouter}. @@ -47,6 +48,8 @@ export interface RouterOptions { urlReader: UrlReader; logger: Logger; catalogApi?: CatalogApi; + auth?: AuthService; + httpAuth?: HttpAuthService; } export interface CodeCoverageApi { @@ -63,6 +66,7 @@ export const makeRouter = async ( const catalogApi = options.catalogApi ?? new CatalogClient({ discoveryApi: discovery }); const scm = ScmIntegrations.fromConfig(config); + const { auth, httpAuth } = createLegacyAuthAdapters(options); const bodySizeLimit = config.getOptionalString('codeCoverage.bodySizeLimit') ?? '100kb'; @@ -92,9 +96,13 @@ export const makeRouter = async ( */ router.get('/report', async (req, res) => { const { entity } = req.query; - const entityLookup = await catalogApi.getEntityByRef(entity as string, { - token: getBearerTokenFromAuthorizationHeader(req.headers.authorization), - }); + const entityLookup = await catalogApi.getEntityByRef( + entity as string, + await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', + }), + ); if (!entityLookup) { throw new NotFoundError(`No entity found matching ${entity}`); } @@ -116,9 +124,13 @@ export const makeRouter = async ( */ router.get('/history', async (req, res) => { const { entity } = req.query; - const entityLookup = await catalogApi.getEntityByRef(entity as string, { - token: getBearerTokenFromAuthorizationHeader(req.headers.authorization), - }); + const entityLookup = await catalogApi.getEntityByRef( + entity as string, + await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', + }), + ); if (!entityLookup) { throw new NotFoundError(`No entity found matching ${entity}`); } @@ -136,9 +148,13 @@ export const makeRouter = async ( */ router.get('/file-content', async (req, res) => { const { entity, path } = req.query; - const entityLookup = await catalogApi.getEntityByRef(entity as string, { - token: getBearerTokenFromAuthorizationHeader(req.headers.authorization), - }); + const entityLookup = await catalogApi.getEntityByRef( + entity as string, + await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', + }), + ); if (!entityLookup) { throw new NotFoundError(`No entity found matching ${entity}`); } @@ -189,9 +205,13 @@ export const makeRouter = async ( */ router.post('/report', async (req, res) => { const { entity: entityRef, coverageType } = req.query; - const entity = await catalogApi.getEntityByRef(entityRef as string, { - token: getBearerTokenFromAuthorizationHeader(req.headers.authorization), - }); + const entity = await catalogApi.getEntityByRef( + entityRef as string, + await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', + }), + ); if (!entity) { throw new NotFoundError(`No entity found matching ${entityRef}`); } diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..08887fd8d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6014,6 +6014,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" From 55191cc4f6f45714b2a31c8713fb684e54432093 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Feb 2024 13:30:38 +0100 Subject: [PATCH 078/176] jenkins-backend: migrated to use auth services Signed-off-by: Patrik Oldsberg --- .changeset/heavy-coats-sniff.md | 7 +++ packages/backend/src/plugins/jenkins.ts | 2 + plugins/jenkins-backend/api-report.md | 16 ++++- plugins/jenkins-backend/package.json | 1 + plugins/jenkins-backend/src/plugin.ts | 16 ++++- plugins/jenkins-backend/src/run.ts | 4 +- .../src/service/jenkinsApi.test.ts | 6 ++ .../jenkins-backend/src/service/jenkinsApi.ts | 15 ++--- .../src/service/jenkinsInfoProvider.test.ts | 59 +++++++++++-------- .../src/service/jenkinsInfoProvider.ts | 26 ++++++-- plugins/jenkins-backend/src/service/router.ts | 38 ++++++------ .../src/service/standaloneServer.ts | 5 +- yarn.lock | 1 + 13 files changed, 136 insertions(+), 60 deletions(-) create mode 100644 .changeset/heavy-coats-sniff.md diff --git a/.changeset/heavy-coats-sniff.md b/.changeset/heavy-coats-sniff.md new file mode 100644 index 0000000000..c503ec0dec --- /dev/null +++ b/.changeset/heavy-coats-sniff.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-jenkins-backend': minor +--- + +**BREAKING**: Both `createRouter` and `DefaultJenkinsInfoProvider.fromConfig` now require the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services. + +The `JenkinsInfoProvider` interface has been updated to receive `credentials` of the type `BackstageCredentials` rather than a token. diff --git a/packages/backend/src/plugins/jenkins.ts b/packages/backend/src/plugins/jenkins.ts index d62200b0ac..7d47ee338d 100644 --- a/packages/backend/src/plugins/jenkins.ts +++ b/packages/backend/src/plugins/jenkins.ts @@ -32,6 +32,8 @@ export default async function createPlugin( jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({ catalog, config: env.config, + discovery: env.discovery, }), + discovery: env.discovery, }); } diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 28dafa70bf..e1cd04d8ec 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -3,11 +3,15 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Logger } from 'winston'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; @@ -21,12 +25,14 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static fromConfig(options: { config: Config; catalog: CatalogApi; + discovery: DiscoveryService; + auth?: AuthService; }): DefaultJenkinsInfoProvider; // (undocumented) getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; // (undocumented) static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; @@ -61,7 +67,7 @@ export interface JenkinsInfoProvider { getInstance(options: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; } @@ -86,6 +92,12 @@ export default jenkinsPlugin; // @public (undocumented) export interface RouterOptions { + // (undocumented) + auth?: AuthService; + // (undocumented) + discovery: DiscoveryService; + // (undocumented) + httpAuth?: HttpAuthService; // (undocumented) jenkinsInfoProvider: JenkinsInfoProvider; // (undocumented) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 069e4f7ae8..eaf8218a01 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -50,6 +50,7 @@ "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/jenkins": "^1.0.0", "@types/supertest": "^2.0.8" diff --git a/plugins/jenkins-backend/src/plugin.ts b/plugins/jenkins-backend/src/plugin.ts index 8da26d62aa..c02fc3e189 100644 --- a/plugins/jenkins-backend/src/plugin.ts +++ b/plugins/jenkins-backend/src/plugin.ts @@ -38,12 +38,24 @@ export const jenkinsPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, config: coreServices.rootConfig, catalogClient: catalogServiceRef, + discovery: coreServices.discovery, + auth: coreServices.auth, }, - async init({ logger, permissions, httpRouter, config, catalogClient }) { + async init({ + logger, + permissions, + httpRouter, + config, + catalogClient, + discovery, + auth, + }) { const winstonLogger = loggerToWinstonLogger(logger); const jenkinsInfoProvider = DefaultJenkinsInfoProvider.fromConfig({ + auth, config, catalog: catalogClient, + discovery, }); httpRouter.use( await createRouter({ @@ -56,6 +68,8 @@ export const jenkinsPlugin = createBackendPlugin({ * Info provider to be able to get all necessary information for the APIs */ jenkinsInfoProvider, + discovery, + auth, }), ); }, diff --git a/plugins/jenkins-backend/src/run.ts b/plugins/jenkins-backend/src/run.ts index 0a3ed2b7f0..95ff18a510 100644 --- a/plugins/jenkins-backend/src/run.ts +++ b/plugins/jenkins-backend/src/run.ts @@ -17,12 +17,14 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; +import { ConfigReader } from '@backstage/config'; const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); +const config = new ConfigReader({}); -startStandaloneServer({ port, enableCors, logger }).catch(err => { +startStandaloneServer({ config, port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 8e80de34a3..2d4fb1d62e 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -20,6 +20,7 @@ import { JenkinsInfo } from './jenkinsInfoProvider'; import { JenkinsBuild, JenkinsProject } from '../types'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import fetch, { Response } from 'node-fetch'; +import { mockServices } from '@backstage/backend-test-utils'; jest.mock('jenkins'); jest.mock('node-fetch'); @@ -716,6 +717,8 @@ describe('JenkinsApi', () => { ); }); describe('rebuildProject', () => { + const auth = mockServices.auth(); + it('successfully rebuilds', async () => { mockFetch.mockResolvedValueOnce({ status: 200 } as Response); const status = await jenkinsApi.rebuildProject( @@ -723,6 +726,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(200); }); @@ -733,6 +737,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(401); }); @@ -750,6 +755,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(401); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 2cf432343f..9171670743 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -23,12 +23,13 @@ import type { JenkinsProject, ScmDetails, } from '../types'; -import { - AuthorizeResult, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; import fetch, { HeaderInit } from 'node-fetch'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; export class JenkinsApiImpl { private static readonly lastBuildTreeSpec = `lastBuild[ @@ -75,7 +76,7 @@ export class JenkinsApiImpl { inQueue, builds[*]`; - constructor(private readonly permissionApi?: PermissionEvaluator) {} + constructor(private readonly permissionApi?: PermissionsService) {} /** * Get a list of projects for the given JenkinsInfo. @@ -160,12 +161,12 @@ export class JenkinsApiImpl { jobFullName: string, buildNumber: number, resourceRef: string, - options?: { token?: string }, + options: { credentials: BackstageCredentials }, ): Promise { if (this.permissionApi) { const response = await this.permissionApi.authorize( [{ permission: jenkinsExecutePermission, resourceRef }], - { token: options?.token }, + { credentials: options.credentials }, ); // permission api returns always at least one item, we need to check only one result since we do not expect any additional results const { result } = response[0]; diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 626e5b0a5a..6be3ec572c 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -22,6 +22,7 @@ import { JenkinsConfig, JenkinsInfo, } from './jenkinsInfoProvider'; +import { mockServices } from '@backstage/backend-test-utils'; describe('JenkinsConfig', () => { it('Reads simple config and annotation', async () => { @@ -184,6 +185,8 @@ describe('DefaultJenkinsInfoProvider', () => { return DefaultJenkinsInfoProvider.fromConfig({ config, catalog: mockCatalog, + discovery: mockServices.discovery(), + auth: mockServices.auth(), }); } @@ -191,9 +194,10 @@ describe('DefaultJenkinsInfoProvider', () => { const provider = configureProvider({ jenkins: {} }, undefined); await expect(provider.getInstance({ entityRef })).rejects.toThrow(); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); }); it('Reads simple config and annotation', async () => { @@ -218,9 +222,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', crumbIssuer: undefined, @@ -257,9 +262,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -296,9 +302,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -335,9 +342,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', @@ -363,9 +371,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -391,9 +400,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -424,9 +434,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index c4b4ef58dd..0feb91d68e 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +import { createLegacyAuthAdapters } from '@backstage/backend-common'; +import { + AuthService, + BackstageCredentials, + DiscoveryService, +} from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity, @@ -34,7 +40,7 @@ export interface JenkinsInfoProvider { */ jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; } @@ -183,27 +189,37 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { private constructor( private readonly config: JenkinsConfig, private readonly catalog: CatalogApi, + private readonly auth: AuthService, ) {} static fromConfig(options: { config: Config; catalog: CatalogApi; + discovery: DiscoveryService; + auth?: AuthService; }): DefaultJenkinsInfoProvider { + const { auth } = createLegacyAuthAdapters(options); return new DefaultJenkinsInfoProvider( JenkinsConfig.fromConfig(options.config), options.catalog, + auth, ); } async getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise { // load entity - const entity = await this.catalog.getEntityByRef(opt.entityRef, { - token: opt.backstageToken, - }); + const entity = await this.catalog.getEntityByRef( + opt.entityRef, + opt.credentials && + (await this.auth.getPluginRequestToken({ + onBehalfOf: opt.credentials, + targetPluginId: 'catalog', + })), + ); if (!entity) { throw new Error( `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 06a04d8266..bda9686efb 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { + createLegacyAuthAdapters, + errorHandler, +} from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -25,17 +28,24 @@ import { PermissionEvaluator, toPermissionEvaluator, } from '@backstage/plugin-permission-common'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { stringifyError } from '@backstage/errors'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import { jenkinsPermissions } from '@backstage/plugin-jenkins-common'; +import { + AuthService, + DiscoveryService, + HttpAuthService, +} from '@backstage/backend-plugin-api'; /** @public */ export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; permissions?: PermissionEvaluator | PermissionAuthorizer; + discovery: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; } /** @public */ @@ -56,6 +66,8 @@ export async function createRouter( : undefined; } + const { httpAuth } = createLegacyAuthAdapters(options); + const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); const router = Router(); @@ -70,9 +82,6 @@ export async function createRouter( '/v1/entity/:namespace/:kind/:name/projects', async (request, response) => { const { namespace, kind, name } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const branch = request.query.branch; let branches: string[] | undefined; @@ -96,7 +105,7 @@ export async function createRouter( namespace, name, }, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); try { @@ -123,9 +132,6 @@ export async function createRouter( router.get( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const { namespace, kind, name, jobFullName, buildNumber } = request.params; @@ -136,7 +142,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const build = await jenkinsApi.getBuild( @@ -154,9 +160,6 @@ export async function createRouter( router.get( '/v1/entity/:namespace/:kind/:name/job/:jobFullName', async (request, response) => { - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const { namespace, kind, name, jobFullName } = request.params; const jenkinsInfo = await jenkinsInfoProvider.getInstance({ @@ -166,7 +169,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName); @@ -182,9 +185,6 @@ export async function createRouter( async (request, response) => { const { namespace, kind, name, jobFullName, buildNumber } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { kind, @@ -192,7 +192,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const resourceRef = stringifyEntityRef({ kind, namespace, name }); @@ -202,7 +202,7 @@ export async function createRouter( parseInt(buildNumber, 10), resourceRef, { - token, + credentials: await httpAuth.credentials(request), }, ); response.json({}).status(status); diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts index f89f235667..ab77bb6573 100644 --- a/plugins/jenkins-backend/src/service/standaloneServer.ts +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -14,17 +14,19 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { HostDiscovery, createServiceBuilder } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { JenkinsInfo } from './jenkinsInfoProvider'; +import { Config } from '@backstage/config'; export interface ServerOptions { port: number; enableCors: boolean; logger: Logger; + config: Config; } export async function startStandaloneServer( @@ -41,6 +43,7 @@ export async function startStandaloneServer( return { baseUrl: 'https://example.com/', jobFullName: 'build-foo' }; }, }, + discovery: HostDiscovery.fromConfig(options.config), }); let service = createServiceBuilder(module) diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..675020750d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7058,6 +7058,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" From 5b2452dcda52c1157dbd7589dc25c3b627f3fd9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 00:47:24 +0000 Subject: [PATCH 079/176] fix(deps): update dependency @uiw/react-codemirror to v4.21.24 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..9bd89355ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20086,9 +20086,9 @@ __metadata: languageName: node linkType: hard -"@uiw/codemirror-extensions-basic-setup@npm:4.21.23": - version: 4.21.23 - resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.21.23" +"@uiw/codemirror-extensions-basic-setup@npm:4.21.24": + version: 4.21.24 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.21.24" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/commands": ^6.0.0 @@ -20105,19 +20105,19 @@ __metadata: "@codemirror/search": ">=6.0.0" "@codemirror/state": ">=6.0.0" "@codemirror/view": ">=6.0.0" - checksum: cd17481d9d9a9b620f961a4df6e8208bcabe98652ca6c18366a8688fbcc09d37a030694a70dcc010aaabe02c85c342acf9f80357c6853b57f1081af5b130bd26 + checksum: db42a1651d7d482e1811cd629a3a8a53c3ac09bfabf376dd35c0cbbaf5780f80c873d6da55294245850f7d8e13da92ec5885eea4938c85c957c38c23394e11f9 languageName: node linkType: hard "@uiw/react-codemirror@npm:^4.9.3": - version: 4.21.23 - resolution: "@uiw/react-codemirror@npm:4.21.23" + version: 4.21.24 + resolution: "@uiw/react-codemirror@npm:4.21.24" dependencies: "@babel/runtime": ^7.18.6 "@codemirror/commands": ^6.1.0 "@codemirror/state": ^6.1.1 "@codemirror/theme-one-dark": ^6.0.0 - "@uiw/codemirror-extensions-basic-setup": 4.21.23 + "@uiw/codemirror-extensions-basic-setup": 4.21.24 codemirror: ^6.0.0 peerDependencies: "@babel/runtime": ">=7.11.0" @@ -20127,7 +20127,7 @@ __metadata: codemirror: ">=6.0.0" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 7d0209d947e1e57cf80ef44a097bb3af04d2bab2d6fc57deed91619a0db77e3d4289db8f03fb43906b2e324496d25c872fa99b83834f085c40ad95137ea4459c + checksum: 6adbee6608f3ec0806ff4b2d2e759fd314d3727c805cf58a6b1d524fe4e12b261b38d31374307eeb51aa34f68e113b298efdc13de807e93ca50a357e5b0f3eff languageName: node linkType: hard From c69abca233b5535f070bee2491c038085bced935 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 00:48:46 +0000 Subject: [PATCH 080/176] fix(deps): update aws-sdk-js-v3 monorepo to v3.521.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 1518 ++++++++++++++++++++++++++--------------------------- 1 file changed, 759 insertions(+), 759 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5aa82df2e2..43b561f068 100644 --- a/yarn.lock +++ b/yarn.lock @@ -363,569 +363,569 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-cognito-identity@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/client-cognito-identity@npm:3.515.0" +"@aws-sdk/client-cognito-identity@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/client-cognito-identity@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: e254357719b355a7c6cdd718c3896aca9971ea9479887c221841ec2c6e91f15b880f6a5d1ebfc62af223b4d02a0d4a882aa8394b436f9b544658ee87a481e1ac + checksum: e664da040d7688b97603dc49310c40acaae95c87dd2b32786664d5ee553a5e4079677b5ebcf5e85137434eafebe20c1d9f6f49b05926394d5b82cac2bcc2bbb8 languageName: node linkType: hard "@aws-sdk/client-eks@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/client-eks@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/client-eks@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 - "@smithy/util-waiter": ^2.1.1 + "@smithy/util-waiter": ^2.1.2 tslib: ^2.5.0 uuid: ^9.0.1 - checksum: 38366b504f5cda083637801b8a89cb03fb05fca56e6266a18d4fa4772a5556a461efcb82c8ed9690d8ba4c49e2ec2bdd71c7d3f987d2e7a85506439de2199ff6 + checksum: c31d933e4f64173276e6c3a9cba1e699fc25d8b43daff15cc52e23c4bbfdbac63f7e6cc8c55910bde551b568c6c7f928b432d518fe6251c5d1fad65854d44861 languageName: node linkType: hard "@aws-sdk/client-organizations@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/client-organizations@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/client-organizations@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 4b947f1fc5ca196b007855cc5d75accb82825adfe4dea370d181b9605bfe827ddc0f6cb073d7ef7f768a523be4e393a2dc1e28a999d118bda050f354997e8f60 + checksum: 1191e702f2123f2316f175dc81e3c00df05c79a53be709f166834c8425a600cff4d61bb0db7befc1fa3fc801dcccc05faab87f74adf0100b8428b4ee26c0a963 languageName: node linkType: hard "@aws-sdk/client-s3@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/client-s3@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/client-s3@npm:3.521.0" dependencies: "@aws-crypto/sha1-browser": 3.0.0 "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/middleware-bucket-endpoint": 3.515.0 - "@aws-sdk/middleware-expect-continue": 3.515.0 - "@aws-sdk/middleware-flexible-checksums": 3.515.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-location-constraint": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-sdk-s3": 3.515.0 - "@aws-sdk/middleware-signing": 3.515.0 - "@aws-sdk/middleware-ssec": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/signature-v4-multi-region": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@aws-sdk/xml-builder": 3.496.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/eventstream-serde-browser": ^2.1.1 - "@smithy/eventstream-serde-config-resolver": ^2.1.1 - "@smithy/eventstream-serde-node": ^2.1.1 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-blob-browser": ^2.1.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/hash-stream-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/md5-js": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/middleware-bucket-endpoint": 3.521.0 + "@aws-sdk/middleware-expect-continue": 3.521.0 + "@aws-sdk/middleware-flexible-checksums": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-location-constraint": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-sdk-s3": 3.521.0 + "@aws-sdk/middleware-signing": 3.521.0 + "@aws-sdk/middleware-ssec": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/signature-v4-multi-region": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@aws-sdk/xml-builder": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/eventstream-serde-browser": ^2.1.2 + "@smithy/eventstream-serde-config-resolver": ^2.1.2 + "@smithy/eventstream-serde-node": ^2.1.2 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-blob-browser": ^2.1.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/hash-stream-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/md5-js": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-retry": ^2.1.1 - "@smithy/util-stream": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-retry": ^2.1.2 + "@smithy/util-stream": ^2.1.2 "@smithy/util-utf8": ^2.1.1 - "@smithy/util-waiter": ^2.1.1 + "@smithy/util-waiter": ^2.1.2 fast-xml-parser: 4.2.5 tslib: ^2.5.0 - checksum: f61f91fb45500108520357e61a018975f4e4a49df378fa16ad0fc15b59560bdfcc11c6cb7d95ad1c4b6e0607e99788662e4013d8d529af75a36dffa5c8bfe5ca + checksum: bf3c5d6a42df6812f5645751bdbbb6cd69f8b28c3f0dd5ba7697a631e7573494e7cdc2bdc8081e49e5159a73c5a32259575a9d79f170753e8fbc53613e3ecc9c languageName: node linkType: hard "@aws-sdk/client-sqs@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/client-sqs@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/client-sqs@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-sdk-sqs": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/md5-js": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-sdk-sqs": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/md5-js": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: e92bbea7c7453cee74898ffde1092437693eb19b3a623243913805229ab6b1ec7905735a2dd58476915d574beeca2675f00eb89b5de8c3f099becc68c9ade901 + checksum: 9f65ffa57f0da1279f00754234e504b9f2250085a2bc843605f591b54a67929b73ee4402a40a291a5f9ce9e9936ad1d31676e37a12bb30c4c24f2bcb17483c5b languageName: node linkType: hard -"@aws-sdk/client-sso-oidc@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/client-sso-oidc@npm:3.515.0" +"@aws-sdk/client-sso-oidc@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/client-sso-oidc@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 peerDependencies: - "@aws-sdk/credential-provider-node": ^3.515.0 - checksum: f220a9ba8542460b2aa91ad060302fb9e68bdf096ecca2ec1d6e525f4df1036b330cb85d20bac3e8399276c0d8d8d388b3f2191b58804919c68369afac0be37b + "@aws-sdk/credential-provider-node": ^3.521.0 + checksum: da6b724cd91f128192eba0bbf0827c7e6fccb30f899240eb908eb62d0a57437e1ac7e28097d31b8af1d616a35a37af97f8dcf9137230cb98e1b6cc33a9f38d36 languageName: node linkType: hard -"@aws-sdk/client-sso@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/client-sso@npm:3.515.0" +"@aws-sdk/client-sso@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/client-sso@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 12287dfa469fb2c6b5bedd3cbd37f7416f8234669b5ed0ff38cb1217d746ba6a5e6ff227b091a7751d1c20489a6b8bd93bcbed8f394cb4c51b6bebb9a9f79108 + checksum: 1035c3beb9d090d6f3858be022c66127d246e2b6c88336c808300814628acd18666cb76f6f6dfcf622b3b67dd195a29230602608bea9b40dde54b98079331359 languageName: node linkType: hard -"@aws-sdk/client-sts@npm:3.515.0, @aws-sdk/client-sts@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/client-sts@npm:3.515.0" +"@aws-sdk/client-sts@npm:3.521.0, @aws-sdk/client-sts@npm:^3.350.0": + version: 3.521.0 + resolution: "@aws-sdk/client-sts@npm:3.521.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/core": 3.513.0 - "@aws-sdk/middleware-host-header": 3.515.0 - "@aws-sdk/middleware-logger": 3.515.0 - "@aws-sdk/middleware-recursion-detection": 3.515.0 - "@aws-sdk/middleware-user-agent": 3.515.0 - "@aws-sdk/region-config-resolver": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@aws-sdk/util-user-agent-browser": 3.515.0 - "@aws-sdk/util-user-agent-node": 3.515.0 - "@smithy/config-resolver": ^2.1.1 - "@smithy/core": ^1.3.2 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/hash-node": ^2.1.1 - "@smithy/invalid-dependency": ^2.1.1 - "@smithy/middleware-content-length": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@aws-sdk/core": 3.521.0 + "@aws-sdk/middleware-host-header": 3.521.0 + "@aws-sdk/middleware-logger": 3.521.0 + "@aws-sdk/middleware-recursion-detection": 3.521.0 + "@aws-sdk/middleware-user-agent": 3.521.0 + "@aws-sdk/region-config-resolver": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@aws-sdk/util-user-agent-browser": 3.521.0 + "@aws-sdk/util-user-agent-node": 3.521.0 + "@smithy/config-resolver": ^2.1.2 + "@smithy/core": ^1.3.3 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/hash-node": ^2.1.2 + "@smithy/invalid-dependency": ^2.1.2 + "@smithy/middleware-content-length": ^2.1.2 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 "@smithy/util-base64": ^2.1.1 "@smithy/util-body-length-browser": ^2.1.1 "@smithy/util-body-length-node": ^2.2.1 - "@smithy/util-defaults-mode-browser": ^2.1.1 - "@smithy/util-defaults-mode-node": ^2.2.0 - "@smithy/util-endpoints": ^1.1.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/util-defaults-mode-browser": ^2.1.2 + "@smithy/util-defaults-mode-node": ^2.2.1 + "@smithy/util-endpoints": ^1.1.2 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 "@smithy/util-utf8": ^2.1.1 fast-xml-parser: 4.2.5 tslib: ^2.5.0 peerDependencies: - "@aws-sdk/credential-provider-node": ^3.515.0 - checksum: 9af6a2484909e88a83c411551d55ad149c80a8f449c2e54c499769535243602a6283cd71f6a0cf975b295a321a74e90eb95f6659bba93bae3d12e2186e7545f4 + "@aws-sdk/credential-provider-node": ^3.521.0 + checksum: 1ca480532746fa6d81bf84bebf6e38ab3d2565789654465ec22bd8c34daf2c64165a90d70e29da8170da7ba9a18041c3da4a037f37689939798bf39c03348a50 languageName: node linkType: hard -"@aws-sdk/core@npm:3.513.0": - version: 3.513.0 - resolution: "@aws-sdk/core@npm:3.513.0" +"@aws-sdk/core@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/core@npm:3.521.0" dependencies: - "@smithy/core": ^1.3.2 - "@smithy/protocol-http": ^3.1.1 + "@smithy/core": ^1.3.3 + "@smithy/protocol-http": ^3.2.0 "@smithy/signature-v4": ^2.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 94a41263e5d0c754f4d6d603572704822b570d5fc5ed450c8eb461b989198b625d2c115a470b087defe2c6c45b9442527062382c9bb1ca32842332317300b2fe + checksum: 43d02d64563b6fc5c55be1fd62dc67a95b862e8355d9c8d574c09fdc3668f7524723aec7a23ce29a40188d56479cba705dd8470787bebfd30b98c046f3b29606 languageName: node linkType: hard -"@aws-sdk/credential-provider-cognito-identity@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.515.0" +"@aws-sdk/credential-provider-cognito-identity@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.521.0" dependencies: - "@aws-sdk/client-cognito-identity": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-cognito-identity": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: cb8cb5fa19b3e10e0a816eddda0a56a412da6bc68eba4807698ed94ef6bf2f3de288d959ced099b399dfc29f08154c84f8c025816e94226e5fb21c605e710542 + checksum: fce4a6b934839cb347fa603eb2c50f70e96e939528d3747492658de98e27d4f412256a40d3b63bb8d983ba81b695c7fff718632bd311c3d74e14194ea4553d95 languageName: node linkType: hard -"@aws-sdk/credential-provider-env@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-env@npm:3.515.0" +"@aws-sdk/credential-provider-env@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 3573bc3f1aa89bc8eedb9eb39c8c1d501a68aec5eb059364a1091c8bf10dfda9cfbd78ee49d3ad25ec1012f765a4464363c4cd70997e94005fd21245871a4229 + checksum: 5b217fa1fc86f1d553bab39ac30942e06dddbffe3061cfcafb978f61740749eec9155271ba241df8889c49e73b498a2e4036b384b8b6de8c63da8f5682228d41 languageName: node linkType: hard -"@aws-sdk/credential-provider-http@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-http@npm:3.515.0" +"@aws-sdk/credential-provider-http@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/node-http-handler": ^2.3.1 + "@aws-sdk/types": 3.521.0 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/node-http-handler": ^2.4.0 "@smithy/property-provider": ^2.1.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/util-stream": ^2.1.1 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/util-stream": ^2.1.2 tslib: ^2.5.0 - checksum: d13943dc7a83c9c129dd03a8b337b7753c791441d65a894085e00146d807738b420b9127f570a32497665be4f6e1cc8eeefd7cb1b013a04789d874c8b23b829f + checksum: b953861a460c2c871a390036e6ed5acdfd545e23204c56d2ae7835c46a43a598103175b9071107f9e817b9c386edc98a3b5e677ecc42b313b2d48cea2d11ca8e languageName: node linkType: hard -"@aws-sdk/credential-provider-ini@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-ini@npm:3.515.0" +"@aws-sdk/credential-provider-ini@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.521.0" dependencies: - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/credential-provider-env": 3.515.0 - "@aws-sdk/credential-provider-process": 3.515.0 - "@aws-sdk/credential-provider-sso": 3.515.0 - "@aws-sdk/credential-provider-web-identity": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/credential-provider-env": 3.521.0 + "@aws-sdk/credential-provider-process": 3.521.0 + "@aws-sdk/credential-provider-sso": 3.521.0 + "@aws-sdk/credential-provider-web-identity": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/credential-provider-imds": ^2.2.1 "@smithy/property-provider": ^2.1.1 "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c136d4257460be8331d7645854b7e3c91205a1eb12efd3dcbcd84501c787085292d1ccc4578c4776308d91748bde5af2c6eaa873b56aed83ab472a878a2d8883 + checksum: 41c9eda9ec49927999aab9137c46392576b84611c434c369c004eb99fcad212188dcf8dbc3bb9388a65b0077b52835b5219c0f68cfd640c2119772678608fad1 languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.515.0, @aws-sdk/credential-provider-node@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-node@npm:3.515.0" +"@aws-sdk/credential-provider-node@npm:3.521.0, @aws-sdk/credential-provider-node@npm:^3.350.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.521.0" dependencies: - "@aws-sdk/credential-provider-env": 3.515.0 - "@aws-sdk/credential-provider-http": 3.515.0 - "@aws-sdk/credential-provider-ini": 3.515.0 - "@aws-sdk/credential-provider-process": 3.515.0 - "@aws-sdk/credential-provider-sso": 3.515.0 - "@aws-sdk/credential-provider-web-identity": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/credential-provider-env": 3.521.0 + "@aws-sdk/credential-provider-http": 3.521.0 + "@aws-sdk/credential-provider-ini": 3.521.0 + "@aws-sdk/credential-provider-process": 3.521.0 + "@aws-sdk/credential-provider-sso": 3.521.0 + "@aws-sdk/credential-provider-web-identity": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/credential-provider-imds": ^2.2.1 "@smithy/property-provider": ^2.1.1 "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c51f267c61de0d82afe47d97cdf58971e3b8eec6e7364fe28d3867addd65e156358b96c39a335fad521e9a6925b349a967ae3eed1aa6e9cb4bcc1f0931e3ed50 + checksum: eaa75f81151113f84ccd7e26964a139fd598645f93074431ec5979a5ebf926df1da8659ecb46b4d79e4b83de30f7136954ea72fadc60a4e8bc5e828f193b7556 languageName: node linkType: hard -"@aws-sdk/credential-provider-process@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-process@npm:3.515.0" +"@aws-sdk/credential-provider-process@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 11159b4c9502218ec6cba9a46ddc120e53aec7f04507c14d4e99a186073cfd363af438c623705890a2e8f6cc792475ebd14c82766dad82dabf7f20d9708f7faf + checksum: 7770461063e9c330331f48401a16d0f792c97c5392b2f393e09f1be6c5ec0f97316c7fa11c2ebfc7392984acced03d5205558b264ed6417697993a9a466185b2 languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-sso@npm:3.515.0" +"@aws-sdk/credential-provider-sso@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.521.0" dependencies: - "@aws-sdk/client-sso": 3.515.0 - "@aws-sdk/token-providers": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-sso": 3.521.0 + "@aws-sdk/token-providers": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: fbe1eebc50e9bd3715bca4e6ee1a2922cf1ea383953730a6bd3b88b12f23a95cb3ff0edaf8578c81156ef65648eb0295f5c39ed6ae2c8e16b6ce9d4c46f207e9 + checksum: 76fbed5935eb4f7cd244f9d90bb5b9f1069226ecaaf03d69f16b4f607864541cc6b8e6d038a07eef27acff75df8f539c3c61d6b672f5bda4db1a1a3966815566 languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.515.0" +"@aws-sdk/credential-provider-web-identity@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.521.0" dependencies: - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: f0a7e9855f78849143c3139c613cc1531a88272f3ec039d23ac9366b94495a3e07212ade9541e56c93560ad9f58600376e9de38706a4cd97aaf5f400911361cd + checksum: 4e9360d0e6a55b7f60d837addb87dbd5d804e4202c2fadc75d11196a54dbb6099fa977fb0a51191d8c62373f17279c379a304b697ddd15abe5508a30c2a34556 languageName: node linkType: hard "@aws-sdk/credential-providers@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/credential-providers@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/credential-providers@npm:3.521.0" dependencies: - "@aws-sdk/client-cognito-identity": 3.515.0 - "@aws-sdk/client-sso": 3.515.0 - "@aws-sdk/client-sts": 3.515.0 - "@aws-sdk/credential-provider-cognito-identity": 3.515.0 - "@aws-sdk/credential-provider-env": 3.515.0 - "@aws-sdk/credential-provider-http": 3.515.0 - "@aws-sdk/credential-provider-ini": 3.515.0 - "@aws-sdk/credential-provider-node": 3.515.0 - "@aws-sdk/credential-provider-process": 3.515.0 - "@aws-sdk/credential-provider-sso": 3.515.0 - "@aws-sdk/credential-provider-web-identity": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-cognito-identity": 3.521.0 + "@aws-sdk/client-sso": 3.521.0 + "@aws-sdk/client-sts": 3.521.0 + "@aws-sdk/credential-provider-cognito-identity": 3.521.0 + "@aws-sdk/credential-provider-env": 3.521.0 + "@aws-sdk/credential-provider-http": 3.521.0 + "@aws-sdk/credential-provider-ini": 3.521.0 + "@aws-sdk/credential-provider-node": 3.521.0 + "@aws-sdk/credential-provider-process": 3.521.0 + "@aws-sdk/credential-provider-sso": 3.521.0 + "@aws-sdk/credential-provider-web-identity": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/credential-provider-imds": ^2.2.1 "@smithy/property-provider": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 8890e83fc19072c51cee8d50ee7168800b9775aa00bcb7a0feb836e60fbae0fb8ba55133d256719033cd7ccda0f5881350474ba3f4769016f01ce7de4ef7b6e5 + checksum: c4b3ca40a4e7a9843847f81f0a3f3ac568986e87054e44df8cad8ddf3f8df6314c4e67ca27b150b05cdfe6e96ccca32457654a121b09a9ba017819b9000d534b languageName: node linkType: hard @@ -951,34 +951,34 @@ __metadata: linkType: hard "@aws-sdk/lib-storage@npm:^3.350.0": - version: 3.515.0 - resolution: "@aws-sdk/lib-storage@npm:3.515.0" + version: 3.521.0 + resolution: "@aws-sdk/lib-storage@npm:3.521.0" dependencies: "@smithy/abort-controller": ^2.1.1 - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/smithy-client": ^2.3.1 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/smithy-client": ^2.4.0 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: ^2.5.0 peerDependencies: "@aws-sdk/client-s3": ^3.0.0 - checksum: b4d7b3783508ce3ef93150b2a249490c5af803d02d91ce81aebf0ec055870aae3260d4d66e9bf1d4234fb61d11cfaa54f2916c9c2572cf4cb6ee1b15993c41b5 + checksum: b55cdbe2744970f863f30e84ba39dc25d123032cf5e7b11a3fb8d6dc0aff7f3311f00112c72b686f4833ff6494c3b1c346cdb03cc5727652933ebd98dc1f8cf7 languageName: node linkType: hard -"@aws-sdk/middleware-bucket-endpoint@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.515.0" +"@aws-sdk/middleware-bucket-endpoint@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@aws-sdk/util-arn-parser": 3.495.0 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 "@smithy/util-config-provider": ^2.2.1 tslib: ^2.5.0 - checksum: 8ecec09ac50c33a24178e73be6b4238e5047984eebf54c4786392e82d2b1cdd09807ee97104f603d22890a58657335843f9587c66e544c2ffbb9a25f3e3bf065 + checksum: 407ea1c7d64159fa86ccc002333c646b841fa7cbec70400798af3b688418a1f44bea31dac4e462383f9221144b113c9ee33c5e585e1ef9d7d207cb91a2439cc7 languageName: node linkType: hard @@ -995,108 +995,108 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-expect-continue@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-expect-continue@npm:3.515.0" +"@aws-sdk/middleware-expect-continue@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c989e0e55f51c631f914b444a0e3a6a81c79c1ad8046346c16a4ccfbb77aeaf48ba948a6b8185fc36c00abf07951cc374c3673da6d23d7de6cd3ec8d5b4d840e + checksum: 28ed930f9e4d9d90d705c371c2ac3055fa1fdb8c6de44d1db899f6ade1231cbe3de7d4f6e7fa7dae41fe0d79d5ee5b068ba8ecaa885dc6a2a6100cc230b0847a languageName: node linkType: hard -"@aws-sdk/middleware-flexible-checksums@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.515.0" +"@aws-sdk/middleware-flexible-checksums@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.521.0" dependencies: "@aws-crypto/crc32": 3.0.0 "@aws-crypto/crc32c": 3.0.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@smithy/is-array-buffer": ^2.1.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 89f2df7a3dd40c174586aec1349e4e144825cffb252df843b115390702523dc6cfb745756a69a1ab1e77e44340fd55bed04be3856fe96d937af5702bcbcf869b + checksum: 60a77546090174ca7cfd8d6894c6d4583ae5ad311f85dc407c1d98128fa16322850a7284a4f75260d4ab5529296110b0952997c1726c325813988c14e2aba792 languageName: node linkType: hard -"@aws-sdk/middleware-host-header@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-host-header@npm:3.515.0" +"@aws-sdk/middleware-host-header@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: ff066cf47b0ba2c64bd70efdec795ac2da8bad7ba8dd44913c98f42b153ca6e753b13b6c1ef7075499590279a5cc49b5a60511dae4512dcdb11a62a0e67fa061 + checksum: e0e0597f436bce61c9fc2598d65db68610d8a4a8576e2d9073d8ffa0fe3099a76570c997fc9ab46ef18b1530102ef73101db04d28ce0a40f40ca76bf502d8db9 languageName: node linkType: hard -"@aws-sdk/middleware-location-constraint@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-location-constraint@npm:3.515.0" +"@aws-sdk/middleware-location-constraint@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c3e7c7b51c276eba7ceef05c18416d2cca976c83ac9db227877090c33f676b50b0c90ceaf5d924cd9894a4c2f44c0a2d06ec93da753ac646fecb68d5facef570 + checksum: 62b8ac417945c826a3042369116250e17b31df2f7f949494320de695985db0801986968503b366965dd0e1beaf25bd37890261dbc2e5fdbf2b0f3cf581ace603 languageName: node linkType: hard -"@aws-sdk/middleware-logger@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-logger@npm:3.515.0" +"@aws-sdk/middleware-logger@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-logger@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 32d251e77f43593ffdd192a4d0628f33773e29c14a3001a4c6519553e94958edbd0fb8e6954a65d1180b0caa16cafe9fc9b362d1ab663db1d1eac84b15667645 + checksum: 9ff749309bd457be1356d3efea53d9067c15baa631d4ba7d874172f087030d4ccbb7df38ccdecb6823944af2732b01caa3cd495a245fa5a4975a5442e2059532 languageName: node linkType: hard -"@aws-sdk/middleware-recursion-detection@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-recursion-detection@npm:3.515.0" +"@aws-sdk/middleware-recursion-detection@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 23c4a1e4d7de86196acfcfbc84bea84c8c3211c4831fdc7c975a6388022037bd5baa4e5809dca188631f06726b51fcf85af358f9ef526e255dc494b368d0da0c + checksum: a097d83c411944d30105a997520791d9a16800a4d5b5b8a77ef5dd8edb3616ecf358a669a4c116314bf60d2ab1c23d54f0c1d79a2e2bcfa38b84a5ec3b418b86 languageName: node linkType: hard -"@aws-sdk/middleware-sdk-s3@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-sdk-s3@npm:3.515.0" +"@aws-sdk/middleware-sdk-s3@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@aws-sdk/util-arn-parser": 3.495.0 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/protocol-http": ^3.1.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/protocol-http": ^3.2.0 "@smithy/signature-v4": ^2.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 "@smithy/util-config-provider": ^2.2.1 tslib: ^2.5.0 - checksum: cb67334b30eee8fcf52637407cab2787353e463bdf5a99a33966f0e765f6f8fc687c4bba2b63ec7a4374b2ab544be807e1849bd910e6f163ff43d036f026e7e1 + checksum: f29d1eed5f3f4de2bb0e85bd572d31f41f494f83f3903edaa45bb79024b6e7cca4bc404a57bb2d69dad58ea1ab92942016a7cd9a5295a54e96851e44a9ce649f languageName: node linkType: hard -"@aws-sdk/middleware-sdk-sqs@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-sdk-sqs@npm:3.515.0" +"@aws-sdk/middleware-sdk-sqs@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-sdk-sqs@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 "@smithy/util-hex-encoding": ^2.1.1 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 67a7d9ed3e975a3fd83266f18ce2d94e3dc457d7001af7031e732b2d96135678dd3323ecae56ffe214f3dbe23d1df295a0cd0d21df7f6b9c98699e6a9b4704ca + checksum: 4fcb19c74ce0a667b78fb053b25a4c6c57d84f8eb2167045922a6310d7ecc9def78abe6f2f310ac78327dc41f54347df0497098db00b571cf26029bee7eff1f7 languageName: node linkType: hard @@ -1110,42 +1110,42 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-signing@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-signing@npm:3.515.0" +"@aws-sdk/middleware-signing@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-signing@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 - "@smithy/protocol-http": ^3.1.1 + "@smithy/protocol-http": ^3.2.0 "@smithy/signature-v4": ^2.1.1 - "@smithy/types": ^2.9.1 - "@smithy/util-middleware": ^2.1.1 + "@smithy/types": ^2.10.0 + "@smithy/util-middleware": ^2.1.2 tslib: ^2.5.0 - checksum: 7ee85c70a81b85e455c4411caad79c7b41a0a0fd9696feead394a31ca360b096c74f7b2b6b5d449fcfa622659305618e24a49d15c45cab3afa54503459a9b24a + checksum: 545225d39d0e6133f14ca6ac721b78293b7ae2b522d9229f3c4afa00efe5ccd38c2b11a508bbec050f2b91a68f51fe6260db57853e8c28f7f80d09b93ef6120f languageName: node linkType: hard -"@aws-sdk/middleware-ssec@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-ssec@npm:3.515.0" +"@aws-sdk/middleware-ssec@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-ssec@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 3a91ebaf128ff63665f9aa69b9b2d475ebb8e519da2e94a142bcea7f9b173be52a0476b814e90e88e0936db3371e3c1c090b4e44018f006e1cfc37d2631a36d8 + checksum: 86b31dcc825d194898dbcc72e9a7590e9693bfbe8d3e0707f7b82814f735dfb73c06f77d68785b061faf173b8a1da6f47f98564602437532c75f8da77f820aca languageName: node linkType: hard -"@aws-sdk/middleware-user-agent@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/middleware-user-agent@npm:3.515.0" +"@aws-sdk/middleware-user-agent@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@aws-sdk/util-endpoints": 3.515.0 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@aws-sdk/util-endpoints": 3.521.0 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: fd601cb0367d42e38b71494c773d82bde8970f9aafbdbf18d7cadc25732ecc2b787f0cfef2110755d0cef73d6aa3ce2ca77dc5353854bedaf392a69019b39ac2 + checksum: 5d1461de4d6d6c7cfd6f7bca9753f3c8340b3bb27638940000d14811ff9a3e63ffecc9c9e4b8784e53a834815d58f902ae9e4a07b60b62d56629359de80fbe6c languageName: node linkType: hard @@ -1193,31 +1193,31 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/region-config-resolver@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/region-config-resolver@npm:3.515.0" +"@aws-sdk/region-config-resolver@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/types": ^2.10.0 "@smithy/util-config-provider": ^2.2.1 - "@smithy/util-middleware": ^2.1.1 + "@smithy/util-middleware": ^2.1.2 tslib: ^2.5.0 - checksum: 0ed7fbd6390baebdf511b30877236fa8be8716e0162e2c9e0138c9b41ebda7d99a6f3d6cf66cb4af24761631c2c29102ecfe7a5f08894e1de3c98ca6a135fa74 + checksum: ce0ec289d6ca59747c1e96dd3b45f11fe690fc2b0407beacadfd07e04258474c2be51a265851ef7bca0feb5f0e7ba6520ee2c5de2b112dff1fbd5c37901e2e72 languageName: node linkType: hard -"@aws-sdk/signature-v4-multi-region@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/signature-v4-multi-region@npm:3.515.0" +"@aws-sdk/signature-v4-multi-region@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.521.0" dependencies: - "@aws-sdk/middleware-sdk-s3": 3.515.0 - "@aws-sdk/types": 3.515.0 - "@smithy/protocol-http": ^3.1.1 + "@aws-sdk/middleware-sdk-s3": 3.521.0 + "@aws-sdk/types": 3.521.0 + "@smithy/protocol-http": ^3.2.0 "@smithy/signature-v4": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 1f780409af431b3ac91beee294cf4af74fff9f3dffc21ddd1c8a1782a01916da162ceec27c133a9e86452279bdf0005f9118809820d557a248613cd44f0f7ec3 + checksum: 312334438f12927e842d0a5c1d3fd837bedb54bc96caa7a7c0f1168a87532ee1b2e90090a2e1369c28cc0dc4b24c79ae7bf9aca66cbb811b61c8fdda775c4581 languageName: node linkType: hard @@ -1237,17 +1237,17 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/token-providers@npm:3.515.0" +"@aws-sdk/token-providers@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/token-providers@npm:3.521.0" dependencies: - "@aws-sdk/client-sso-oidc": 3.515.0 - "@aws-sdk/types": 3.515.0 + "@aws-sdk/client-sso-oidc": 3.521.0 + "@aws-sdk/types": 3.521.0 "@smithy/property-provider": ^2.1.1 "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: ab51c440da9772d0ee58948241b975705c171395cf1bad81a4ffd8f11f34106af54dcba930e360fb19489beedc1106ac14432bd27abdfe4b7536dc2113841027 + checksum: e34671eaab24dac569d0a98a87b21e2a6ff0960cdc931276f446a31310bff343af33dcf742738a02c9eb8e01226981ab64abd8e59e7758ed695f0045bb524af6 languageName: node linkType: hard @@ -1261,13 +1261,13 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:3.515.0, @aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.347.0": - version: 3.515.0 - resolution: "@aws-sdk/types@npm:3.515.0" +"@aws-sdk/types@npm:3.521.0, @aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.347.0": + version: 3.521.0 + resolution: "@aws-sdk/types@npm:3.521.0" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 0874f1814b58eae6e7115c3d08c2bc56e558e73d1ff8c5f833a73b4a0f76a42743c83c36a4b2759177e41b1feff065e85450f7bc235a087b94e67db12f87d298 + checksum: 28d9ab39ad19e74ca721100131152bec975cea3c78e5013e70e9684b051c5115623430a923f0e92426b298033be94ebd554925ec4a5fb64273c48df90ea6c6eb languageName: node linkType: hard @@ -1301,15 +1301,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-endpoints@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/util-endpoints@npm:3.515.0" +"@aws-sdk/util-endpoints@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/util-endpoints@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/types": ^2.9.1 - "@smithy/util-endpoints": ^1.1.1 + "@aws-sdk/types": 3.521.0 + "@smithy/types": ^2.10.0 + "@smithy/util-endpoints": ^1.1.2 tslib: ^2.5.0 - checksum: 1ab8fcd3054dc0366f10813a01130d05f4ba33f1488c1a168f44881cb24f3fbc2393111b7b0fd4dc06c852e4c9a5bbe8a82717b72229b0977cdba8a631ddeee1 + checksum: a8f01159d4114a7893200a3a782ccec091da7a46b4de5bb1c4db253ad836de123fbb314c376798044119b49f21b9d3bfd61eecb4328da9a73279719079baba48 languageName: node linkType: hard @@ -1361,32 +1361,32 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-user-agent-browser@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/util-user-agent-browser@npm:3.515.0" +"@aws-sdk/util-user-agent-browser@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/types": ^2.10.0 bowser: ^2.11.0 tslib: ^2.5.0 - checksum: 40f518006cb7e76d06d83dcf05222b0b0ff47c10b63149cd5db2c0c1db79c8eff34bd582e89c748897bc11697b7b357bdca77d569f57ad0b2081c088752d601f + checksum: 1938f4e00873a3d0ba55988d562fe987352abf6c57fef7c27dc28f561a7752af73d34883eb89eba1173b42b8d31ed0d0a4fc2592b46c28e775545bdb27b0bc80 languageName: node linkType: hard -"@aws-sdk/util-user-agent-node@npm:3.515.0": - version: 3.515.0 - resolution: "@aws-sdk/util-user-agent-node@npm:3.515.0" +"@aws-sdk/util-user-agent-node@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.521.0" dependencies: - "@aws-sdk/types": 3.515.0 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/types": ^2.9.1 + "@aws-sdk/types": 3.521.0 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 peerDependencies: aws-crt: ">=1.0.0" peerDependenciesMeta: aws-crt: optional: true - checksum: 4e91d9cd5bbe4aa8321417ea1bd9caf3229416ee624b7f67b5206b284a539116692412ca41d60dcb5759b841fed7d9fb570915566d1f7e620578657f89548a23 + checksum: d78a47e32fef990da97635af88484649636bf452320ce80ed6376302d18014f7b191f5da3c096e44aa4f9054639c31c3b2e62d06e7708f747ef7847bad019aa7 languageName: node linkType: hard @@ -1409,13 +1409,13 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/xml-builder@npm:3.496.0": - version: 3.496.0 - resolution: "@aws-sdk/xml-builder@npm:3.496.0" +"@aws-sdk/xml-builder@npm:3.521.0": + version: 3.521.0 + resolution: "@aws-sdk/xml-builder@npm:3.521.0" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 42d9d60c1c7f8a22f6a64ac36ba9d5ccff200ce963beebb142ad4708d2486fe29b61ecb37bbccd6f0019e25107aa2327e6d8550d30214b4895e7219ccb8661c8 + checksum: 33a86fdcf93029706725f829b9cea1846c8c51d0170410e19c75564256cb5825fb4c9e65ecbb7930c5ee14855bb63425b596fc2660978c5724197fee1b09c8f5 languageName: node linkType: hard @@ -15966,158 +15966,158 @@ __metadata: languageName: node linkType: hard -"@smithy/config-resolver@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/config-resolver@npm:2.1.1" +"@smithy/config-resolver@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/config-resolver@npm:2.1.2" dependencies: - "@smithy/node-config-provider": ^2.2.1 - "@smithy/types": ^2.9.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/types": ^2.10.0 "@smithy/util-config-provider": ^2.2.1 - "@smithy/util-middleware": ^2.1.1 + "@smithy/util-middleware": ^2.1.2 tslib: ^2.5.0 - checksum: 18c8af60cbc528887a82dc0eabaf0b398d868511dc6b10fa01f41c77ea9c2679ab2137feaee51aa9060dbc5c46fc33325a659f4bd54549c203f64e15dbacbc0a + checksum: 20ac9423e416bbb486d1bca247d7a37a2cbffe30c2e292b15c25e411c6cc5af438362ba2aa8e2218e93ef10c7d7fa04873646c4dd82bbcb4df6203efd1f1d3c9 languageName: node linkType: hard -"@smithy/core@npm:^1.3.2": - version: 1.3.2 - resolution: "@smithy/core@npm:1.3.2" +"@smithy/core@npm:^1.3.3": + version: 1.3.3 + resolution: "@smithy/core@npm:1.3.3" dependencies: - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-retry": ^2.1.1 - "@smithy/middleware-serde": ^2.1.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/util-middleware": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-retry": ^2.1.2 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/protocol-http": ^3.2.0 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/util-middleware": ^2.1.2 tslib: ^2.5.0 - checksum: 5c716b170aa8fb6485b7c98d2d59c44a7333566345727472fb9fabbe86473b33f090fa7a3e08de6ca10829a048c5f20bd238da7da471214789171c7e0a4460a9 + checksum: bb8a79f51517049064f1b5fb2233b9410d8c6dfeafa2ecda31b9d5326f05ffa4773c38ec72d90b380366e8a9b4c21b93231aaadce262c0d87989904950358cd3 languageName: node linkType: hard -"@smithy/credential-provider-imds@npm:^2.2.1": - version: 2.2.1 - resolution: "@smithy/credential-provider-imds@npm:2.2.1" +"@smithy/credential-provider-imds@npm:^2.2.1, @smithy/credential-provider-imds@npm:^2.2.2": + version: 2.2.2 + resolution: "@smithy/credential-provider-imds@npm:2.2.2" dependencies: - "@smithy/node-config-provider": ^2.2.1 - "@smithy/property-provider": ^2.1.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/property-provider": ^2.1.2 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 tslib: ^2.5.0 - checksum: a4e693719384440718728772ea2126be133bbc83fa7bfcefd236942ccb28d1390f1b32fe3262bf330ba4c8e600d01ac73a57110eb42462ec1eb6bbd51e2676a6 + checksum: 85cc9a6e2c52a8f47c0db3dd11c31ff550e5ddd0f6d1917169e22fbf242bb5a5c05bc426da5d04bce79cf0633d0c645e83ca02d42e243f3b40c2ace07aeb1b56 languageName: node linkType: hard -"@smithy/eventstream-codec@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/eventstream-codec@npm:2.1.1" +"@smithy/eventstream-codec@npm:^2.1.1, @smithy/eventstream-codec@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/eventstream-codec@npm:2.1.2" dependencies: "@aws-crypto/crc32": 3.0.0 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 "@smithy/util-hex-encoding": ^2.1.1 tslib: ^2.5.0 - checksum: 7e59028a69e669d1ca1a0fef788f9892a427fad32f33ded731cbfa3bde0163acbc1e7d207e0ce3eae2d3b53f48dce7a99ded092122cdf78e4f392cffd762bfe3 + checksum: ea455826916906a480c7abc517ceb043b578a95994842f802f71e409c45a5575a4f337c376c0c371055371bebafb81119a044cdb4c7de057304e6b1c0527d1d9 languageName: node linkType: hard -"@smithy/eventstream-serde-browser@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/eventstream-serde-browser@npm:2.1.1" +"@smithy/eventstream-serde-browser@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/eventstream-serde-browser@npm:2.1.2" dependencies: - "@smithy/eventstream-serde-universal": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/eventstream-serde-universal": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c909b620de25e9779653742012c665df8c76bf5193bb79054ef302bc3c08b0fa5620884a5965a3a6ebbb4f059da1b05221662a7a652aa979f4830f26c534be60 + checksum: 855118cd6ffc99a05d4950a01af23f727599e7fb127323d508b935617005e3d0e45cf1025fe7ba5079d9def82c73dc8d3e703c36f4b71288fd945c1433b41777 languageName: node linkType: hard -"@smithy/eventstream-serde-config-resolver@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/eventstream-serde-config-resolver@npm:2.1.1" +"@smithy/eventstream-serde-config-resolver@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/eventstream-serde-config-resolver@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 14d4d1c638be460290eb05dec3a700d742f8ce77814c1c235fbd7cf248941a387595f1cd684b9acfc3e081a8d9e6dc2810f10c894b3e08f16f0c3adb130cb736 + checksum: 397d91a492948bced849c5a90422ff6a2d49d4d300ec9fff09eea7256931402966d0285e8fd33a2df3745c10ff5dc88bbfafbf0ce53f0858371d02e7057fd033 languageName: node linkType: hard -"@smithy/eventstream-serde-node@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/eventstream-serde-node@npm:2.1.1" +"@smithy/eventstream-serde-node@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/eventstream-serde-node@npm:2.1.2" dependencies: - "@smithy/eventstream-serde-universal": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/eventstream-serde-universal": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 4be3dd11854d66310273bae07faafd4ca872158be8d3ef7bdc1dec55a175e983975750ebdaf762e74daf80495e379bd2791971a50899076865759a75b2634d73 + checksum: 56a65908d8ac07fd72dfa06a4709972c997a10696c0850de398804590fcc33144afbe1aa70b80ce7d98f6003dffa2b0f5d29228586fb2cb0f076b3d4c03ed20a languageName: node linkType: hard -"@smithy/eventstream-serde-universal@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/eventstream-serde-universal@npm:2.1.1" +"@smithy/eventstream-serde-universal@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/eventstream-serde-universal@npm:2.1.2" dependencies: - "@smithy/eventstream-codec": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/eventstream-codec": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 99c7cf5b869f8e6323e976335a3238b77d3b1c32005fc78093d448981883294e4d59bcbd419e88d6a53c76aab01c27bc9af63a5dfed9451d2302eaf6ccddbd64 + checksum: 693be21ef300c26f638fd7f9b9b36652aff319d7316893a661f32a5f1f29369bc216eb6f1d9c80d5e42473ccdd83e332163a8c9fce012c08df4305a52dea09c7 languageName: node linkType: hard -"@smithy/fetch-http-handler@npm:^2.4.1": - version: 2.4.1 - resolution: "@smithy/fetch-http-handler@npm:2.4.1" +"@smithy/fetch-http-handler@npm:^2.4.2": + version: 2.4.2 + resolution: "@smithy/fetch-http-handler@npm:2.4.2" dependencies: - "@smithy/protocol-http": ^3.1.1 - "@smithy/querystring-builder": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/protocol-http": ^3.2.0 + "@smithy/querystring-builder": ^2.1.2 + "@smithy/types": ^2.10.0 "@smithy/util-base64": ^2.1.1 tslib: ^2.5.0 - checksum: c23701d45bca6842b5206939ccd587e3482ace9f656ae3dca92ff0bad3fefb846cc33683dff41a19186f2a5662ca6cd66c8aefda4664b7dfd95f9a616055a1c1 + checksum: 7d87d5c6674623250972ac673a3317eeaeeba0647d8095c92e63ec9a002e96bb56dd7aa75172e474e226a4971f2abbd2506025cb1bf131d3b45698dbff27220d languageName: node linkType: hard -"@smithy/hash-blob-browser@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/hash-blob-browser@npm:2.1.1" +"@smithy/hash-blob-browser@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/hash-blob-browser@npm:2.1.2" dependencies: "@smithy/chunked-blob-reader": ^2.1.1 "@smithy/chunked-blob-reader-native": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: f4dc57c11ef32ddea0e7094d2c230aa274f1e410d84c789d8f5e2ed8a090da8675ca76da9605d297285324107ea8106af1c2aab2859bd62d6e9a8db415eb8e55 + checksum: e8d9fcedcfd03d03603753d6e4aafd7ad7c26e9ed629bf54a8dbb2ecd14b9e29cd267209453479eab698cb19329bfc80632823b40f49cb6d8b949aa4cb6db7c4 languageName: node linkType: hard -"@smithy/hash-node@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/hash-node@npm:2.1.1" +"@smithy/hash-node@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/hash-node@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 "@smithy/util-buffer-from": ^2.1.1 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 5d5aae69b94dcb8abaf9f6a5b53ee320c9e126445c4540fcf2169e8ea7ebd953acff7fd77ba514614f6ebbb0baf412e878eebcc3427a5b9b6f8ee39abbc59230 + checksum: 2f4fe6120a177afbc540c0ba904a3285a0b81de576a57bb28dbee94186635ab585034e7f48eeff0950d3a6442f5fd932cd66c366199c42f4314a540d02261eba languageName: node linkType: hard -"@smithy/hash-stream-node@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/hash-stream-node@npm:2.1.1" +"@smithy/hash-stream-node@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/hash-stream-node@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: da3c4ba14c648ee0d2fe7d3298d601150ee0ce5ac0c7d9f54a88148b5f67b03513b41560f76f5f109f11196547b4dc4f26e314774794596d7e3ee1103a9906a8 + checksum: 5a1a4d4fff29a4ba048900c606c98baeac0daabbaeba77c0c5b603d5896eff7f1eba2b012d41344638ce3d7fe10cb7d8d88551d34f1c637e807adb61efe3f43e languageName: node linkType: hard -"@smithy/invalid-dependency@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/invalid-dependency@npm:2.1.1" +"@smithy/invalid-dependency@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/invalid-dependency@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: f95ecd9acd337a408b6608a3f451b24a61e26149878f61fc7855c724888f0d28abf0b798d16990dadb7eafc8027098f934c0cd44e75d01d31617bd1fbfd93935 + checksum: 5f5ce3d408c67c3e8b80c7dbc3504662c437ded360c360f072ed731c0bae6b57386ba37dc059883dfe7d23711680a5119a68e05b3ea6ccc7fc124cf0e6f90024 languageName: node linkType: hard @@ -16130,93 +16130,93 @@ __metadata: languageName: node linkType: hard -"@smithy/md5-js@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/md5-js@npm:2.1.1" +"@smithy/md5-js@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/md5-js@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: d15bc426a46d80d450b555a5ccd3d5a6bf37190f4b9ccb705852cd53ce61e4fe6fb08a569b87303ee787da57023f2b75f0e7893644af16c89e9aaf513f8afff3 + checksum: c6e4bdb779e9af5146e502d1e0d757e09a991b70e39fdb089efbcb8e511761942330034b215b66814c3627b0b0bb7bd028d0f26e8558b0ec5b2ea07c46b5f4e5 languageName: node linkType: hard -"@smithy/middleware-content-length@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/middleware-content-length@npm:2.1.1" +"@smithy/middleware-content-length@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/middleware-content-length@npm:2.1.2" dependencies: - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: cb0ea801f72a1a01f5956b3526df930fc19762b07d43a3871ff29815f621603410753d37710d72675d9761b93da32a38cfd5195582de8b6a47e299b1f073be25 + checksum: ddea93b236e5f916da8e1574317967d5aa449e78b0c7153c60c821d117f1648b00effad5301095919de9225810cd8f90f8ee76e7b95c346fc616d8598ad54447 languageName: node linkType: hard -"@smithy/middleware-endpoint@npm:^2.4.1": - version: 2.4.1 - resolution: "@smithy/middleware-endpoint@npm:2.4.1" +"@smithy/middleware-endpoint@npm:^2.4.2": + version: 2.4.2 + resolution: "@smithy/middleware-endpoint@npm:2.4.2" dependencies: - "@smithy/middleware-serde": ^2.1.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/url-parser": ^2.1.1 - "@smithy/util-middleware": ^2.1.1 + "@smithy/middleware-serde": ^2.1.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/shared-ini-file-loader": ^2.3.2 + "@smithy/types": ^2.10.0 + "@smithy/url-parser": ^2.1.2 + "@smithy/util-middleware": ^2.1.2 tslib: ^2.5.0 - checksum: 685f74c76cba205bdb20ad7bda449b73e498ae2e9074a026d48b38c7b4456d8a0cfb4fdb48625b65f93f3a75e92eaf7951db28f8e9f44e50ce18fd59a7b325af + checksum: 3e989123fc608c9a32abf30c4033718b3da665a63bd84e8e2869d4aecb0545d461506e75f82b91bbc35a07915ddadf1432643e4c937c11447847a9a45b3de9fa languageName: node linkType: hard -"@smithy/middleware-retry@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/middleware-retry@npm:2.1.1" +"@smithy/middleware-retry@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/middleware-retry@npm:2.1.2" dependencies: - "@smithy/node-config-provider": ^2.2.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/service-error-classification": ^2.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 - "@smithy/util-middleware": ^2.1.1 - "@smithy/util-retry": ^2.1.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/protocol-http": ^3.2.0 + "@smithy/service-error-classification": ^2.1.2 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 + "@smithy/util-middleware": ^2.1.2 + "@smithy/util-retry": ^2.1.2 tslib: ^2.5.0 uuid: ^8.3.2 - checksum: a4bc59d2ff8f65367aeb93391a2aafc7caf8031d8b2dfb32ee35748cdc46e06d5182c37bee90d7a107e890959bd40e6a7f4041bc1b0b36a99d14919b1cc78812 + checksum: ec04fd0c362070529ecd52f2dadd6fd4d638a4e35c62a67309791f1a288550ee8fcd6406ec27fea78d3c89b08a9c6d29ce63bf7cb5a1b0269f86a759b233dac4 languageName: node linkType: hard -"@smithy/middleware-serde@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/middleware-serde@npm:2.1.1" +"@smithy/middleware-serde@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/middleware-serde@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: ed77b80ac6b68640ee4bf8310bc4d9f5aa13de2741333f6f03a4983e897fa66e0de057d178e78d9ba095d5686d3e4531437c9dd2583366efe948bd75b2aa8581 + checksum: 4f5bd5ee173cf20cd1c12838b0802f96df5a14c3bdab2d50a6009965c128596863c095633958f0c74d82bca3cc9343f8a4c659c033b9a75e614e3a85e34e0665 languageName: node linkType: hard -"@smithy/middleware-stack@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/middleware-stack@npm:2.1.1" +"@smithy/middleware-stack@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/middleware-stack@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 0d7c1051c96fcf19f7d5e96bc59484ce13df4e570c1da3eda74d23a7911b41eb61d6c378aad0aa21f7e9c72934148bdf39f9767c57abd4845aa4417a84e3f6e4 + checksum: f93dda40f08051a6391e213cc3b90f30ebb31399b000bba882cbf37f942786030822e10ccfe576005361ef6341b571460ce791fbdc7dad971c99a33a92e400dd languageName: node linkType: hard -"@smithy/node-config-provider@npm:^2.2.1": - version: 2.2.1 - resolution: "@smithy/node-config-provider@npm:2.2.1" +"@smithy/node-config-provider@npm:^2.2.2": + version: 2.2.2 + resolution: "@smithy/node-config-provider@npm:2.2.2" dependencies: - "@smithy/property-provider": ^2.1.1 - "@smithy/shared-ini-file-loader": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/property-provider": ^2.1.2 + "@smithy/shared-ini-file-loader": ^2.3.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 62ed3124d888a10cac633a250fbe12d6c5b8aa75ea691889abebce227cbaf155f3db00fa6beb453fbd6147e667e70819d043da1750980669451281a28eafd285 + checksum: 666d80d893985e6af5aa88a2f3ed07bd68c6873805974331fd148ec5a5d331e5116e2dca8656ef57c60e22cec03aee0717061a03ce703a691765bf94d809f2eb languageName: node linkType: hard -"@smithy/node-http-handler@npm:^2.1.7, @smithy/node-http-handler@npm:^2.3.1": +"@smithy/node-http-handler@npm:^2.1.7, @smithy/node-http-handler@npm:^2.4.0": version: 2.4.0 resolution: "@smithy/node-http-handler@npm:2.4.0" dependencies: @@ -16229,17 +16229,17 @@ __metadata: languageName: node linkType: hard -"@smithy/property-provider@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/property-provider@npm:2.1.1" +"@smithy/property-provider@npm:^2.1.1, @smithy/property-provider@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/property-provider@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: e87d70c4efe07e830cfb2094b046af89175b87b13259fba37641aa7bfc2ab0c7bf2397797ac48b92e1feb11bf6129b82b350519172093efd7ac4d3a4a98bbe2f + checksum: df2b72912ede1843a75220a458e3ff8ec70e5544c990c0915e615507380cea4c28bb39b425b7ee600f5c3c90d53b5c82ceaf14d641348f465c14640c556ac9bd languageName: node linkType: hard -"@smithy/protocol-http@npm:^3.1.1, @smithy/protocol-http@npm:^3.2.0": +"@smithy/protocol-http@npm:^3.2.0": version: 3.2.0 resolution: "@smithy/protocol-http@npm:3.2.0" dependencies: @@ -16249,7 +16249,7 @@ __metadata: languageName: node linkType: hard -"@smithy/querystring-builder@npm:^2.1.1, @smithy/querystring-builder@npm:^2.1.2": +"@smithy/querystring-builder@npm:^2.1.2": version: 2.1.2 resolution: "@smithy/querystring-builder@npm:2.1.2" dependencies: @@ -16260,32 +16260,32 @@ __metadata: languageName: node linkType: hard -"@smithy/querystring-parser@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/querystring-parser@npm:2.1.1" +"@smithy/querystring-parser@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/querystring-parser@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: bfac40793b0e42f4e25137db4e7d866debfa32557359cc41e02a23174a6fd8e0132f098cef5669a3ddf5211e477c9c97d4aa9039b35c7b4a29f2207236da236e + checksum: 02a1e3a31b37b59adb162d3a2cb084852c2ea01dec948b0669939e77241b05fd7f5b00734418b925248f0b6c164bc483e897438cb2b1a750829f6b4aab0fa8d1 languageName: node linkType: hard -"@smithy/service-error-classification@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/service-error-classification@npm:2.1.1" +"@smithy/service-error-classification@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/service-error-classification@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 - checksum: 59a5e3cb0fb42d70fc2d85814124abbff60e28cc9aa45d87fde3370e25943abaf4b6baf62cc40e496c3687e9fa9161156a055ad29a4f7ce8dd7d937bbf49f9a7 + "@smithy/types": ^2.10.0 + checksum: 8a26f553fd2a823179b701f87c0952e58580c9297166e608a036c7b313d5cc1399bc8b4b056b038003aedc0145643c6c6323e7f683b1a2d1140d9a7982e6bf7c languageName: node linkType: hard -"@smithy/shared-ini-file-loader@npm:^2.3.1": - version: 2.3.1 - resolution: "@smithy/shared-ini-file-loader@npm:2.3.1" +"@smithy/shared-ini-file-loader@npm:^2.3.1, @smithy/shared-ini-file-loader@npm:^2.3.2": + version: 2.3.2 + resolution: "@smithy/shared-ini-file-loader@npm:2.3.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 89b0dfb65faab917fcb4a6a8f34a85d668a759ccbfd6c4dc3d6311e59a8f1b78baab1d97402c333d2207da810cb00de9d5b4379f114bde82135f9aa0d0069cab + checksum: 6db5ac83a76a15f3bf49496747ef4d20343e87a4de35b87892c5ac5c69a7046ffe7276230a4e9cbc075183d8b0584f1530a878f58324279cf936103c578aa70a languageName: node linkType: hard @@ -16305,17 +16305,17 @@ __metadata: languageName: node linkType: hard -"@smithy/smithy-client@npm:^2.3.1": - version: 2.3.1 - resolution: "@smithy/smithy-client@npm:2.3.1" +"@smithy/smithy-client@npm:^2.4.0": + version: 2.4.0 + resolution: "@smithy/smithy-client@npm:2.4.0" dependencies: - "@smithy/middleware-endpoint": ^2.4.1 - "@smithy/middleware-stack": ^2.1.1 - "@smithy/protocol-http": ^3.1.1 - "@smithy/types": ^2.9.1 - "@smithy/util-stream": ^2.1.1 + "@smithy/middleware-endpoint": ^2.4.2 + "@smithy/middleware-stack": ^2.1.2 + "@smithy/protocol-http": ^3.2.0 + "@smithy/types": ^2.10.0 + "@smithy/util-stream": ^2.1.2 tslib: ^2.5.0 - checksum: 9b13c361528b3120b1a1db17cd60521d04c72f664c2709be20934cea12756117441d2a33d0464ff3099be11ccb12946c62ece1126b9532eb8f6243a35d6fd171 + checksum: af17a6334e0b19323145482d829b664fcc3102cfbea9682753b9bc328840b9bc1968cd3cf64677cdc23c824194061f8fdf3a905d6f71431547a4ec413d557f92 languageName: node linkType: hard @@ -16337,14 +16337,14 @@ __metadata: languageName: node linkType: hard -"@smithy/url-parser@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/url-parser@npm:2.1.1" +"@smithy/url-parser@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/url-parser@npm:2.1.2" dependencies: - "@smithy/querystring-parser": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/querystring-parser": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 5c939f3ff9c53a0b7a0c5a1ac7641f229598d2bf9499e1abf4d33c1c1cd13bd5f7fcfffd00c366ca9f8092d28979a4a958b80f9bbc91e817e4d1940451e93489 + checksum: 83aca5a6474e85d835958caed5b486d5e6438682e6c17a5817ebda48ec9936c68b7b39c35a050f7eb4bd3902e83d8008b26d9fc5df7a168502d6ed93848005c6 languageName: node linkType: hard @@ -16395,42 +16395,42 @@ __metadata: languageName: node linkType: hard -"@smithy/util-defaults-mode-browser@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/util-defaults-mode-browser@npm:2.1.1" +"@smithy/util-defaults-mode-browser@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/util-defaults-mode-browser@npm:2.1.2" dependencies: - "@smithy/property-provider": ^2.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/property-provider": ^2.1.2 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 bowser: ^2.11.0 tslib: ^2.5.0 - checksum: 5d3b11be1768410e24ad9829dc70bed9b50419f85a8ca934c6296e21e278d87f665cfdb603241ef749f80d154a2c4be26cd29338daecc625d31b30af8bd9c139 + checksum: bc0621f1d5ca46830a4b525def4b829e23336707af28e305758de7f97170024f387a9f3d73e2e29b033ea4963466373df644a304a87291c980159b31cf5ffbcf languageName: node linkType: hard -"@smithy/util-defaults-mode-node@npm:^2.2.0": - version: 2.2.0 - resolution: "@smithy/util-defaults-mode-node@npm:2.2.0" +"@smithy/util-defaults-mode-node@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/util-defaults-mode-node@npm:2.2.1" dependencies: - "@smithy/config-resolver": ^2.1.1 - "@smithy/credential-provider-imds": ^2.2.1 - "@smithy/node-config-provider": ^2.2.1 - "@smithy/property-provider": ^2.1.1 - "@smithy/smithy-client": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/config-resolver": ^2.1.2 + "@smithy/credential-provider-imds": ^2.2.2 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/property-provider": ^2.1.2 + "@smithy/smithy-client": ^2.4.0 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: c4a69b73bc46c3bb5ff4149b80bdfa79f4c25b82253d9c7168c9920066e12830e1bea324dce09414b09791fd0379bdc05c39117155d5b37a229d226962a95d5f + checksum: 672c13329e37d61170fb6c997b416fe9216efb6308b2e15560f5a96089bd839018d4705fb541aed0717dd1e1574e3dfc835f37a9608679713ee679d1d7c17642 languageName: node linkType: hard -"@smithy/util-endpoints@npm:^1.1.1": - version: 1.1.1 - resolution: "@smithy/util-endpoints@npm:1.1.1" +"@smithy/util-endpoints@npm:^1.1.2": + version: 1.1.2 + resolution: "@smithy/util-endpoints@npm:1.1.2" dependencies: - "@smithy/node-config-provider": ^2.2.1 - "@smithy/types": ^2.9.1 + "@smithy/node-config-provider": ^2.2.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 40619bf739c1fc959486946cb49319f34c9c4c5c19f46cdefc7ff8e7331b84f6ad7a4aeb8a0268f6d77d266ff5ec9df8d2244094dd79ae469983e9c07e43766a + checksum: 261f383e64116f767cc8e304a647c47261fee8425c43e517511e1bf8ec5e72652b8a12c65259ca4557f3fba477662606253d4cf89ccab5af184a05c59d2d735f languageName: node linkType: hard @@ -16443,40 +16443,40 @@ __metadata: languageName: node linkType: hard -"@smithy/util-middleware@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/util-middleware@npm:2.1.1" +"@smithy/util-middleware@npm:^2.1.1, @smithy/util-middleware@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/util-middleware@npm:2.1.2" dependencies: - "@smithy/types": ^2.9.1 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 404bb944202df70ba0ff8bb6ea105ead0a6b365d5ef7bfafbfc919df228823563818f0ee36f0f1e20462200da2fb8c8961e20b237e4e1bd9f77c38dd701f39ab + checksum: 8a05c05ba1358515aa6881189cd4a6a57701e8cd9e036c8d7219662fd12bce1695a4970c247314e0020f13bb506a558545ab5c519373647be89d79af08d5bcdf languageName: node linkType: hard -"@smithy/util-retry@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/util-retry@npm:2.1.1" +"@smithy/util-retry@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/util-retry@npm:2.1.2" dependencies: - "@smithy/service-error-classification": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/service-error-classification": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 1747c75f55a208f16104483cd76ec45200dedaa924868e84d4882b88f8b4a8d3a4422834359fd9bfba242e0e96a474349ac0a6f5d804fb15b15e8b639b6d2ad0 + checksum: 3be4b984b0f1daa54948fe158568a41003f725464ef32f0ccf32e02b566545364bd6f89a380a218397742edd1ad1d214906fab27debce531c137bfceca0c9c6d languageName: node linkType: hard -"@smithy/util-stream@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/util-stream@npm:2.1.1" +"@smithy/util-stream@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/util-stream@npm:2.1.2" dependencies: - "@smithy/fetch-http-handler": ^2.4.1 - "@smithy/node-http-handler": ^2.3.1 - "@smithy/types": ^2.9.1 + "@smithy/fetch-http-handler": ^2.4.2 + "@smithy/node-http-handler": ^2.4.0 + "@smithy/types": ^2.10.0 "@smithy/util-base64": ^2.1.1 "@smithy/util-buffer-from": ^2.1.1 "@smithy/util-hex-encoding": ^2.1.1 "@smithy/util-utf8": ^2.1.1 tslib: ^2.5.0 - checksum: 3a060226b8a506e722d0d8c1c4b7a2989241f7946c8acc892a8a70d92d9952cc8619b14bf686c9c822115d99159c6c16534bad2d72ecc2809a56f865224e82a6 + checksum: 8b95535323fcf3ce86cbb070791405afd4de1513a38fef209bfc4d5b2ed91ae16ae40393dd8a5f8b127194ec023cc264808f87beef0678219f56b2bcb580eb65 languageName: node linkType: hard @@ -16499,14 +16499,14 @@ __metadata: languageName: node linkType: hard -"@smithy/util-waiter@npm:^2.1.1": - version: 2.1.1 - resolution: "@smithy/util-waiter@npm:2.1.1" +"@smithy/util-waiter@npm:^2.1.2": + version: 2.1.2 + resolution: "@smithy/util-waiter@npm:2.1.2" dependencies: - "@smithy/abort-controller": ^2.1.1 - "@smithy/types": ^2.9.1 + "@smithy/abort-controller": ^2.1.2 + "@smithy/types": ^2.10.0 tslib: ^2.5.0 - checksum: 52d9c82bb9684b6b11eeb2814fa1454514cb90aeeb87bfdf7c458613c13d18189712585486859c975824d08f2d1e3c817dd7e51c400531aaa479af8a06ea0bff + checksum: 089e777701ff2d6d8910f843c73de0d504221064401a02e56f104b5a50c66abfe8c8fa41c9e9ec62c8b6088f234cd995afccd9e2220fa7b7c8fcbf67fe343062 languageName: node linkType: hard From 37e734e865adb4bf64cb1ae683d58d6c914c8715 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 01:51:34 +0000 Subject: [PATCH 081/176] fix(deps): update dependency elastic-builder to v2.25.0 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 210e70b900..d57284da6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25915,8 +25915,8 @@ __metadata: linkType: hard "elastic-builder@npm:^2.16.0": - version: 2.24.0 - resolution: "elastic-builder@npm:2.24.0" + version: 2.25.0 + resolution: "elastic-builder@npm:2.25.0" dependencies: lodash.has: ^4.5.2 lodash.hasin: ^4.5.2 @@ -25926,7 +25926,7 @@ __metadata: lodash.isobject: ^3.0.2 lodash.isstring: ^4.0.1 lodash.omit: ^4.5.0 - checksum: 4bbfa66a179b78dbd90a0a3ee19cf7fc2105deca195c65d3047cf2ef2e6fa4925049038ed1f28d46a3ce96daa47f2cf8d924ea2e2fc0c585569a5a7acd09f2ed + checksum: 576b1060174cd5b62f5f802f9b947a3481ed0477c9c1ccbfe572f6ef4c3287c7ba0f5101cf6d32a69e1097e36fb95bded4066ef1ba195176f3b6b8c546d66e6d languageName: node linkType: hard From 086d7af53445157588a858796f231fa33f007e52 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 01:52:43 +0000 Subject: [PATCH 082/176] fix(deps): update dependency yaml to v2.4.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 210e70b900..c70476532e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46087,7 +46087,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:2.3.4, yaml@npm:^2.0.0, yaml@npm:^2.0.0-10, yaml@npm:^2.1.1, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.2, yaml@npm:^2.3.3": +"yaml@npm:2.3.4": version: 2.3.4 resolution: "yaml@npm:2.3.4" checksum: e6d1dae1c6383bcc8ba11796eef3b8c02d5082911c6723efeeb5ba50fc8e881df18d645e64de68e421b577296000bea9c75d6d9097c2f6699da3ae0406c030d8 @@ -46101,6 +46101,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.0.0, yaml@npm:^2.0.0-10, yaml@npm:^2.1.1, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.2, yaml@npm:^2.3.3": + version: 2.4.0 + resolution: "yaml@npm:2.4.0" + bin: + yaml: bin.mjs + checksum: 3c25ebae34ee702af772ebbd1855a980b1487cd21d6220d952592edb4f7d89322aafd14753d99924ba7076eb4c5b3d809c64bb532402b01af280f7af674277f1 + languageName: node + linkType: hard + "yargs-parser@npm:^18.1.2, yargs-parser@npm:^18.1.3": version: 18.1.3 resolution: "yargs-parser@npm:18.1.3" From 0fb419ba03ac366458bbd9bc40d87ec208832ec5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 02:29:25 +0000 Subject: [PATCH 083/176] fix(deps): update dependency uuid to v9 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-9850908.md | 38 ++++++++ packages/backend-common/package.json | 2 +- packages/backend-tasks/package.json | 2 +- packages/backend-test-utils/package.json | 2 +- plugins/auth-backend/package.json | 2 +- plugins/auth-node/package.json | 2 +- .../catalog-backend-module-aws/package.json | 2 +- .../catalog-backend-module-azure/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- .../package.json | 4 +- .../package.json | 2 +- .../catalog-backend-module-ldap/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- plugins/catalog-backend/package.json | 4 +- plugins/code-coverage-backend/package.json | 2 +- .../example-todo-list-backend/package.json | 4 +- plugins/linguist-backend/package.json | 2 +- plugins/notifications-backend/package.json | 2 +- plugins/notifications-node/package.json | 2 +- plugins/permission-common/package.json | 2 +- plugins/playlist-backend/package.json | 2 +- plugins/proxy-backend/package.json | 4 +- plugins/scaffolder-backend/package.json | 2 +- .../package.json | 2 +- plugins/search-backend-module-pg/package.json | 2 +- plugins/search-backend-node/package.json | 2 +- plugins/shortcuts/package.json | 2 +- plugins/signals-backend/package.json | 2 +- plugins/signals-node/package.json | 2 +- plugins/signals/package.json | 2 +- plugins/tech-insights-backend/package.json | 2 +- yarn.lock | 86 +++++++++---------- 36 files changed, 119 insertions(+), 81 deletions(-) create mode 100644 .changeset/renovate-9850908.md diff --git a/.changeset/renovate-9850908.md b/.changeset/renovate-9850908.md new file mode 100644 index 0000000000..4911d08027 --- /dev/null +++ b/.changeset/renovate-9850908.md @@ -0,0 +1,38 @@ +--- +'@backstage/backend-common': patch +'@backstage/backend-tasks': patch +'@backstage/backend-test-utils': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth-node': patch +'@backstage/plugin-catalog-backend-module-aws': patch +'@backstage/plugin-catalog-backend-module-azure': patch +'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch +'@backstage/plugin-catalog-backend-module-bitbucket-server': patch +'@backstage/plugin-catalog-backend-module-gerrit': patch +'@backstage/plugin-catalog-backend-module-github': patch +'@backstage/plugin-catalog-backend-module-gitlab': patch +'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch +'@backstage/plugin-catalog-backend-module-ldap': patch +'@backstage/plugin-catalog-backend-module-msgraph': patch +'@backstage/plugin-catalog-backend-module-puppetdb': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-code-coverage-backend': patch +'@backstage/plugin-linguist-backend': patch +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications-node': patch +'@backstage/plugin-permission-common': patch +'@backstage/plugin-playlist-backend': patch +'@backstage/plugin-proxy-backend': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-search-backend-module-elasticsearch': patch +'@backstage/plugin-search-backend-module-pg': patch +'@backstage/plugin-search-backend-node': patch +'@backstage/plugin-shortcuts': patch +'@backstage/plugin-signals-backend': patch +'@backstage/plugin-signals-node': patch +'@backstage/plugin-signals': patch +'@backstage/plugin-tech-insights-backend': patch +--- + +Updated dependency `uuid` to `^9.0.0`. +Updated dependency `@types/uuid` to `^9.0.0`. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 2fe5945750..a0753c6776 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -101,7 +101,7 @@ "pg": "^8.11.3", "raw-body": "^2.4.1", "tar": "^6.1.12", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1", "winston-transport": "^4.5.0", "yauzl": "^2.10.0", diff --git a/packages/backend-tasks/package.json b/packages/backend-tasks/package.json index 6a8b9b4fc3..9f6743fe37 100644 --- a/packages/backend-tasks/package.json +++ b/packages/backend-tasks/package.json @@ -42,7 +42,7 @@ "knex": "^3.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "zod": "^3.22.4" }, diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 4153fba211..6545ddbe20 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -58,7 +58,7 @@ "pg": "^8.11.3", "testcontainers": "^8.1.2", "textextensions": "^5.16.0", - "uuid": "^8.0.0" + "uuid": "^9.0.0" }, "peerDependencies": { "@types/jest": "*" diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 967233e586..456cc18af2 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -87,7 +87,7 @@ "passport-microsoft": "^1.0.0", "passport-oauth2": "^1.6.1", "passport-onelogin-oauth": "^0.0.1", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index add7087ace..7830126365 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -57,6 +57,6 @@ "lodash": "^4.17.21", "msw": "^1.0.0", "supertest": "^6.1.3", - "uuid": "^8.0.0" + "uuid": "^9.0.0" } } diff --git a/plugins/catalog-backend-module-aws/package.json b/plugins/catalog-backend-module-aws/package.json index 346daa2d09..19e4571d96 100644 --- a/plugins/catalog-backend-module-aws/package.json +++ b/plugins/catalog-backend-module-aws/package.json @@ -67,7 +67,7 @@ "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "p-limit": "^3.0.2", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-azure/package.json b/plugins/catalog-backend-module-azure/package.json index 08b1dceeb5..2d92d7284f 100644 --- a/plugins/catalog-backend-module-azure/package.json +++ b/plugins/catalog-backend-module-azure/package.json @@ -57,7 +57,7 @@ "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-bitbucket-cloud/package.json b/plugins/catalog-backend-module-bitbucket-cloud/package.json index b5e75b517f..418e88a003 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/package.json +++ b/plugins/catalog-backend-module-bitbucket-cloud/package.json @@ -56,7 +56,7 @@ "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-bitbucket-server/package.json b/plugins/catalog-backend-module-bitbucket-server/package.json index 46548b3df5..795652603d 100644 --- a/plugins/catalog-backend-module-bitbucket-server/package.json +++ b/plugins/catalog-backend-module-bitbucket-server/package.json @@ -54,7 +54,7 @@ "@backstage/plugin-catalog-node": "workspace:^", "@types/node-fetch": "^2.5.12", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-gerrit/package.json b/plugins/catalog-backend-module-gerrit/package.json index 3337176653..b0c0ff81ea 100644 --- a/plugins/catalog-backend-module-gerrit/package.json +++ b/plugins/catalog-backend-module-gerrit/package.json @@ -54,7 +54,7 @@ "@backstage/plugin-catalog-node": "workspace:^", "fs-extra": "^11.2.0", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json index b6a8f3acfe..6f3b933aa8 100644 --- a/plugins/catalog-backend-module-github/package.json +++ b/plugins/catalog-backend-module-github/package.json @@ -66,7 +66,7 @@ "lodash": "^4.17.21", "minimatch": "^9.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json index 661ed43f0e..76a20bd97f 100644 --- a/plugins/catalog-backend-module-gitlab/package.json +++ b/plugins/catalog-backend-module-gitlab/package.json @@ -54,14 +54,14 @@ "@backstage/plugin-catalog-node": "workspace:^", "lodash": "^4.17.21", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/lodash": "^4.14.151", - "@types/uuid": "^8.0.0", + "@types/uuid": "^9.0.0", "luxon": "^3.0.0", "msw": "^1.0.0" }, diff --git a/plugins/catalog-backend-module-incremental-ingestion/package.json b/plugins/catalog-backend-module-incremental-ingestion/package.json index e4d7b7efcf..02963f6bc1 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/package.json +++ b/plugins/catalog-backend-module-incremental-ingestion/package.json @@ -65,7 +65,7 @@ "express-promise-router": "^4.1.0", "knex": "^3.0.0", "luxon": "^3.0.0", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index 9cae290571..7720e97aa6 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -42,7 +42,7 @@ "@types/ldapjs": "^2.2.0", "ldapjs": "^2.2.0", "lodash": "^4.17.21", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index 95070e6d78..b2c09bf201 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -63,7 +63,7 @@ "node-fetch": "^2.6.7", "p-limit": "^3.0.2", "qs": "^6.9.4", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-puppetdb/package.json b/plugins/catalog-backend-module-puppetdb/package.json index 4e127953bc..c971e58299 100644 --- a/plugins/catalog-backend-module-puppetdb/package.json +++ b/plugins/catalog-backend-module-puppetdb/package.json @@ -62,7 +62,7 @@ "lodash": "^4.17.21", "luxon": "^3.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index fe72f0938c..7253f2db31 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -80,7 +80,7 @@ "node-fetch": "^2.6.7", "p-limit": "^3.0.2", "prom-client": "^15.0.0", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yaml": "^2.0.0", "yn": "^4.0.0", @@ -95,7 +95,7 @@ "@types/glob": "^8.0.0", "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", - "@types/uuid": "^8.0.0", + "@types/uuid": "^9.0.0", "better-sqlite3": "^9.0.0", "luxon": "^3.0.0", "msw": "^1.0.0", diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index acdeab5a23..b44f608bbb 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -43,7 +43,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^3.0.0", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/example-todo-list-backend/package.json b/plugins/example-todo-list-backend/package.json index d938d9816f..c6792746e9 100644 --- a/plugins/example-todo-list-backend/package.json +++ b/plugins/example-todo-list-backend/package.json @@ -40,14 +40,14 @@ "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", "@types/supertest": "^2.0.8", - "@types/uuid": "^8.0.0", + "@types/uuid": "^9.0.0", "supertest": "^6.1.6" } } diff --git a/plugins/linguist-backend/package.json b/plugins/linguist-backend/package.json index 0983b72f47..8b1d0104c1 100644 --- a/plugins/linguist-backend/package.json +++ b/plugins/linguist-backend/package.json @@ -52,7 +52,7 @@ "linguist-js": "^2.5.3", "luxon": "^3.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 809cab9973..8c11253a01 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -45,7 +45,7 @@ "express-promise-router": "^4.1.0", "knex": "^3.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index 9d2094beaf..9150ce1816 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -43,6 +43,6 @@ "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-signals-node": "workspace:^", "knex": "^3.0.0", - "uuid": "^8.0.0" + "uuid": "^9.0.0" } } diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index 7dd86b3b0d..204e146e56 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -45,7 +45,7 @@ "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "cross-fetch": "^4.0.0", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/plugins/playlist-backend/package.json b/plugins/playlist-backend/package.json index af26e90102..e7889830e1 100644 --- a/plugins/playlist-backend/package.json +++ b/plugins/playlist-backend/package.json @@ -47,7 +47,7 @@ "express-promise-router": "^4.1.0", "knex": "^3.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.2.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0", "zod": "^3.22.4" diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 30ce530b9b..df9890b68c 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -58,7 +58,7 @@ "express-promise-router": "^4.1.0", "http-proxy-middleware": "^2.0.0", "morgan": "^1.10.0", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yaml": "^2.0.0", "yn": "^4.0.0", @@ -70,7 +70,7 @@ "@backstage/config-loader": "workspace:^", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", - "@types/uuid": "^8.0.0", + "@types/uuid": "^9.0.0", "@types/yup": "^0.29.13", "msw": "^1.0.0", "supertest": "^6.1.3" diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 4df6d6c89f..4fec5bdfaf 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -86,7 +86,7 @@ "p-limit": "^3.1.0", "p-queue": "^6.6.2", "prom-client": "^15.0.0", - "uuid": "^8.2.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "yaml": "^2.0.0", "zen-observable": "^0.10.0", diff --git a/plugins/search-backend-module-elasticsearch/package.json b/plugins/search-backend-module-elasticsearch/package.json index 88b6d9542a..f177121a2e 100644 --- a/plugins/search-backend-module-elasticsearch/package.json +++ b/plugins/search-backend-module-elasticsearch/package.json @@ -57,7 +57,7 @@ "aws4": "^1.12.0", "elastic-builder": "^2.16.0", "lodash": "^4.17.21", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/search-backend-module-pg/package.json b/plugins/search-backend-module-pg/package.json index 2ba5331a4d..9344e1c5ba 100644 --- a/plugins/search-backend-module-pg/package.json +++ b/plugins/search-backend-module-pg/package.json @@ -54,7 +54,7 @@ "@backstage/plugin-search-common": "workspace:^", "knex": "^3.0.0", "lodash": "^4.17.21", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index b87bd15f7d..7ef7b7d2c5 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -56,7 +56,7 @@ "lodash": "^4.17.21", "lunr": "^2.3.9", "ndjson": "^2.0.0", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index 5e3e6f9bb3..ef8bde1043 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -42,7 +42,7 @@ "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", "react-hook-form": "^7.12.2", "react-use": "^17.2.4", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "zen-observable": "^0.10.0" }, "devDependencies": { diff --git a/plugins/signals-backend/package.json b/plugins/signals-backend/package.json index f8424ed0c1..4fcca7b0e6 100644 --- a/plugins/signals-backend/package.json +++ b/plugins/signals-backend/package.json @@ -39,7 +39,7 @@ "express-promise-router": "^4.1.0", "http-proxy-middleware": "^2.0.0", "node-fetch": "^2.6.7", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "ws": "^8.14.2", "yn": "^4.0.0" diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json index c00696893d..03ef61739f 100644 --- a/plugins/signals-node/package.json +++ b/plugins/signals-node/package.json @@ -41,7 +41,7 @@ "@backstage/plugin-events-node": "workspace:^", "@backstage/types": "workspace:^", "express": "^4.17.1", - "uuid": "^8.0.0", + "uuid": "^9.0.0", "ws": "^8.14.2" } } diff --git a/plugins/signals/package.json b/plugins/signals/package.json index 22cc82ef54..5f2f4222c2 100644 --- a/plugins/signals/package.json +++ b/plugins/signals/package.json @@ -40,7 +40,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", "react-use": "^17.2.4", - "uuid": "^8.0.0" + "uuid": "^9.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/tech-insights-backend/package.json b/plugins/tech-insights-backend/package.json index d39f8a3d6d..b1d84acae1 100644 --- a/plugins/tech-insights-backend/package.json +++ b/plugins/tech-insights-backend/package.json @@ -56,7 +56,7 @@ "lodash": "^4.17.21", "luxon": "^3.0.0", "semver": "^7.5.3", - "uuid": "^8.3.2", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/yarn.lock b/yarn.lock index df8ee83b70..b4606723c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3340,7 +3340,7 @@ __metadata: raw-body: ^2.4.1 supertest: ^6.1.3 tar: ^6.1.12 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 winston-transport: ^4.5.0 yauzl: ^2.10.0 @@ -3462,7 +3462,7 @@ __metadata: knex: ^3.0.0 lodash: ^4.17.21 luxon: ^3.0.0 - uuid: ^8.0.0 + uuid: ^9.0.0 wait-for-expect: ^3.0.2 winston: ^3.2.1 zod: ^3.22.4 @@ -3492,7 +3492,7 @@ __metadata: supertest: ^6.1.3 testcontainers: ^8.1.2 textextensions: ^5.16.0 - uuid: ^8.0.0 + uuid: ^9.0.0 peerDependencies: "@types/jest": "*" languageName: unknown @@ -4889,7 +4889,7 @@ __metadata: passport-oauth2: ^1.6.1 passport-onelogin-oauth: ^0.0.1 supertest: ^6.1.3 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -4919,7 +4919,7 @@ __metadata: node-fetch: ^2.6.7 passport: ^0.7.0 supertest: ^6.1.3 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 zod: ^3.22.4 zod-to-json-schema: ^3.21.4 @@ -5237,7 +5237,7 @@ __metadata: aws-sdk-client-mock-jest: ^3.0.0 luxon: ^3.0.0 p-limit: ^3.0.2 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 yaml: ^2.0.0 languageName: unknown @@ -5259,7 +5259,7 @@ __metadata: luxon: ^3.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5307,7 +5307,7 @@ __metadata: "@backstage/plugin-events-node": "workspace:^" luxon: ^3.0.0 msw: ^1.0.0 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5330,7 +5330,7 @@ __metadata: luxon: ^3.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5371,7 +5371,7 @@ __metadata: luxon: ^3.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5418,7 +5418,7 @@ __metadata: minimatch: ^9.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5437,12 +5437,12 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@types/lodash": ^4.14.151 - "@types/uuid": ^8.0.0 + "@types/uuid": ^9.0.0 lodash: ^4.17.21 luxon: ^3.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5470,7 +5470,7 @@ __metadata: express-promise-router: ^4.1.0 knex: ^3.0.0 luxon: ^3.0.0 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5491,7 +5491,7 @@ __metadata: "@types/lodash": ^4.14.151 ldapjs: ^2.2.0 lodash: ^4.17.21 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5519,7 +5519,7 @@ __metadata: node-fetch: ^2.6.7 p-limit: ^3.0.2 qs: ^6.9.4 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5565,7 +5565,7 @@ __metadata: luxon: ^3.0.0 msw: ^1.0.0 node-fetch: ^2.6.7 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -5628,7 +5628,7 @@ __metadata: "@types/glob": ^8.0.0 "@types/lodash": ^4.14.151 "@types/supertest": ^2.0.8 - "@types/uuid": ^8.0.0 + "@types/uuid": ^9.0.0 better-sqlite3: ^9.0.0 codeowners-utils: ^1.0.2 core-js: ^3.6.5 @@ -5646,7 +5646,7 @@ __metadata: p-limit: ^3.0.2 prom-client: ^15.0.0 supertest: ^6.1.3 - uuid: ^8.0.0 + uuid: ^9.0.0 wait-for-expect: ^3.0.2 winston: ^3.2.1 yaml: ^2.0.0 @@ -6031,7 +6031,7 @@ __metadata: express-promise-router: ^4.1.0 knex: ^3.0.0 supertest: ^6.1.6 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 xml2js: ^0.6.0 yn: ^4.0.0 @@ -7450,7 +7450,7 @@ __metadata: luxon: ^3.0.0 node-fetch: ^2.6.7 supertest: ^6.2.4 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -7644,7 +7644,7 @@ __metadata: msw: ^1.0.0 node-fetch: ^2.6.7 supertest: ^6.2.4 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -7674,7 +7674,7 @@ __metadata: "@backstage/test-utils": "workspace:^" knex: ^3.0.0 msw: ^1.0.0 - uuid: ^8.0.0 + uuid: ^9.0.0 languageName: unknown linkType: soft @@ -7950,7 +7950,7 @@ __metadata: "@backstage/types": "workspace:^" cross-fetch: ^4.0.0 msw: ^1.0.0 - uuid: ^8.0.0 + uuid: ^9.0.0 zod: ^3.22.4 languageName: unknown linkType: soft @@ -8021,7 +8021,7 @@ __metadata: knex: ^3.0.0 node-fetch: ^2.6.7 supertest: ^6.1.3 - uuid: ^8.2.0 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 zod: ^3.22.4 @@ -8090,7 +8090,7 @@ __metadata: "@types/express": ^4.17.6 "@types/http-proxy-middleware": ^0.19.3 "@types/supertest": ^2.0.8 - "@types/uuid": ^8.0.0 + "@types/uuid": ^9.0.0 "@types/yup": ^0.29.13 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -8098,7 +8098,7 @@ __metadata: morgan: ^1.10.0 msw: ^1.0.0 supertest: ^6.1.3 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 yaml: ^2.0.0 yn: ^4.0.0 @@ -8491,7 +8491,7 @@ __metadata: prom-client: ^15.0.0 strip-ansi: ^7.1.0 supertest: ^6.1.3 - uuid: ^8.2.0 + uuid: ^9.0.0 wait-for-expect: ^3.0.2 winston: ^3.2.1 yaml: ^2.0.0 @@ -8695,7 +8695,7 @@ __metadata: aws4: ^1.12.0 elastic-builder: ^2.16.0 lodash: ^4.17.21 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -8732,7 +8732,7 @@ __metadata: "@backstage/plugin-search-common": "workspace:^" knex: ^3.0.0 lodash: ^4.17.21 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -8799,7 +8799,7 @@ __metadata: lodash: ^4.17.21 lunr: ^2.3.9 ndjson: ^2.0.0 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 languageName: unknown linkType: soft @@ -8962,7 +8962,7 @@ __metadata: "@types/zen-observable": ^0.8.2 react-hook-form: ^7.12.2 react-use: ^17.2.4 - uuid: ^8.3.2 + uuid: ^9.0.0 zen-observable: ^0.10.0 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 @@ -8991,7 +8991,7 @@ __metadata: msw: ^1.0.0 node-fetch: ^2.6.7 supertest: ^6.2.4 - uuid: ^8.0.0 + uuid: ^9.0.0 winston: ^3.2.1 ws: ^8.14.2 yn: ^4.0.0 @@ -9011,7 +9011,7 @@ __metadata: "@backstage/types": "workspace:^" "@types/express": ^4.17.21 express: ^4.17.1 - uuid: ^8.0.0 + uuid: ^9.0.0 ws: ^8.14.2 languageName: unknown linkType: soft @@ -9054,7 +9054,7 @@ __metadata: jest-websocket-mock: ^2.5.0 msw: ^1.0.0 react-use: ^17.2.4 - uuid: ^8.0.0 + uuid: ^9.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 languageName: unknown @@ -9277,7 +9277,7 @@ __metadata: luxon: ^3.0.0 semver: ^7.5.3 supertest: ^6.1.3 - uuid: ^8.3.2 + uuid: ^9.0.0 wait-for-expect: ^3.0.2 winston: ^3.2.1 yn: ^4.0.0 @@ -11925,11 +11925,11 @@ __metadata: "@backstage/plugin-auth-node": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 - "@types/uuid": ^8.0.0 + "@types/uuid": ^9.0.0 express: ^4.17.1 express-promise-router: ^4.1.0 supertest: ^6.1.6 - uuid: ^8.3.2 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -19769,10 +19769,10 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^8.0.0": - version: 8.3.4 - resolution: "@types/uuid@npm:8.3.4" - checksum: 6f11f3ff70f30210edaa8071422d405e9c1d4e53abbe50fdce365150d3c698fe7bbff65c1e71ae080cbfb8fded860dbb5e174da96fdbbdfcaa3fb3daa474d20f +"@types/uuid@npm:^9.0.0": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275 languageName: node linkType: hard @@ -44831,7 +44831,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:8.3.2, uuid@npm:^8.0.0, uuid@npm:^8.2.0, uuid@npm:^8.3.0, uuid@npm:^8.3.2": +"uuid@npm:8.3.2, uuid@npm:^8.0.0, uuid@npm:^8.3.0, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: From 568881fa78f16c8f82e34563a7bcb16c331f82f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 09:10:45 +0000 Subject: [PATCH 084/176] fix(deps): update dependency yauzl to v3 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-ea48bac.md | 5 +++++ packages/backend-common/package.json | 2 +- yarn.lock | 21 ++++++--------------- 3 files changed, 12 insertions(+), 16 deletions(-) create mode 100644 .changeset/renovate-ea48bac.md diff --git a/.changeset/renovate-ea48bac.md b/.changeset/renovate-ea48bac.md new file mode 100644 index 0000000000..ca56d7490a --- /dev/null +++ b/.changeset/renovate-ea48bac.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Updated dependency `yauzl` to `^3.0.0`. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 2fe5945750..c1b7e181fd 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -104,7 +104,7 @@ "uuid": "^8.3.2", "winston": "^3.2.1", "winston-transport": "^4.5.0", - "yauzl": "^2.10.0", + "yauzl": "^3.0.0", "yn": "^4.0.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 3ce73c69e8..bdc37d7de8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3343,7 +3343,7 @@ __metadata: uuid: ^8.3.2 winston: ^3.2.1 winston-transport: ^4.5.0 - yauzl: ^2.10.0 + yauzl: ^3.0.0 yn: ^4.0.0 peerDependencies: pg-connection-string: ^2.3.0 @@ -28043,15 +28043,6 @@ __metadata: languageName: node linkType: hard -"fd-slicer@npm:~1.1.0": - version: 1.1.0 - resolution: "fd-slicer@npm:1.1.0" - dependencies: - pend: ~1.2.0 - checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2 - languageName: node - linkType: hard - "fecha@npm:^4.2.0": version: 4.2.0 resolution: "fecha@npm:4.2.0" @@ -46254,13 +46245,13 @@ __metadata: languageName: node linkType: hard -"yauzl@npm:^2.10.0": - version: 2.10.0 - resolution: "yauzl@npm:2.10.0" +"yauzl@npm:^3.0.0": + version: 3.1.0 + resolution: "yauzl@npm:3.1.0" dependencies: buffer-crc32: ~0.2.3 - fd-slicer: ~1.1.0 - checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b + pend: ~1.2.0 + checksum: 0464b49b0c10f0ab19136c917358b025c7f86bab0699a1f8afd681e09f3782688d3106b4e7eadfcc81e54779cca5c6de0843fabf5ceb07e1638ec2fd8371d0b7 languageName: node linkType: hard From 5097060df8204f42067d58ae47c7bcd6b2a2613b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 26 Feb 2024 10:15:05 +0100 Subject: [PATCH 085/176] enter pre mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/pre.json | 280 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..8a921bf194 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,280 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "example-app": "0.2.92", + "@backstage/app-defaults": "1.5.0", + "example-app-next": "0.0.6", + "app-next-example-plugin": "0.0.6", + "example-backend": "0.2.92", + "@backstage/backend-app-api": "0.5.11", + "@backstage/backend-common": "0.21.0", + "@backstage/backend-defaults": "0.2.10", + "@backstage/backend-dev-utils": "0.1.4", + "@backstage/backend-dynamic-feature-service": "0.2.0", + "example-backend-next": "0.0.20", + "@backstage/backend-openapi-utils": "0.1.3", + "@backstage/backend-plugin-api": "0.6.10", + "@backstage/backend-tasks": "0.5.15", + "@backstage/backend-test-utils": "0.3.0", + "@backstage/catalog-client": "1.6.0", + "@backstage/catalog-model": "1.4.4", + "@backstage/cli": "0.25.2", + "@backstage/cli-common": "0.1.13", + "@backstage/cli-node": "0.2.3", + "@backstage/codemods": "0.1.47", + "@backstage/config": "1.1.1", + "@backstage/config-loader": "1.6.2", + "@backstage/core-app-api": "1.12.0", + "@backstage/core-compat-api": "0.2.0", + "@backstage/core-components": "0.14.0", + "@backstage/core-plugin-api": "1.9.0", + "@backstage/create-app": "0.5.11", + "@backstage/dev-utils": "1.0.27", + "e2e-test": "0.2.12", + "@backstage/e2e-test-utils": "0.1.1", + "@backstage/errors": "1.2.3", + "@backstage/eslint-plugin": "0.1.5", + "@backstage/frontend-app-api": "0.6.0", + "@backstage/frontend-plugin-api": "0.6.0", + "@backstage/frontend-test-utils": "0.1.2", + "@backstage/integration": "1.9.0", + "@backstage/integration-aws-node": "0.1.9", + "@backstage/integration-react": "1.1.24", + "@backstage/release-manifests": "0.0.11", + "@backstage/repo-tools": "0.6.0", + "@techdocs/cli": "1.8.2", + "techdocs-cli-embedded-app": "0.2.91", + "@backstage/test-utils": "1.5.0", + "@backstage/theme": "0.5.1", + "@backstage/types": "1.1.1", + "@backstage/version-bridge": "1.0.7", + "@backstage/plugin-adr": "0.6.13", + "@backstage/plugin-adr-backend": "0.4.7", + "@backstage/plugin-adr-common": "0.2.20", + "@backstage/plugin-airbrake": "0.3.30", + "@backstage/plugin-airbrake-backend": "0.3.7", + "@backstage/plugin-allure": "0.1.46", + "@backstage/plugin-analytics-module-ga": "0.2.0", + "@backstage/plugin-analytics-module-ga4": "0.2.0", + "@backstage/plugin-analytics-module-newrelic-browser": "0.1.0", + "@backstage/plugin-apache-airflow": "0.2.20", + "@backstage/plugin-api-docs": "0.11.0", + "@backstage/plugin-api-docs-module-protoc-gen-doc": "0.1.6", + "@backstage/plugin-apollo-explorer": "0.1.20", + "@backstage/plugin-app-backend": "0.3.58", + "@backstage/plugin-app-node": "0.1.10", + "@backstage/plugin-app-visualizer": "0.1.1", + "@backstage/plugin-auth-backend": "0.21.0", + "@backstage/plugin-auth-backend-module-atlassian-provider": "0.1.2", + "@backstage/plugin-auth-backend-module-aws-alb-provider": "0.1.0", + "@backstage/plugin-auth-backend-module-gcp-iap-provider": "0.2.4", + "@backstage/plugin-auth-backend-module-github-provider": "0.1.7", + "@backstage/plugin-auth-backend-module-gitlab-provider": "0.1.7", + "@backstage/plugin-auth-backend-module-google-provider": "0.1.7", + "@backstage/plugin-auth-backend-module-microsoft-provider": "0.1.5", + "@backstage/plugin-auth-backend-module-oauth2-provider": "0.1.7", + "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "0.1.2", + "@backstage/plugin-auth-backend-module-oidc-provider": "0.1.0", + "@backstage/plugin-auth-backend-module-okta-provider": "0.0.3", + "@backstage/plugin-auth-backend-module-pinniped-provider": "0.1.4", + "@backstage/plugin-auth-backend-module-vmware-cloud-provider": "0.1.2", + "@backstage/plugin-auth-node": "0.4.4", + "@backstage/plugin-azure-devops": "0.3.12", + "@backstage/plugin-azure-devops-backend": "0.5.2", + "@backstage/plugin-azure-devops-common": "0.3.2", + "@backstage/plugin-azure-sites": "0.1.19", + "@backstage/plugin-azure-sites-backend": "0.2.0", + "@backstage/plugin-azure-sites-common": "0.1.2", + "@backstage/plugin-badges": "0.2.54", + "@backstage/plugin-badges-backend": "0.3.7", + "@backstage/plugin-bazaar": "0.2.22", + "@backstage/plugin-bazaar-backend": "0.3.8", + "@backstage/plugin-bitbucket-cloud-common": "0.2.16", + "@backstage/plugin-bitrise": "0.1.57", + "@backstage/plugin-catalog": "1.17.0", + "@backstage/plugin-catalog-backend": "1.17.0", + "@backstage/plugin-catalog-backend-module-aws": "0.3.4", + "@backstage/plugin-catalog-backend-module-azure": "0.1.29", + "@backstage/plugin-catalog-backend-module-backstage-openapi": "0.1.3", + "@backstage/plugin-catalog-backend-module-bitbucket-cloud": "0.1.25", + "@backstage/plugin-catalog-backend-module-bitbucket-server": "0.1.23", + "@backstage/plugin-catalog-backend-module-gcp": "0.1.10", + "@backstage/plugin-catalog-backend-module-gerrit": "0.1.26", + "@backstage/plugin-catalog-backend-module-github": "0.5.0", + "@backstage/plugin-catalog-backend-module-github-org": "0.1.4", + "@backstage/plugin-catalog-backend-module-gitlab": "0.3.7", + "@backstage/plugin-catalog-backend-module-incremental-ingestion": "0.4.14", + "@backstage/plugin-catalog-backend-module-ldap": "0.5.25", + "@backstage/plugin-catalog-backend-module-msgraph": "0.5.17", + "@backstage/plugin-catalog-backend-module-openapi": "0.1.27", + "@backstage/plugin-catalog-backend-module-puppetdb": "0.1.15", + "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "0.1.7", + "@backstage/plugin-catalog-backend-module-unprocessed": "0.3.7", + "@backstage/plugin-catalog-common": "1.0.21", + "@backstage/plugin-catalog-graph": "0.4.0", + "@backstage/plugin-catalog-import": "0.10.6", + "@backstage/plugin-catalog-node": "1.7.0", + "@backstage/plugin-catalog-react": "1.10.0", + "@backstage/plugin-catalog-unprocessed-entities": "0.1.8", + "@backstage/plugin-cicd-statistics": "0.1.32", + "@backstage/plugin-cicd-statistics-module-gitlab": "0.1.26", + "@backstage/plugin-circleci": "0.3.30", + "@backstage/plugin-cloudbuild": "0.4.0", + "@backstage/plugin-code-climate": "0.1.30", + "@backstage/plugin-code-coverage": "0.2.23", + "@backstage/plugin-code-coverage-backend": "0.2.24", + "@backstage/plugin-codescene": "0.1.22", + "@backstage/plugin-config-schema": "0.1.50", + "@backstage/plugin-cost-insights": "0.12.19", + "@backstage/plugin-cost-insights-common": "0.1.2", + "@backstage/plugin-devtools": "0.1.9", + "@backstage/plugin-devtools-backend": "0.2.7", + "@backstage/plugin-devtools-common": "0.1.8", + "@backstage/plugin-dynatrace": "9.0.0", + "@backstage/plugin-entity-feedback": "0.2.13", + "@backstage/plugin-entity-feedback-backend": "0.2.7", + "@backstage/plugin-entity-feedback-common": "0.1.3", + "@backstage/plugin-entity-validation": "0.1.15", + "@backstage/plugin-events-backend": "0.2.19", + "@backstage/plugin-events-backend-module-aws-sqs": "0.2.13", + "@backstage/plugin-events-backend-module-azure": "0.1.20", + "@backstage/plugin-events-backend-module-bitbucket-cloud": "0.1.20", + "@backstage/plugin-events-backend-module-gerrit": "0.1.20", + "@backstage/plugin-events-backend-module-github": "0.1.20", + "@backstage/plugin-events-backend-module-gitlab": "0.1.20", + "@backstage/plugin-events-backend-test-utils": "0.1.20", + "@backstage/plugin-events-node": "0.2.19", + "@internal/plugin-todo-list": "1.0.22", + "@internal/plugin-todo-list-backend": "1.0.22", + "@internal/plugin-todo-list-common": "1.0.17", + "@backstage/plugin-explore": "0.4.16", + "@backstage/plugin-explore-backend": "0.0.20", + "@backstage/plugin-explore-common": "0.0.2", + "@backstage/plugin-explore-react": "0.0.36", + "@backstage/plugin-firehydrant": "0.2.14", + "@backstage/plugin-fossa": "0.2.62", + "@backstage/plugin-gcalendar": "0.3.23", + "@backstage/plugin-gcp-projects": "0.3.46", + "@backstage/plugin-git-release-manager": "0.3.42", + "@backstage/plugin-github-actions": "0.6.11", + "@backstage/plugin-github-deployments": "0.1.61", + "@backstage/plugin-github-issues": "0.2.19", + "@backstage/plugin-github-pull-requests-board": "0.1.24", + "@backstage/plugin-gitops-profiles": "0.3.45", + "@backstage/plugin-gocd": "0.1.36", + "@backstage/plugin-graphiql": "0.3.3", + "@backstage/plugin-graphql-voyager": "0.1.12", + "@backstage/plugin-home": "0.6.2", + "@backstage/plugin-home-react": "0.1.8", + "@backstage/plugin-ilert": "0.2.19", + "@backstage/plugin-jenkins": "0.9.5", + "@backstage/plugin-jenkins-backend": "0.3.4", + "@backstage/plugin-jenkins-common": "0.1.24", + "@backstage/plugin-kafka": "0.3.30", + "@backstage/plugin-kafka-backend": "0.3.8", + "@backstage/plugin-kubernetes": "0.11.5", + "@backstage/plugin-kubernetes-backend": "0.15.0", + "@backstage/plugin-kubernetes-cluster": "0.0.6", + "@backstage/plugin-kubernetes-common": "0.7.4", + "@backstage/plugin-kubernetes-node": "0.1.4", + "@backstage/plugin-kubernetes-react": "0.3.0", + "@backstage/plugin-lighthouse": "0.4.15", + "@backstage/plugin-lighthouse-backend": "0.4.2", + "@backstage/plugin-lighthouse-common": "0.1.4", + "@backstage/plugin-linguist": "0.1.15", + "@backstage/plugin-linguist-backend": "0.5.7", + "@backstage/plugin-linguist-common": "0.1.2", + "@backstage/plugin-microsoft-calendar": "0.1.12", + "@backstage/plugin-newrelic": "0.3.45", + "@backstage/plugin-newrelic-dashboard": "0.3.5", + "@backstage/plugin-nomad": "0.1.11", + "@backstage/plugin-nomad-backend": "0.1.12", + "@backstage/plugin-notifications": "0.0.1", + "@backstage/plugin-notifications-backend": "0.0.1", + "@backstage/plugin-notifications-common": "0.0.1", + "@backstage/plugin-notifications-node": "0.0.1", + "@backstage/plugin-octopus-deploy": "0.2.12", + "@backstage/plugin-opencost": "0.2.5", + "@backstage/plugin-org": "0.6.20", + "@backstage/plugin-org-react": "0.1.19", + "@backstage/plugin-pagerduty": "0.7.2", + "@backstage/plugin-periskop": "0.1.28", + "@backstage/plugin-periskop-backend": "0.2.8", + "@backstage/plugin-permission-backend": "0.5.33", + "@backstage/plugin-permission-backend-module-allow-all-policy": "0.1.7", + "@backstage/plugin-permission-common": "0.7.12", + "@backstage/plugin-permission-node": "0.7.21", + "@backstage/plugin-permission-react": "0.4.20", + "@backstage/plugin-playlist": "0.2.4", + "@backstage/plugin-playlist-backend": "0.3.14", + "@backstage/plugin-playlist-common": "0.1.14", + "@backstage/plugin-proxy-backend": "0.4.8", + "@backstage/plugin-puppetdb": "0.1.13", + "@backstage/plugin-rollbar": "0.4.30", + "@backstage/plugin-rollbar-backend": "0.1.55", + "@backstage/plugin-scaffolder": "1.18.0", + "@backstage/plugin-scaffolder-backend": "1.21.0", + "@backstage/plugin-scaffolder-backend-module-azure": "0.1.2", + "@backstage/plugin-scaffolder-backend-module-bitbucket": "0.2.0", + "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "0.1.0", + "@backstage/plugin-scaffolder-backend-module-bitbucket-server": "0.1.0", + "@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "0.2.11", + "@backstage/plugin-scaffolder-backend-module-cookiecutter": "0.2.34", + "@backstage/plugin-scaffolder-backend-module-gerrit": "0.1.2", + "@backstage/plugin-scaffolder-backend-module-gitea": "0.1.0", + "@backstage/plugin-scaffolder-backend-module-github": "0.2.0", + "@backstage/plugin-scaffolder-backend-module-gitlab": "0.2.13", + "@backstage/plugin-scaffolder-backend-module-rails": "0.4.27", + "@backstage/plugin-scaffolder-backend-module-sentry": "0.1.18", + "@backstage/plugin-scaffolder-backend-module-yeoman": "0.2.31", + "@backstage/plugin-scaffolder-common": "1.5.0", + "@backstage/plugin-scaffolder-node": "0.3.0", + "@backstage/plugin-scaffolder-react": "1.8.0", + "@backstage/plugin-search": "1.4.6", + "@backstage/plugin-search-backend": "1.5.0", + "@backstage/plugin-search-backend-module-catalog": "0.1.14", + "@backstage/plugin-search-backend-module-elasticsearch": "1.3.13", + "@backstage/plugin-search-backend-module-explore": "0.1.14", + "@backstage/plugin-search-backend-module-pg": "0.5.19", + "@backstage/plugin-search-backend-module-stack-overflow-collator": "0.1.3", + "@backstage/plugin-search-backend-module-techdocs": "0.1.14", + "@backstage/plugin-search-backend-node": "1.2.14", + "@backstage/plugin-search-common": "1.2.10", + "@backstage/plugin-search-react": "1.7.6", + "@backstage/plugin-sentry": "0.5.15", + "@backstage/plugin-shortcuts": "0.3.19", + "@backstage/plugin-signals": "0.0.1", + "@backstage/plugin-signals-backend": "0.0.1", + "@backstage/plugin-signals-node": "0.0.1", + "@backstage/plugin-signals-react": "0.0.1", + "@backstage/plugin-sonarqube": "0.7.12", + "@backstage/plugin-sonarqube-backend": "0.2.12", + "@backstage/plugin-sonarqube-react": "0.1.13", + "@backstage/plugin-splunk-on-call": "0.4.19", + "@backstage/plugin-stack-overflow": "0.1.25", + "@backstage/plugin-stack-overflow-backend": "0.2.14", + "@backstage/plugin-stackstorm": "0.1.11", + "@backstage/plugin-tech-insights": "0.3.22", + "@backstage/plugin-tech-insights-backend": "0.5.24", + "@backstage/plugin-tech-insights-backend-module-jsonfc": "0.1.42", + "@backstage/plugin-tech-insights-common": "0.2.12", + "@backstage/plugin-tech-insights-node": "0.4.16", + "@backstage/plugin-tech-radar": "0.6.13", + "@backstage/plugin-techdocs": "1.10.0", + "@backstage/plugin-techdocs-addons-test-utils": "1.0.27", + "@backstage/plugin-techdocs-backend": "1.9.3", + "@backstage/plugin-techdocs-module-addons-contrib": "1.1.5", + "@backstage/plugin-techdocs-node": "1.11.2", + "@backstage/plugin-techdocs-react": "1.1.16", + "@backstage/plugin-todo": "0.2.34", + "@backstage/plugin-todo-backend": "0.3.8", + "@backstage/plugin-user-settings": "0.8.1", + "@backstage/plugin-user-settings-backend": "0.2.9", + "@backstage/plugin-vault": "0.1.25", + "@backstage/plugin-vault-backend": "0.4.3", + "@backstage/plugin-vault-node": "0.1.3", + "@backstage/plugin-xcmetrics": "0.2.48" + }, + "changesets": [] +} From 05a4bbb5d0eaa7098b36b4f436bcf17c1ed21aa8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 10:17:50 +0100 Subject: [PATCH 086/176] beps/0003: add none credential and expiration times Signed-off-by: Patrik Oldsberg --- beps/0003-auth-architecture-evolution/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index fe3cc7bdb8..998eb08dbd 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -110,6 +110,8 @@ export type BackstageServicePrincipal = { export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + expiresAt?: Date; + principal: TPrincipal; }; @@ -132,6 +134,8 @@ export interface AuthService { type: TType, ): credentials is BackstageCredentials; + getNoneCredentials(): Promise>; + getOwnServiceCredentials(): Promise< BackstageCredentials >; @@ -228,9 +232,9 @@ export default createBackendPlugin({ // Endpoint that sets the cookie for the user router.get('/cookie', async (req, res) => { - await httpAuth.issueUserCookie(req); + const { expiresAt } = await httpAuth.issueUserCookie(req); - res.json({ ok: true }); + res.json({ expiresAt: expiresAt.toISOString() }); }); // Endpoint protected by cookie auth @@ -303,7 +307,7 @@ export interface HttpAuthService { // If credentials are not provided, they will be read from the request credentials?: BackstageCredentials; }, - ): Promise; + ): Promise<{ expiresAt: Date }>; } ``` From 1e416561fbe08afa07b9474a33cfe0287916168e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 26 Feb 2024 10:37:53 +0100 Subject: [PATCH 087/176] Update packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Eric Peterson Signed-off-by: Fredrik Adelöw --- .../backend-common/src/auth/createLegacyAuthAdapters.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts index db4461781b..43a503199b 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts @@ -56,7 +56,7 @@ describe('createLegacyAuthAdapters', () => { expect(ret.httpAuth).toBe(httpAuth); }); - it('should pass through userInfo if it provided', () => { + it('should pass through userInfo if it is provided', () => { const auth = {}; const userInfo = {}; const ret = createLegacyAuthAdapters({ From bd37c85bfae074ed059218e84ebc73154f0d99e3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 10:53:45 +0100 Subject: [PATCH 088/176] scaffolder-backend-module-gitlab: clear mocks in test Signed-off-by: Patrik Oldsberg --- .../src/actions/gitlabMergeRequest.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts index 71a2273aed..0883a5e9f0 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts @@ -78,6 +78,8 @@ describe('createGitLabMergeRequest', () => { const workspacePath = mockDir.resolve('workspace'); beforeEach(() => { + jest.clearAllMocks(); + mockDir.clear(); const config = new ConfigReader({ From 19e3a21e5f75c156b0a6b7392ce11422bfc30aab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 11:00:46 +0100 Subject: [PATCH 089/176] Update plugins/playlist-backend/src/service/router.ts Co-authored-by: Phil Kuang Signed-off-by: Patrik Oldsberg --- plugins/playlist-backend/src/service/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/playlist-backend/src/service/router.ts b/plugins/playlist-backend/src/service/router.ts index 8fa9983d0b..b73c7279f7 100644 --- a/plugins/playlist-backend/src/service/router.ts +++ b/plugins/playlist-backend/src/service/router.ts @@ -232,7 +232,7 @@ export async function createRouter( targetPluginId: 'catalog', }); - // TODO(kuanpg): entities in this playlist that no longer exist in the catalog will be + // TODO(kuangp): entities in this playlist that no longer exist in the catalog will be // excluded from this response, we need a way to clean up these orphaned refs potentially // via catalog events (https://github.com/backstage/backstage/issues/8219) // From 1b4fd09aeaa0ea04cb4144ced917175d28653d38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 10:05:43 +0000 Subject: [PATCH 090/176] fix(deps): update dependency yup to v1 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-7aa519f.md | 6 ++++ plugins/cost-insights/package.json | 2 +- plugins/proxy-backend/package.json | 2 +- yarn.lock | 47 ++++++++++++++---------------- 4 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 .changeset/renovate-7aa519f.md diff --git a/.changeset/renovate-7aa519f.md b/.changeset/renovate-7aa519f.md new file mode 100644 index 0000000000..763d94217b --- /dev/null +++ b/.changeset/renovate-7aa519f.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-cost-insights': patch +'@backstage/plugin-proxy-backend': patch +--- + +Updated dependency `yup` to `^1.0.0`. diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 4bc0454ab0..50c6119655 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -58,7 +58,7 @@ "react-use": "^17.2.4", "recharts": "^2.5.0", "regression": "^2.0.1", - "yup": "^0.32.9" + "yup": "^1.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index df9890b68c..5e4ed1e728 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -62,7 +62,7 @@ "winston": "^3.2.1", "yaml": "^2.0.0", "yn": "^4.0.0", - "yup": "^0.32.9" + "yup": "^1.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/yarn.lock b/yarn.lock index f71d95fbcd..86dd8563f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3144,7 +3144,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.23.8 resolution: "@babel/runtime@npm:7.23.8" dependencies: @@ -6170,7 +6170,7 @@ __metadata: react-use: ^17.2.4 recharts: ^2.5.0 regression: ^2.0.1 - yup: ^0.32.9 + yup: ^1.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 @@ -8102,7 +8102,7 @@ __metadata: winston: ^3.2.1 yaml: ^2.0.0 yn: ^4.0.0 - yup: ^0.32.9 + yup: ^1.0.0 languageName: unknown linkType: soft @@ -18857,7 +18857,7 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.151, @types/lodash@npm:^4.14.173, @types/lodash@npm:^4.14.175": +"@types/lodash@npm:^4.14.151, @types/lodash@npm:^4.14.173": version: 4.14.202 resolution: "@types/lodash@npm:4.14.202" checksum: a91acf3564a568c6f199912f3eb2c76c99c5a0d7e219394294213b3f2d54f672619f0fde4da22b29dc5d4c31457cd799acc2e5cb6bd90f9af04a1578483b6ff7 @@ -35657,13 +35657,6 @@ __metadata: languageName: node linkType: hard -"nanoclone@npm:^0.2.1": - version: 0.2.1 - resolution: "nanoclone@npm:0.2.1" - checksum: 96b2954e22f70561f41e20d69856266c65583c2a441dae108f1dc71b716785d2c8038dac5f1d5e92b117aed3825f526b53139e2e5d6e6db8a77cfa35b3b8bf40 - languageName: node - linkType: hard - "nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -38792,10 +38785,10 @@ __metadata: languageName: node linkType: hard -"property-expr@npm:^2.0.4": - version: 2.0.4 - resolution: "property-expr@npm:2.0.4" - checksum: 7ac142e189f0feef685f327f582efe13bfbc24a0b6e2328afdb38520bc140caa5f91dfa9529f2539b4468d85dc83a593e1ef0e0f7401b525368bb634b323bf54 +"property-expr@npm:^2.0.5": + version: 2.0.6 + resolution: "property-expr@npm:2.0.6" + checksum: 89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab languageName: node linkType: hard @@ -43506,6 +43499,13 @@ __metadata: languageName: node linkType: hard +"tiny-case@npm:^1.0.3": + version: 1.0.3 + resolution: "tiny-case@npm:1.0.3" + checksum: 3f7a30c39d5b0e1bc097b0b271bec14eb5b836093db034f35a0de26c14422380b50dc12bfd37498cf35b192f5df06f28a710712c87ead68872a9e37ad6f6049d + languageName: node + linkType: hard + "tiny-emitter@npm:^2.0.0": version: 2.1.0 resolution: "tiny-emitter@npm:2.1.0" @@ -46349,18 +46349,15 @@ __metadata: languageName: node linkType: hard -"yup@npm:^0.32.9": - version: 0.32.11 - resolution: "yup@npm:0.32.11" +"yup@npm:^1.0.0": + version: 1.3.3 + resolution: "yup@npm:1.3.3" dependencies: - "@babel/runtime": ^7.15.4 - "@types/lodash": ^4.14.175 - lodash: ^4.17.21 - lodash-es: ^4.17.21 - nanoclone: ^0.2.1 - property-expr: ^2.0.4 + property-expr: ^2.0.5 + tiny-case: ^1.0.3 toposort: ^2.0.2 - checksum: 43a16786b47cc910fed4891cebdd89df6d6e31702e9462e8f969c73eac88551ce750732608012201ea6b93802c8847cb0aa27b5d57370640f4ecf30f9f97d4b0 + type-fest: ^2.19.0 + checksum: 7b9e19fedc85deb8cffd6b24617f79542c904f11a9c4ca8bb72fb003e61bd6c5278cc799fa9421c6a1a96ff04e7117bf0c000c9f10b543d6cd32a1e35e8f5f65 languageName: node linkType: hard From 15ba00ff7dc25828d797f2615b09e49c522d3090 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 13:24:42 +0100 Subject: [PATCH 091/176] catalog-backend: migrate to support new auth services Signed-off-by: Patrik Oldsberg --- .changeset/purple-kiwis-complain.md | 5 + plugins/catalog-backend/api-report.md | 10 +- plugins/catalog-backend/src/catalog/types.ts | 19 +-- .../service/AuthorizedEntitiesCatalog.test.ts | 79 ++++++----- .../src/service/AuthorizedEntitiesCatalog.ts | 41 +++--- .../service/AuthorizedLocationService.test.ts | 63 ++++----- .../src/service/AuthorizedLocationService.ts | 45 ++++--- .../service/AuthorizedRefreshService.test.ts | 5 +- .../src/service/AuthorizedRefreshService.ts | 10 +- .../src/service/CatalogBuilder.ts | 49 +++++-- .../src/service/CatalogPlugin.ts | 9 ++ .../service/DefaultEntitiesCatalog.test.ts | 93 ++++++++++--- .../src/service/DefaultRefreshService.test.ts | 10 +- .../src/service/createRouter.test.ts | 127 +++++++++--------- .../src/service/createRouter.ts | 77 ++++------- .../request/parseQueryEntitiesParams.ts | 6 +- plugins/catalog-backend/src/service/types.ts | 17 ++- 17 files changed, 396 insertions(+), 269 deletions(-) create mode 100644 .changeset/purple-kiwis-complain.md diff --git a/.changeset/purple-kiwis-complain.md b/.changeset/purple-kiwis-complain.md new file mode 100644 index 0000000000..a5bcb3ed67 --- /dev/null +++ b/.changeset/purple-kiwis-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Migrated to support new auth services. The `CatalogBuilder.create` method now accepts a `discovery` option, which is recommended to forward from the plugin environment, as it will otherwise fall back to use the `HostDiscovery` implementation. diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 92a2fbb280..ef2379766d 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -11,6 +11,7 @@ import { AnalyzeLocationGenerateEntity as AnalyzeLocationGenerateEntity_2 } from import { AnalyzeLocationRequest as AnalyzeLocationRequest_2 } from '@backstage/plugin-catalog-common'; import { AnalyzeLocationResponse as AnalyzeLocationResponse_2 } from '@backstage/plugin-catalog-common'; import { AnalyzeOptions as AnalyzeOptions_2 } from '@backstage/plugin-catalog-node'; +import { AuthService } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CatalogCollatorEntityTransformer as CatalogCollatorEntityTransformer_2 } from '@backstage/plugin-search-backend-module-catalog'; import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; @@ -28,6 +29,7 @@ import { Config } from '@backstage/config'; import { DefaultCatalogCollatorFactory as DefaultCatalogCollatorFactory_2 } from '@backstage/plugin-search-backend-module-catalog'; import { DefaultCatalogCollatorFactoryOptions as DefaultCatalogCollatorFactoryOptions_2 } from '@backstage/plugin-search-backend-module-catalog'; import { DeferredEntity as DeferredEntity_2 } from '@backstage/plugin-catalog-node'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { EntitiesSearchFilter as EntitiesSearchFilter_2 } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter as EntityFilter_2 } from '@backstage/plugin-catalog-node'; @@ -38,15 +40,16 @@ import { EntityProviderMutation as EntityProviderMutation_2 } from '@backstage/p import { EntityRelationSpec as EntityRelationSpec_2 } from '@backstage/plugin-catalog-node'; import { EventBroker } from '@backstage/plugin-events-node'; import { GetEntitiesRequest } from '@backstage/catalog-client'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { LocationSpec as LocationSpec_2 } from '@backstage/plugin-catalog-common'; import { locationSpecToLocationEntity as locationSpecToLocationEntity_2 } from '@backstage/plugin-catalog-node'; import { locationSpecToMetadataName as locationSpecToMetadataName_2 } from '@backstage/plugin-catalog-node'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; -import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PermissionRuleParams } from '@backstage/plugin-permission-common'; +import { PermissionsService } from '@backstage/backend-plugin-api'; import { PlaceholderResolver as PlaceholderResolver_2 } from '@backstage/plugin-catalog-node'; import { PlaceholderResolverParams as PlaceholderResolverParams_2 } from '@backstage/plugin-catalog-node'; import { PlaceholderResolverRead as PlaceholderResolverRead_2 } from '@backstage/plugin-catalog-node'; @@ -189,8 +192,11 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionEvaluator | PermissionAuthorizer; + permissions: PermissionsService | PermissionAuthorizer; scheduler?: PluginTaskScheduler; + discovery?: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; }; // @public diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index e9f5f154c2..9cc83098b3 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; @@ -48,7 +49,7 @@ export type EntitiesRequest = { fields?: (entity: Entity) => Entity; order?: EntityOrder[]; pagination?: EntityPagination; - authorizationToken?: string; + credentials: BackstageCredentials; }; export type EntitiesResponse = { @@ -75,9 +76,9 @@ export interface EntitiesBatchRequest { */ fields?: (entity: Entity) => Entity; /** - * The optional token that authorizes the action. + * The credentials that authorizes the action. */ - authorizationToken?: string; + credentials: BackstageCredentials; } export interface EntitiesBatchResponse { @@ -115,9 +116,9 @@ export interface EntityFacetsRequest { */ facets: string[]; /** - * The optional token that authorizes the action. + * The credentials that authorizes the action. */ - authorizationToken?: string; + credentials: BackstageCredentials; } /** @@ -157,7 +158,7 @@ export interface EntitiesCatalog { */ removeEntityByUid( uid: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise; /** @@ -167,7 +168,7 @@ export interface EntitiesCatalog { */ entityAncestry( entityRef: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise; /** @@ -192,7 +193,7 @@ export type QueryEntitiesRequest = * for the current and the next pagination requests. */ export interface QueryEntitiesInitialRequest { - authorizationToken?: string; + credentials: BackstageCredentials; fields?: (entity: Entity) => Entity; limit?: number; filter?: EntityFilter; @@ -208,7 +209,7 @@ export interface QueryEntitiesInitialRequest { * move forward or backward on the data. */ export interface QueryEntitiesCursorRequest { - authorizationToken?: string; + credentials: BackstageCredentials; fields?: (entity: Entity) => Entity; limit?: number; cursor: Cursor; diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 4fe0311fff..3e2dee7ae0 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { Cursor, QueryEntitiesResponse } from '../catalog/types'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedEntitiesCatalog', () => { const fakeCatalog = { @@ -60,7 +61,7 @@ describe('AuthorizedEntitiesCatalog', () => { expect( await catalog.entities({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }), ).toEqual({ entities: [], @@ -77,10 +78,10 @@ describe('AuthorizedEntitiesCatalog', () => { ]); const catalog = createCatalog(isEntityKind); - await catalog.entities({ authorizationToken: 'abcd' }); + await catalog.entities({ credentials: mockCredentials.none() }); expect(fakeCatalog.entities).toHaveBeenCalledWith({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }); }); @@ -91,10 +92,10 @@ describe('AuthorizedEntitiesCatalog', () => { ]); const catalog = createCatalog(); - await catalog.entities({ authorizationToken: 'abcd' }); + await catalog.entities({ credentials: mockCredentials.none() }); expect(fakeCatalog.entities).toHaveBeenCalledWith({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }); }); }); @@ -109,7 +110,7 @@ describe('AuthorizedEntitiesCatalog', () => { await expect( catalog.entitiesBatch({ entityRefs: ['component:default/component-a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }), ).resolves.toEqual({ items: [null], @@ -132,12 +133,12 @@ describe('AuthorizedEntitiesCatalog', () => { await catalog.entitiesBatch({ entityRefs: ['component:default/component-a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }); expect(fakeCatalog.entitiesBatch).toHaveBeenCalledWith({ entityRefs: ['component:default/component-a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }); }); @@ -150,12 +151,12 @@ describe('AuthorizedEntitiesCatalog', () => { await catalog.entitiesBatch({ entityRefs: ['component:default/component-a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }); expect(fakeCatalog.entitiesBatch).toHaveBeenCalledWith({ entityRefs: ['component:default/component-a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }); }); }); @@ -169,7 +170,7 @@ describe('AuthorizedEntitiesCatalog', () => { await expect( catalog.queryEntities({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }), ).resolves.toEqual({ @@ -188,12 +189,12 @@ describe('AuthorizedEntitiesCatalog', () => { const catalog = createCatalog(); await catalog.queryEntities({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }); expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }); }); @@ -243,12 +244,12 @@ describe('AuthorizedEntitiesCatalog', () => { const catalog = createCatalog(isEntityKind); let response = await catalog.queryEntities({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'name', values: ['name'] }, }); expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { allOf: [{ key: 'kind', values: ['b'] }, requestFilter] }, }); @@ -276,12 +277,12 @@ describe('AuthorizedEntitiesCatalog', () => { orderFieldValues: ['a', null], }; response = await catalog.queryEntities({ - authorizationToken: 'abcd', + credentials: mockCredentials.none(), cursor, }); expect(fakeCatalog.queryEntities).toHaveBeenNthCalledWith(2, { - authorizationToken: 'abcd', + credentials: mockCredentials.none(), cursor: { ...cursor, filter: { allOf: [{ key: 'kind', values: ['b'] }, requestFilter] }, @@ -324,7 +325,9 @@ describe('AuthorizedEntitiesCatalog', () => { ); await expect(() => - catalog.removeEntityByUid('uid', { authorizationToken: 'abcd' }), + catalog.removeEntityByUid('uid', { + credentials: mockCredentials.none(), + }), ).rejects.toThrow(NotAllowedError); }); @@ -343,7 +346,9 @@ describe('AuthorizedEntitiesCatalog', () => { ); await expect(() => - catalog.removeEntityByUid('uid', { authorizationToken: 'abcd' }), + catalog.removeEntityByUid('uid', { + credentials: mockCredentials.none(), + }), ).rejects.toThrow(NotAllowedError); }); @@ -363,9 +368,13 @@ describe('AuthorizedEntitiesCatalog', () => { createConditionTransformer([isEntityKind]), ); - await catalog.removeEntityByUid('uid', { authorizationToken: 'abcd' }); + await catalog.removeEntityByUid('uid', { + credentials: mockCredentials.none(), + }); - expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid'); + expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid', { + credentials: mockCredentials.none(), + }); }); it('calls underlying catalog method on ALLOW', async () => { @@ -383,9 +392,13 @@ describe('AuthorizedEntitiesCatalog', () => { createConditionTransformer([]), ); - await catalog.removeEntityByUid('uid', { authorizationToken: 'abcd' }); + await catalog.removeEntityByUid('uid', { + credentials: mockCredentials.none(), + }); - expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid'); + expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid', { + credentials: mockCredentials.none(), + }); }); }); @@ -398,7 +411,7 @@ describe('AuthorizedEntitiesCatalog', () => { await expect(() => catalog.entityAncestry('backstage:default/component', { - authorizationToken: 'Bearer abcd', + credentials: mockCredentials.none(), }), ).rejects.toThrow(NotAllowedError); }); @@ -443,7 +456,7 @@ describe('AuthorizedEntitiesCatalog', () => { const ancestryResult = await catalog.entityAncestry( 'backstage:default/a', - { authorizationToken: 'Bearer abcd' }, + { credentials: mockCredentials.none() }, ); expect(ancestryResult).toEqual({ @@ -476,7 +489,7 @@ describe('AuthorizedEntitiesCatalog', () => { expect( await catalog.facets({ facets: ['a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }), ).toEqual({ facets: { a: [] }, @@ -492,11 +505,14 @@ describe('AuthorizedEntitiesCatalog', () => { ]); const catalog = createCatalog(isEntityKind); - await catalog.facets({ facets: ['a'], authorizationToken: 'abcd' }); + await catalog.facets({ + facets: ['a'], + credentials: mockCredentials.none(), + }); expect(fakeCatalog.facets).toHaveBeenCalledWith({ facets: ['a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), filter: { key: 'kind', values: ['b'] }, }); }); @@ -507,11 +523,14 @@ describe('AuthorizedEntitiesCatalog', () => { ]); const catalog = createCatalog(); - await catalog.facets({ facets: ['a'], authorizationToken: 'abcd' }); + await catalog.facets({ + facets: ['a'], + credentials: mockCredentials.none(), + }); expect(fakeCatalog.facets).toHaveBeenCalledWith({ facets: ['a'], - authorizationToken: 'abcd', + credentials: mockCredentials.none(), }); }); }); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index f302a2b75a..5358e90825 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -20,10 +20,7 @@ import { catalogEntityReadPermission, } from '@backstage/plugin-catalog-common/alpha'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; -import { - AuthorizeResult, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ConditionTransformer } from '@backstage/plugin-permission-node'; import { Cursor, @@ -41,19 +38,23 @@ import { import { basicEntityFilter } from './request'; import { isQueryEntitiesCursorRequest } from './util'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; export class AuthorizedEntitiesCatalog implements EntitiesCatalog { constructor( private readonly entitiesCatalog: EntitiesCatalog, - private readonly permissionApi: PermissionEvaluator, + private readonly permissionApi: PermissionsService, private readonly transformConditions: ConditionTransformer, ) {} - async entities(request?: EntitiesRequest): Promise { + async entities(request: EntitiesRequest): Promise { const authorizeDecision = ( await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], - { token: request?.authorizationToken }, + { credentials: request.credentials }, ) )[0]; @@ -85,7 +86,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const authorizeDecision = ( await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], - { token: request?.authorizationToken }, + { credentials: request.credentials }, ) )[0]; @@ -116,7 +117,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const authorizeDecision = ( await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], - { token: request.authorizationToken }, + { credentials: request.credentials }, ) )[0]; @@ -186,12 +187,12 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async removeEntityByUid( uid: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise { const authorizeResponse = ( await this.permissionApi.authorizeConditional( [{ permission: catalogEntityDeletePermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; if (authorizeResponse.result === AuthorizeResult.DENY) { @@ -202,6 +203,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { authorizeResponse.conditions, ); const { entities } = await this.entitiesCatalog.entities({ + credentials: options.credentials, filter: { allOf: [permissionFilter, basicEntityFilter({ 'metadata.uid': uid })], }, @@ -210,30 +212,35 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { throw new NotAllowedError(); } } - return this.entitiesCatalog.removeEntityByUid(uid); + return this.entitiesCatalog.removeEntityByUid(uid, { + credentials: options.credentials, + }); } async entityAncestry( entityRef: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise { const rootEntityAuthorizeResponse = ( await this.permissionApi.authorize( [{ permission: catalogEntityReadPermission, resourceRef: entityRef }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; if (rootEntityAuthorizeResponse.result === AuthorizeResult.DENY) { throw new NotAllowedError(); } - const ancestryResult = await this.entitiesCatalog.entityAncestry(entityRef); + const ancestryResult = await this.entitiesCatalog.entityAncestry( + entityRef, + { credentials: options.credentials }, + ); const authorizeResponse = await this.permissionApi.authorize( ancestryResult.items.map(item => ({ permission: catalogEntityReadPermission, resourceRef: stringifyEntityRef(item.entity), })), - { token: options?.authorizationToken }, + { credentials: options.credentials }, ); const unauthorizedAncestryItems = ancestryResult.items.filter( (_, index) => authorizeResponse[index].result === AuthorizeResult.DENY, @@ -268,7 +275,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const authorizeDecision = ( await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], - { token: request?.authorizationToken }, + { credentials: request.credentials }, ) )[0]; diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index c2ffee8003..2eae1c7553 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -17,6 +17,7 @@ import { NotAllowedError, NotFoundError } from '@backstage/errors'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { AuthorizedLocationService } from './AuthorizedLocationService'; +import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedLocationService', () => { const fakeLocationService = { @@ -45,6 +46,10 @@ describe('AuthorizedLocationService', () => { const createService = () => new AuthorizedLocationService(fakeLocationService, fakePermissionApi); + const mockOptions = { + credentials: mockCredentials.none(), + }; + afterEach(() => { jest.resetAllMocks(); }); @@ -55,13 +60,12 @@ describe('AuthorizedLocationService', () => { const service = createService(); const spec = { type: 'type', target: 'target' }; - await service.createLocation(spec, false, { - authorizationToken: 'Bearer authtoken', - }); + await service.createLocation(spec, false, mockOptions); expect(fakeLocationService.createLocation).toHaveBeenCalledWith( spec, false, + mockOptions, ); }); @@ -71,9 +75,7 @@ describe('AuthorizedLocationService', () => { const spec = { type: 'type', target: 'target' }; await expect(() => - service.createLocation(spec, false, { - authorizationToken: 'Bearer authtoken', - }), + service.createLocation(spec, false, mockOptions), ).rejects.toThrow(NotAllowedError); }); }); @@ -83,7 +85,7 @@ describe('AuthorizedLocationService', () => { mockAllow(); const service = createService(); - await service.listLocations({ authorizationToken: 'Bearer authtoken' }); + await service.listLocations(mockOptions); expect(fakeLocationService.listLocations).toHaveBeenCalled(); }); @@ -92,9 +94,7 @@ describe('AuthorizedLocationService', () => { mockDeny(); const service = createService(); - const locations = await service.listLocations({ - authorizationToken: 'Bearer authtoken', - }); + const locations = await service.listLocations(mockOptions); expect(locations).toEqual([]); }); @@ -105,11 +105,12 @@ describe('AuthorizedLocationService', () => { mockAllow(); const service = createService(); - await service.getLocation('id', { - authorizationToken: 'Bearer authtoken', - }); + await service.getLocation('id', mockOptions); - expect(fakeLocationService.getLocation).toHaveBeenCalledWith('id'); + expect(fakeLocationService.getLocation).toHaveBeenCalledWith( + 'id', + mockOptions, + ); }); it('throws error on DENY', async () => { @@ -117,7 +118,7 @@ describe('AuthorizedLocationService', () => { const service = createService(); await expect(() => - service.getLocation('id', { authorizationToken: 'Bearer authtoken' }), + service.getLocation('id', mockOptions), ).rejects.toThrow(NotFoundError); }); }); @@ -127,11 +128,12 @@ describe('AuthorizedLocationService', () => { mockAllow(); const service = createService(); - await service.deleteLocation('id', { - authorizationToken: 'Bearer authtoken', - }); + await service.deleteLocation('id', mockOptions); - expect(fakeLocationService.deleteLocation).toHaveBeenCalledWith('id'); + expect(fakeLocationService.deleteLocation).toHaveBeenCalledWith( + 'id', + mockOptions, + ); }); it('throws error on DENY', async () => { @@ -139,9 +141,7 @@ describe('AuthorizedLocationService', () => { const service = createService(); await expect(() => - service.deleteLocation('id', { - authorizationToken: 'Bearer authtoken', - }), + service.deleteLocation('id', mockOptions), ).rejects.toThrow(NotAllowedError); }); }); @@ -153,16 +153,17 @@ describe('AuthorizedLocationService', () => { await service.getLocationByEntity( { kind: 'c', namespace: 'ns', name: 'n' }, - { - authorizationToken: 'Bearer authtoken', - }, + mockOptions, ); - expect(fakeLocationService.getLocationByEntity).toHaveBeenCalledWith({ - kind: 'c', - namespace: 'ns', - name: 'n', - }); + expect(fakeLocationService.getLocationByEntity).toHaveBeenCalledWith( + { + kind: 'c', + namespace: 'ns', + name: 'n', + }, + mockOptions, + ); }); it('throws error on DENY', async () => { @@ -172,7 +173,7 @@ describe('AuthorizedLocationService', () => { await expect(() => service.getLocationByEntity( { kind: 'c', namespace: 'ns', name: 'n' }, - { authorizationToken: 'Bearer authtoken' }, + mockOptions, ), ).rejects.toThrow(NotFoundError); }); diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 0b9d50b20e..3eb3eede12 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -22,23 +22,24 @@ import { catalogLocationDeletePermission, catalogLocationReadPermission, } from '@backstage/plugin-catalog-common/alpha'; -import { - AuthorizeResult, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { LocationInput, LocationService } from './types'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; export class AuthorizedLocationService implements LocationService { constructor( private readonly locationService: LocationService, - private readonly permissionApi: PermissionEvaluator, + private readonly permissionApi: PermissionsService, ) {} async createLocation( spec: LocationInput, dryRun: boolean, - options?: { - authorizationToken?: string; + options: { + credentials: BackstageCredentials; }, ): Promise<{ location: Location; @@ -48,7 +49,7 @@ export class AuthorizedLocationService implements LocationService { const authorizationResponse = ( await this.permissionApi.authorize( [{ permission: catalogLocationCreatePermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; @@ -56,16 +57,16 @@ export class AuthorizedLocationService implements LocationService { throw new NotAllowedError(); } - return this.locationService.createLocation(spec, dryRun); + return this.locationService.createLocation(spec, dryRun, options); } - async listLocations(options?: { - authorizationToken?: string; + async listLocations(options: { + credentials: BackstageCredentials; }): Promise { const authorizationResponse = ( await this.permissionApi.authorize( [{ permission: catalogLocationReadPermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; @@ -73,17 +74,17 @@ export class AuthorizedLocationService implements LocationService { return []; } - return this.locationService.listLocations(); + return this.locationService.listLocations(options); } async getLocation( id: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise { const authorizationResponse = ( await this.permissionApi.authorize( [{ permission: catalogLocationReadPermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; @@ -91,17 +92,17 @@ export class AuthorizedLocationService implements LocationService { throw new NotFoundError(`Found no location with ID ${id}`); } - return this.locationService.getLocation(id); + return this.locationService.getLocation(id, options); } async deleteLocation( id: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise { const authorizationResponse = ( await this.permissionApi.authorize( [{ permission: catalogLocationDeletePermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; @@ -109,23 +110,23 @@ export class AuthorizedLocationService implements LocationService { throw new NotAllowedError(); } - return this.locationService.deleteLocation(id); + return this.locationService.deleteLocation(id, options); } async getLocationByEntity( entityRef: CompoundEntityRef | string, - options?: { authorizationToken?: string | undefined } | undefined, + options: { credentials: BackstageCredentials }, ): Promise { const authorizationResponse = ( await this.permissionApi.authorize( [{ permission: catalogLocationReadPermission }], - { token: options?.authorizationToken }, + { credentials: options.credentials }, ) )[0]; if (authorizationResponse.result === AuthorizeResult.DENY) { throw new NotFoundError(); } - return this.locationService.getLocationByEntity(entityRef); + return this.locationService.getLocationByEntity(entityRef, options); } } diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts index f37bd0ec49..ba9ef2db73 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts @@ -18,6 +18,7 @@ import { NotAllowedError } from '@backstage/errors'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { AuthorizedRefreshService } from './AuthorizedRefreshService'; +import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedRefreshService', () => { const refreshService = { @@ -46,7 +47,7 @@ describe('AuthorizedRefreshService', () => { await expect(() => authorizedService.refresh({ entityRef: 'some entity ref', - authorizationToken: 'some auth token', + credentials: mockCredentials.none(), }), ).rejects.toThrow(NotAllowedError); }); @@ -64,7 +65,7 @@ describe('AuthorizedRefreshService', () => { const options = { entityRef: 'some entity ref', - authorizationToken: 'some auth token', + credentials: mockCredentials.none(), }; await authorizedService.refresh(options); diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts index cbc728750a..a5a2491a04 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts @@ -16,16 +16,14 @@ import { NotAllowedError } from '@backstage/errors'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; -import { - AuthorizeResult, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { RefreshOptions, RefreshService } from './types'; +import { PermissionsService } from '@backstage/backend-plugin-api'; export class AuthorizedRefreshService implements RefreshService { constructor( private readonly service: RefreshService, - private readonly permissionApi: PermissionEvaluator, + private readonly permissionApi: PermissionsService, ) {} async refresh(options: RefreshOptions) { @@ -37,7 +35,7 @@ export class AuthorizedRefreshService implements RefreshService { resourceRef: options.entityRef, }, ], - { token: options.authorizationToken }, + { credentials: options.credentials }, ) )[0]; if (authorizeDecision.result !== AuthorizeResult.ALLOW) { diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 0abaf2ec0a..7fcee231fd 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + HostDiscovery, + UrlReader, + createLegacyAuthAdapters, +} from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { DefaultNamespaceEntityPolicy, @@ -84,7 +89,6 @@ import { permissionRules as catalogPermissionRules } from '../permissions/rules' import { PermissionRule } from '@backstage/plugin-permission-node'; import { PermissionAuthorizer, - PermissionEvaluator, toPermissionEvaluator, } from '@backstage/plugin-permission-common'; import { @@ -102,6 +106,12 @@ import { DefaultProviderDatabase } from '../database/DefaultProviderDatabase'; import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase'; import { EventBroker } from '@backstage/plugin-events-node'; import { durationToMilliseconds } from '@backstage/types'; +import { + DiscoveryService, + AuthService, + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; /** * This is a duplicate of the alpha `CatalogPermissionRule` type, for use in the stable API. @@ -118,8 +128,11 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionEvaluator | PermissionAuthorizer; + permissions: PermissionsService | PermissionAuthorizer; scheduler?: PluginTaskScheduler; + discovery?: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; }; /** @@ -438,7 +451,19 @@ export class CatalogBuilder { processingEngine: CatalogProcessingEngine; router: Router; }> { - const { config, database, logger, permissions, scheduler } = this.env; + const { + config, + database, + logger, + permissions, + scheduler, + discovery = HostDiscovery.fromConfig(config), + } = this.env; + + const { auth, httpAuth } = createLegacyAuthAdapters({ + ...this.env, + discovery, + }); const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); @@ -486,25 +511,26 @@ export class CatalogBuilder { stitcher, }); - let permissionEvaluator: PermissionEvaluator; + let permissionsService: PermissionsService; if ('authorizeConditional' in permissions) { - permissionEvaluator = permissions as PermissionEvaluator; + permissionsService = permissions as PermissionsService; } else { logger.warn( 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', ); - permissionEvaluator = toPermissionEvaluator(permissions); + permissionsService = toPermissionEvaluator(permissions); } const entitiesCatalog = new AuthorizedEntitiesCatalog( unauthorizedEntitiesCatalog, - permissionEvaluator, + permissionsService, createConditionTransformer(this.permissionRules), ); const permissionIntegrationRouter = createPermissionIntegrationRouter({ resourceType: RESOURCE_TYPE_CATALOG_ENTITY, getResources: async (resourceRefs: string[]) => { const { entities } = await unauthorizedEntitiesCatalog.entities({ + credentials: await auth.getOwnServiceCredentials(), filter: { anyOf: resourceRefs.map(resourceRef => { const { kind, namespace, name } = parseEntityRef(resourceRef); @@ -558,12 +584,13 @@ export class CatalogBuilder { new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, }), - permissionEvaluator, + permissionsService, ); const refreshService = new AuthorizedRefreshService( new DefaultRefreshService({ database: catalogDatabase }), - permissionEvaluator, + permissionsService, ); + const router = await createRouter({ entitiesCatalog, locationAnalyzer, @@ -573,6 +600,8 @@ export class CatalogBuilder { logger, config, permissionIntegrationRouter, + auth, + httpAuth, }); await connectEntityProviders(providerDatabase, entityProviders); diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 255df84d58..87e4316c11 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -178,6 +178,9 @@ export const catalogPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, lifecycle: coreServices.lifecycle, scheduler: coreServices.scheduler, + discovery: coreServices.discovery, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, }, async init({ logger, @@ -188,6 +191,9 @@ export const catalogPlugin = createBackendPlugin({ httpRouter, lifecycle, scheduler, + discovery, + auth, + httpAuth, }) { const winstonLogger = loggerToWinstonLogger(logger); const builder = await CatalogBuilder.create({ @@ -197,6 +203,9 @@ export const catalogPlugin = createBackendPlugin({ database, scheduler, logger: winstonLogger, + discovery, + auth, + httpAuth, }); if (processingExtensions.onProcessingErrorHandler) { builder.subscribe({ diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index fe7d57c89a..4d8dc7c7a4 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -15,7 +15,11 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { + TestDatabaseId, + TestDatabases, + mockCredentials, +} from '@backstage/backend-test-utils'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Knex } from 'knex'; import { v4 as uuid, v4 } from 'uuid'; @@ -304,8 +308,10 @@ describe('DefaultEntitiesCatalog', () => { const testFilter = { key: 'spec.test', }; - const request = { filter: testFilter }; - const { entities } = await catalog.entities(request); + const { entities } = await catalog.entities({ + filter: testFilter, + credentials: mockCredentials.none(), + }); expect(entities.length).toBe(1); expect(entities[0]).toEqual(entity2); @@ -343,8 +349,10 @@ describe('DefaultEntitiesCatalog', () => { key: 'spec.test', }, }; - const request = { filter: testFilter }; - const { entities } = await catalog.entities(request); + const { entities } = await catalog.entities({ + filter: testFilter, + credentials: mockCredentials.none(), + }); expect(entities.length).toBe(1); expect(entities[0]).toEqual(entity1); @@ -406,7 +414,7 @@ describe('DefaultEntitiesCatalog', () => { values: ['red'], }, }; - const request = { + const { entities } = await catalog.entities({ filter: { allOf: [ testFilter1, @@ -415,8 +423,8 @@ describe('DefaultEntitiesCatalog', () => { }, ], }, - }; - const { entities } = await catalog.entities(request); + credentials: mockCredentials.none(), + }); expect(entities.length).toBe(2); expect(entities).toContainEqual(entity2); @@ -455,14 +463,15 @@ describe('DefaultEntitiesCatalog', () => { const testFilter2 = { key: 'metadata.desc', }; - const request = { + const { entities } = await catalog.entities({ filter: { not: { allOf: [testFilter1, testFilter2], }, }, - }; - const { entities } = await catalog.entities(request); + + credentials: mockCredentials.none(), + }); expect(entities.length).toBe(1); expect(entities).toContainEqual(entity1); @@ -498,8 +507,10 @@ describe('DefaultEntitiesCatalog', () => { key: 'kind', values: [], }; - const request = { filter: testFilter }; - const { entities } = await catalog.entities(request); + const { entities } = await catalog.entities({ + filter: testFilter, + credentials: mockCredentials.none(), + }); expect(entities.length).toBe(0); }, @@ -603,9 +614,11 @@ describe('DefaultEntitiesCatalog', () => { stitcher, }); - function f(request: EntitiesRequest): Promise { + function f( + request: Omit, + ): Promise { return catalog - .entities(request) + .entities({ ...request, credentials: mockCredentials.none() }) .then(response => response.entities.map(e => e.metadata.name)); } @@ -701,6 +714,7 @@ describe('DefaultEntitiesCatalog', () => { 'k:default/does-not-exist', 'k:default/two', ], + credentials: mockCredentials.none(), }); expect(items.map(e => e && stringifyEntityRef(e))).toEqual([ @@ -749,6 +763,7 @@ describe('DefaultEntitiesCatalog', () => { const { items } = await catalog.entitiesBatch({ entityRefs: ['k:default/two', 'k:default/one'], filter: { key: 'spec.owner', values: ['me'] }, + credentials: mockCredentials.none(), }); expect(items.map(e => e && stringifyEntityRef(e))).toEqual([ @@ -804,6 +819,7 @@ describe('DefaultEntitiesCatalog', () => { filter, limit, orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toEqual([entityFrom('A'), entityFrom('B')]); @@ -815,6 +831,7 @@ describe('DefaultEntitiesCatalog', () => { const request2: QueryEntitiesCursorRequest = { cursor: response1.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toEqual([entityFrom('C'), entityFrom('D')]); @@ -826,6 +843,7 @@ describe('DefaultEntitiesCatalog', () => { const request3: QueryEntitiesCursorRequest = { cursor: response2.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response3 = await catalog.queryEntities(request3); expect(response3.items).toEqual([entityFrom('E'), entityFrom('F')]); @@ -837,6 +855,7 @@ describe('DefaultEntitiesCatalog', () => { const request4: QueryEntitiesCursorRequest = { cursor: response3.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response4 = await catalog.queryEntities(request4); expect(response4.items).toEqual([entityFrom('C'), entityFrom('D')]); @@ -848,6 +867,7 @@ describe('DefaultEntitiesCatalog', () => { const request5: QueryEntitiesCursorRequest = { cursor: response4.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response5 = await catalog.queryEntities(request5); expect(response5.items).toEqual([entityFrom('A'), entityFrom('B')]); @@ -859,6 +879,7 @@ describe('DefaultEntitiesCatalog', () => { const request6: QueryEntitiesCursorRequest = { cursor: response5.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response6 = await catalog.queryEntities(request6); expect(response6.items).toEqual([entityFrom('C'), entityFrom('D')]); @@ -870,6 +891,7 @@ describe('DefaultEntitiesCatalog', () => { const request7: QueryEntitiesCursorRequest = { cursor: response6.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response7 = await catalog.queryEntities(request7); expect(response7.items).toEqual([entityFrom('E'), entityFrom('F')]); @@ -881,6 +903,7 @@ describe('DefaultEntitiesCatalog', () => { const request7bis: QueryEntitiesCursorRequest = { cursor: response6.pageInfo.nextCursor!, limit: limit + 1, + credentials: mockCredentials.none(), }; const response7bis = await catalog.queryEntities(request7bis); expect(response7bis.items).toEqual([ @@ -896,6 +919,7 @@ describe('DefaultEntitiesCatalog', () => { const request8: QueryEntitiesCursorRequest = { cursor: response7.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response8 = await catalog.queryEntities(request8); expect(response8.items).toEqual([entityFrom('G')]); @@ -949,6 +973,7 @@ describe('DefaultEntitiesCatalog', () => { filter, limit, orderFields: [{ field: 'metadata.name', order: 'desc' }], + credentials: mockCredentials.none(), }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toEqual([entityFrom('G'), entityFrom('F')]); @@ -960,6 +985,7 @@ describe('DefaultEntitiesCatalog', () => { const request2: QueryEntitiesCursorRequest = { cursor: response1.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toEqual([entityFrom('E'), entityFrom('D')]); @@ -971,6 +997,7 @@ describe('DefaultEntitiesCatalog', () => { const request3: QueryEntitiesCursorRequest = { cursor: response2.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response3 = await catalog.queryEntities(request3); expect(response3.items).toEqual([entityFrom('C'), entityFrom('B')]); @@ -982,6 +1009,7 @@ describe('DefaultEntitiesCatalog', () => { const request4: QueryEntitiesCursorRequest = { cursor: response3.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response4 = await catalog.queryEntities(request4); @@ -994,6 +1022,7 @@ describe('DefaultEntitiesCatalog', () => { const request5: QueryEntitiesCursorRequest = { cursor: response4.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response5 = await catalog.queryEntities(request5); expect(response5.items).toEqual([entityFrom('G'), entityFrom('F')]); @@ -1005,6 +1034,7 @@ describe('DefaultEntitiesCatalog', () => { const request6: QueryEntitiesCursorRequest = { cursor: response5.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response6 = await catalog.queryEntities(request6); expect(response6.items).toEqual([entityFrom('E'), entityFrom('D')]); @@ -1016,6 +1046,7 @@ describe('DefaultEntitiesCatalog', () => { const request7: QueryEntitiesCursorRequest = { cursor: response6.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response7 = await catalog.queryEntities(request7); expect(response7.items).toEqual([entityFrom('C'), entityFrom('B')]); @@ -1027,6 +1058,7 @@ describe('DefaultEntitiesCatalog', () => { const request7bis: QueryEntitiesCursorRequest = { cursor: response6.pageInfo.nextCursor!, limit: limit + 1, + credentials: mockCredentials.none(), }; const response7bis = await catalog.queryEntities(request7bis); expect(response7bis.items).toEqual([ @@ -1042,6 +1074,7 @@ describe('DefaultEntitiesCatalog', () => { const request8: QueryEntitiesCursorRequest = { cursor: response7.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response8 = await catalog.queryEntities(request8); expect(response8.items).toEqual([entityFrom('A')]); @@ -1094,6 +1127,7 @@ describe('DefaultEntitiesCatalog', () => { orderFields: [{ field: 'metadata.name', order: 'asc' }], fullTextFilter: { term: 'cAt ' }, + credentials: mockCredentials.none(), }; const response = await catalog.queryEntities(request); expect(response.items).toEqual([ @@ -1152,6 +1186,7 @@ describe('DefaultEntitiesCatalog', () => { filter, limit: 100, fullTextFilter: { term: 'cAt ', fields: ['metadata.title'] }, + credentials: mockCredentials.none(), }; const response = await catalog.queryEntities(request); expect(response.items).toEqual([ @@ -1177,6 +1212,7 @@ describe('DefaultEntitiesCatalog', () => { const paginatedResponseNext = await catalog.queryEntities({ cursor: paginatedResponse.pageInfo.nextCursor!, + credentials: mockCredentials.none(), }); expect(paginatedResponseNext.items).toEqual([ entityFrom('4', { uid: 'id4', title: 'dogcat' }), @@ -1187,6 +1223,7 @@ describe('DefaultEntitiesCatalog', () => { const paginatedResponsePrev = await catalog.queryEntities({ cursor: paginatedResponseNext.pageInfo.prevCursor!, + credentials: mockCredentials.none(), }); expect(paginatedResponsePrev).toMatchObject(paginatedResponse); }, @@ -1251,6 +1288,7 @@ describe('DefaultEntitiesCatalog', () => { term: 'KiNg ', fields: ['metadata.title', 'metadata.name'], }, + credentials: mockCredentials.none(), }; const response = await catalog.queryEntities(request); @@ -1278,6 +1316,7 @@ describe('DefaultEntitiesCatalog', () => { const paginatedResponseNext = await catalog.queryEntities({ cursor: paginatedResponse.pageInfo.nextCursor!, + credentials: mockCredentials.none(), }); expect(paginatedResponseNext.items).toEqual([ entityFrom('NotACatKing', { uid: 'id2', title: 'atcatss' }), @@ -1289,6 +1328,7 @@ describe('DefaultEntitiesCatalog', () => { const paginatedResponsePrev = await catalog.queryEntities({ cursor: paginatedResponseNext.pageInfo.prevCursor!, + credentials: mockCredentials.none(), }); expect(paginatedResponsePrev).toMatchObject(paginatedResponse); }, @@ -1319,6 +1359,7 @@ describe('DefaultEntitiesCatalog', () => { const request: QueryEntitiesInitialRequest = { limit: 0, + credentials: mockCredentials.none(), }; const response = await catalog.queryEntities(request); expect(response).toEqual({ totalItems: 20, items: [], pageInfo: {} }); @@ -1351,6 +1392,7 @@ describe('DefaultEntitiesCatalog', () => { const request1: QueryEntitiesInitialRequest = { limit, orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toMatchObject([ @@ -1365,6 +1407,7 @@ describe('DefaultEntitiesCatalog', () => { const request2: QueryEntitiesCursorRequest = { cursor: response1.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toMatchObject([ @@ -1379,6 +1422,7 @@ describe('DefaultEntitiesCatalog', () => { const request3: QueryEntitiesCursorRequest = { cursor: response2.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response3 = await catalog.queryEntities(request3); expect(response3.items).toEqual([entityFrom('CC'), entityFrom('DD')]); @@ -1390,6 +1434,7 @@ describe('DefaultEntitiesCatalog', () => { const request4: QueryEntitiesCursorRequest = { cursor: response3.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response4 = await catalog.queryEntities(request4); expect(response4.items).toMatchObject([ @@ -1404,6 +1449,7 @@ describe('DefaultEntitiesCatalog', () => { const request5: QueryEntitiesCursorRequest = { cursor: response4.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response5 = await catalog.queryEntities(request5); expect(response5.items).toMatchObject([ @@ -1471,6 +1517,7 @@ describe('DefaultEntitiesCatalog', () => { values: ['included'], }, orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toMatchObject([ @@ -1485,6 +1532,7 @@ describe('DefaultEntitiesCatalog', () => { const request2: QueryEntitiesCursorRequest = { cursor: response1.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toMatchObject([ @@ -1528,6 +1576,7 @@ describe('DefaultEntitiesCatalog', () => { // initial request const request1: QueryEntitiesInitialRequest = { limit, + credentials: mockCredentials.none(), }; const response1 = await catalog.queryEntities(request1); expect(response1.items).toMatchObject([ @@ -1542,6 +1591,7 @@ describe('DefaultEntitiesCatalog', () => { const request2: QueryEntitiesCursorRequest = { cursor: response1.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response2 = await catalog.queryEntities(request2); expect(response2.items).toMatchObject([ @@ -1556,6 +1606,7 @@ describe('DefaultEntitiesCatalog', () => { const request3: QueryEntitiesCursorRequest = { cursor: response2.pageInfo.nextCursor!, limit, + credentials: mockCredentials.none(), }; const response3 = await catalog.queryEntities(request3); expect(response3.items).toMatchObject([ @@ -1570,6 +1621,7 @@ describe('DefaultEntitiesCatalog', () => { const request4: QueryEntitiesCursorRequest = { cursor: response3.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response4 = await catalog.queryEntities(request4); expect(response4.items).toMatchObject([ @@ -1584,6 +1636,7 @@ describe('DefaultEntitiesCatalog', () => { const request5: QueryEntitiesCursorRequest = { cursor: response4.pageInfo.prevCursor!, limit, + credentials: mockCredentials.none(), }; const response5 = await catalog.queryEntities(request5); expect(response5.items).toMatchObject([ @@ -1719,7 +1772,12 @@ describe('DefaultEntitiesCatalog', () => { stitcher, }); - await expect(catalog.facets({ facets: ['kind'] })).resolves.toEqual({ + await expect( + catalog.facets({ + facets: ['kind'], + credentials: mockCredentials.none(), + }), + ).resolves.toEqual({ facets: { kind: [ { value: 'k', count: 2 }, @@ -1732,6 +1790,7 @@ describe('DefaultEntitiesCatalog', () => { catalog.facets({ facets: ['kind'], filter: { not: { key: 'metadata.name', values: ['two'] } }, + credentials: mockCredentials.none(), }), ).resolves.toEqual({ facets: { @@ -1775,6 +1834,7 @@ describe('DefaultEntitiesCatalog', () => { await expect( catalog.facets({ facets: ['metadata.annotations.a.b/c.d', 'metadata.labels.e.f/g.h'], + credentials: mockCredentials.none(), }), ).resolves.toEqual({ facets: { @@ -1823,6 +1883,7 @@ describe('DefaultEntitiesCatalog', () => { await expect( catalog.facets({ facets: ['metadata.tags'], + credentials: mockCredentials.none(), }), ).resolves.toEqual({ facets: { diff --git a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts index e1b775cce6..9ca9ae0a5f 100644 --- a/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultRefreshService.test.ts @@ -15,7 +15,11 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { + TestDatabaseId, + TestDatabases, + mockCredentials, +} from '@backstage/backend-test-utils'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { createHash } from 'crypto'; import { Knex } from 'knex'; @@ -220,6 +224,7 @@ describe('DefaultRefreshService', () => { await refreshService.refresh({ entityRef: 'component:default/mycomp', + credentials: mockCredentials.none(), }); await expect( @@ -273,6 +278,7 @@ describe('DefaultRefreshService', () => { await refreshService.refresh({ entityRef: 'api:default/myapi', + credentials: mockCredentials.none(), }); await expect(waitForRefresh(knex, 'api:default/myapi')).resolves.toBe( @@ -324,6 +330,7 @@ describe('DefaultRefreshService', () => { await refreshService.refresh({ entityRef: 'component:default/mycomp', + credentials: mockCredentials.none(), }); await expect( @@ -334,6 +341,7 @@ describe('DefaultRefreshService', () => { await refreshService.refresh({ entityRef: 'component:default/mycomp', + credentials: mockCredentials.none(), }); await expect( diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 3eb00c5bd6..5177f3b42b 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -41,6 +41,7 @@ import { z } from 'zod'; import { decodeCursor, encodeCursor } from './util'; import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; describe('createRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; @@ -75,6 +76,8 @@ describe('createRouter readonly disabled', () => { refreshService, config: new ConfigReader(undefined), permissionIntegrationRouter: express.Router(), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), }); app = wrapInOpenApiTestServer(express().use(router)); }); @@ -88,15 +91,30 @@ describe('createRouter readonly disabled', () => { const response = await request(app) .post('/refresh') .set('Content-Type', 'application/json') - .set('authorization', 'Bearer someauthtoken') .send({ entityRef: 'Component/default:foo' }); expect(response.status).toBe(200); expect(refreshService.refresh).toHaveBeenCalledWith({ entityRef: 'Component/default:foo', - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), + }); + }); + + it('should support passing the token in the request body for backwards compatibility', async () => { + const response = await request(app) + .post('/refresh') + .set('Content-Type', 'application/json') + .send({ + entityRef: 'Component/default:foo', + authorizationToken: mockCredentials.user.token('user:default/other'), + }); + expect(response.status).toBe(200); + expect(refreshService.refresh).toHaveBeenCalledWith({ + entityRef: 'Component/default:foo', + credentials: mockCredentials.user('user:default/other'), }); }); }); + describe('GET /entities', () => { it('happy path: lists entities', async () => { const entities: Entity[] = [ @@ -137,6 +155,7 @@ describe('createRouter readonly disabled', () => { { allOf: [{ key: 'c', values: ['4'] }] }, ], }, + credentials: mockCredentials.user(), }); }); }); @@ -196,6 +215,7 @@ describe('createRouter readonly disabled', () => { fields: undefined, term: '', }, + credentials: mockCredentials.user(), }); }); @@ -235,6 +255,7 @@ describe('createRouter readonly disabled', () => { fields: undefined, term: '', }, + credentials: mockCredentials.user(), }); }); @@ -257,6 +278,7 @@ describe('createRouter readonly disabled', () => { expect(entitiesCatalog.queryEntities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith({ cursor, + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual({ @@ -291,6 +313,7 @@ describe('createRouter readonly disabled', () => { expect(entitiesCatalog.queryEntities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith({ cursor, + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual({ @@ -370,6 +393,7 @@ describe('createRouter readonly disabled', () => { expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.entities).toHaveBeenCalledWith({ filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); @@ -386,6 +410,7 @@ describe('createRouter readonly disabled', () => { expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.entities).toHaveBeenCalledWith({ filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + credentials: mockCredentials.user(), }); expect(response.status).toEqual(404); expect(response.text).toMatch(/uid/); @@ -416,6 +441,7 @@ describe('createRouter readonly disabled', () => { 'metadata.namespace': 'ns', 'metadata.name': 'n', }), + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); @@ -436,6 +462,7 @@ describe('createRouter readonly disabled', () => { 'metadata.namespace': 'd', 'metadata.name': 'c', }), + credentials: mockCredentials.user(), }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); @@ -446,13 +473,10 @@ describe('createRouter readonly disabled', () => { it('can remove', async () => { entitiesCatalog.removeEntityByUid.mockResolvedValue(undefined); - const response = await request(app) - .delete('/entities/by-uid/apa') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).delete('/entities/by-uid/apa'); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(204); }); @@ -462,13 +486,10 @@ describe('createRouter readonly disabled', () => { new NotFoundError('nope'), ); - const response = await request(app) - .delete('/entities/by-uid/apa') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).delete('/entities/by-uid/apa'); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(404); }); @@ -518,6 +539,7 @@ describe('createRouter readonly disabled', () => { expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledWith({ entityRefs: [entityRef], fields: expect.any(Function), + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual({ items: [entity] }); @@ -531,13 +553,10 @@ describe('createRouter readonly disabled', () => { ]; locationService.listLocations.mockResolvedValueOnce(locations); - const response = await request(app) - .get('/locations') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations'); expect(locationService.listLocations).toHaveBeenCalledTimes(1); expect(locationService.listLocations).toHaveBeenCalledWith({ - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); expect(response.body).toEqual([ @@ -555,13 +574,10 @@ describe('createRouter readonly disabled', () => { }; locationService.getLocation.mockResolvedValueOnce(location); - const response = await request(app) - .get('/locations/foo') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations/foo'); expect(locationService.getLocation).toHaveBeenCalledTimes(1); expect(locationService.getLocation).toHaveBeenCalledWith('foo', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); @@ -582,7 +598,7 @@ describe('createRouter readonly disabled', () => { const response = await request(app) .post('/locations') - .set('authorization', 'Bearer someauthtoken') + .send(spec); expect(locationService.createLocation).not.toHaveBeenCalled(); @@ -602,12 +618,12 @@ describe('createRouter readonly disabled', () => { const response = await request(app) .post('/locations') - .set('authorization', 'Bearer someauthtoken') + .send(spec); expect(locationService.createLocation).toHaveBeenCalledTimes(1); expect(locationService.createLocation).toHaveBeenCalledWith(spec, false, { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(201); expect(response.body).toEqual( @@ -630,12 +646,12 @@ describe('createRouter readonly disabled', () => { const response = await request(app) .post('/locations?dryRun=true') - .set('authorization', 'Bearer someauthtoken') + .send(spec); expect(locationService.createLocation).toHaveBeenCalledTimes(1); expect(locationService.createLocation).toHaveBeenCalledWith(spec, true, { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(201); expect(response.body).toEqual( @@ -650,13 +666,10 @@ describe('createRouter readonly disabled', () => { it('deletes the location', async () => { locationService.deleteLocation.mockResolvedValueOnce(undefined); - const response = await request(app) - .delete('/locations/foo') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).delete('/locations/foo'); expect(locationService.deleteLocation).toHaveBeenCalledTimes(1); expect(locationService.deleteLocation).toHaveBeenCalledWith('foo', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(204); @@ -672,15 +685,12 @@ describe('createRouter readonly disabled', () => { }; locationService.getLocationByEntity.mockResolvedValueOnce(location); - const response = await request(app) - .get('/locations/by-entity/c/ns/n') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations/by-entity/c/ns/n'); expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); expect(locationService.getLocationByEntity).toHaveBeenCalledWith( { kind: 'c', namespace: 'ns', name: 'n' }, { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }, ); @@ -837,6 +847,8 @@ describe('createRouter readonly enabled', () => { }, }), permissionIntegrationRouter: express.Router(), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), }); app = express().use(router); }); @@ -866,13 +878,10 @@ describe('createRouter readonly enabled', () => { describe('DELETE /entities/by-uid/:uid', () => { // this delete is allowed as there is no other way to remove entities it('is allowed', async () => { - const response = await request(app) - .delete('/entities/by-uid/apa') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).delete('/entities/by-uid/apa'); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(204); }); @@ -885,13 +894,10 @@ describe('createRouter readonly enabled', () => { ]; locationService.listLocations.mockResolvedValueOnce(locations); - const response = await request(app) - .get('/locations') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations'); expect(locationService.listLocations).toHaveBeenCalledTimes(1); expect(locationService.listLocations).toHaveBeenCalledWith({ - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); @@ -910,13 +916,10 @@ describe('createRouter readonly enabled', () => { }; locationService.getLocation.mockResolvedValueOnce(location); - const response = await request(app) - .get('/locations/foo') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations/foo'); expect(locationService.getLocation).toHaveBeenCalledTimes(1); expect(locationService.getLocation).toHaveBeenCalledWith('foo', { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(200); @@ -937,7 +940,7 @@ describe('createRouter readonly enabled', () => { const response = await request(app) .post('/locations') - .set('authorization', 'Bearer someauthtoken') + .send(spec); expect(locationService.createLocation).not.toHaveBeenCalled(); @@ -958,12 +961,12 @@ describe('createRouter readonly enabled', () => { const response = await request(app) .post('/locations?dryRun=true') - .set('authorization', 'Bearer someauthtoken') + .send(spec); expect(locationService.createLocation).toHaveBeenCalledTimes(1); expect(locationService.createLocation).toHaveBeenCalledWith(spec, true, { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }); expect(response.status).toEqual(201); expect(response.body).toEqual( @@ -976,10 +979,7 @@ describe('createRouter readonly enabled', () => { describe('DELETE /locations', () => { it('is not allowed', async () => { - const response = await request(app) - .delete('/locations/foo') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).delete('/locations/foo'); expect(locationService.deleteLocation).not.toHaveBeenCalled(); expect(response.status).toEqual(403); }); @@ -994,15 +994,12 @@ describe('createRouter readonly enabled', () => { }; locationService.getLocationByEntity.mockResolvedValueOnce(location); - const response = await request(app) - .get('/locations/by-entity/c/ns/n') - .set('authorization', 'Bearer someauthtoken'); - + const response = await request(app).get('/locations/by-entity/c/ns/n'); expect(locationService.getLocationByEntity).toHaveBeenCalledTimes(1); expect(locationService.getLocationByEntity).toHaveBeenCalledWith( { kind: 'c', namespace: 'ns', name: 'n' }, { - authorizationToken: 'someauthtoken', + credentials: mockCredentials.user(), }, ); @@ -1065,6 +1062,8 @@ describe('NextRouter permissioning', () => { ), ), }), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), }); app = express().use(router); }); diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 2522966408..922782d75a 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -41,7 +41,7 @@ import { } from './request'; import { parseEntityFacetParams } from './request/parseEntityFacetParams'; import { parseEntityOrderParams } from './request/parseEntityOrderParams'; -import { LocationService, RefreshOptions, RefreshService } from './types'; +import { LocationService, RefreshService } from './types'; import { disallowReadonlyMode, encodeCursor, @@ -50,8 +50,8 @@ import { } from './util'; import { createOpenApiRouter } from '../schema/openapi.generated'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { parseEntityPaginationParams } from './request/parseEntityPaginationParams'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; /** * Options used by {@link createRouter}. @@ -68,6 +68,8 @@ export interface RouterOptions { logger: Logger; config: Config; permissionIntegrationRouter?: express.Router; + auth: AuthService; + httpAuth: HttpAuthService; } /** @@ -94,6 +96,8 @@ export async function createRouter( config, logger, permissionIntegrationRouter, + auth, + httpAuth, } = options; const readonlyEnabled = @@ -104,12 +108,16 @@ export async function createRouter( if (refreshService) { router.post('/refresh', async (req, res) => { - const refreshOptions: RefreshOptions = req.body; - refreshOptions.authorizationToken = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); + const { authorizationToken, ...restBody } = req.body; - await refreshService.refresh(refreshOptions); + const credentials = authorizationToken + ? await auth.authenticate(authorizationToken) + : await httpAuth.credentials(req); + + await refreshService.refresh({ + ...restBody, + credentials, + }); res.status(200).end(); }); } @@ -126,9 +134,7 @@ export async function createRouter( fields: parseEntityTransformParams(req.query), order: parseEntityOrderParams(req.query), pagination: parseEntityPaginationParams(req.query), - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); // Add a Link header to the next page @@ -147,9 +153,7 @@ export async function createRouter( await entitiesCatalog.queryEntities({ limit: req.query.limit, ...parseQueryEntitiesParams(req.query), - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.json({ @@ -169,9 +173,7 @@ export async function createRouter( const { uid } = req.params; const { entities } = await entitiesCatalog.entities({ filter: basicEntityFilter({ 'metadata.uid': uid }), - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); if (!entities.length) { throw new NotFoundError(`No entity with uid ${uid}`); @@ -181,9 +183,7 @@ export async function createRouter( .delete('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; await entitiesCatalog.removeEntityByUid(uid, { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(204).end(); }) @@ -195,9 +195,7 @@ export async function createRouter( 'metadata.namespace': namespace, 'metadata.name': name, }), - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); if (!entities.length) { throw new NotFoundError( @@ -212,22 +210,17 @@ export async function createRouter( const { kind, namespace, name } = req.params; const entityRef = stringifyEntityRef({ kind, namespace, name }); const response = await entitiesCatalog.entityAncestry(entityRef, { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(200).json(response); }, ) .post('/entities/by-refs', async (req, res) => { const request = entitiesBatchRequest(req); - const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); const response = await entitiesCatalog.entitiesBatch({ entityRefs: request.entityRefs, fields: parseEntityTransformParams(req.query, request.fields), - authorizationToken: token, + credentials: await httpAuth.credentials(req), }); res.status(200).json(response); }) @@ -235,9 +228,7 @@ export async function createRouter( const response = await entitiesCatalog.facets({ filter: parseEntityFilterParams(req.query), facets: parseEntityFacetParams(req.query), - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(200).json(response); }); @@ -256,17 +247,13 @@ export async function createRouter( } const output = await locationService.createLocation(location, dryRun, { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(201).json(output); }) .get('/locations', async (req, res) => { const locations = await locationService.listLocations({ - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(200).json(locations.map(l => ({ data: l }))); }) @@ -274,9 +261,7 @@ export async function createRouter( .get('/locations/:id', async (req, res) => { const { id } = req.params; const output = await locationService.getLocation(id, { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(200).json(output); }) @@ -285,9 +270,7 @@ export async function createRouter( const { id } = req.params; await locationService.deleteLocation(id, { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), + credentials: await httpAuth.credentials(req), }); res.status(204).end(); }) @@ -295,11 +278,7 @@ export async function createRouter( const { kind, namespace, name } = req.params; const output = await locationService.getLocationByEntity( { kind, namespace, name }, - { - authorizationToken: getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ), - }, + { credentials: await httpAuth.credentials(req) }, ); res.status(200).json(output); }); diff --git a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts index b1d5e8dd1f..919e97a1f7 100644 --- a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts +++ b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts @@ -28,12 +28,12 @@ import { internal } from '@backstage/backend-openapi-utils'; export function parseQueryEntitiesParams( params: internal.QuerySchema, -): Omit { +): Omit { const fields = parseEntityTransformParams(params); if (params.cursor) { const decodedCursor = decodeCursor(params.cursor); - const response: Omit = { + const response: Omit = { cursor: decodedCursor, fields, }; @@ -43,7 +43,7 @@ export function parseQueryEntitiesParams( const filter = parseEntityFilterParams(params); const orderFields = parseEntityOrderFieldParams(params); - const response: Omit = { + const response: Omit = { fields, filter, orderFields, diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index 878afe5662..30470e5af0 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -16,6 +16,7 @@ import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; /** * Holds the information required to create a new location in the catalog location store. @@ -35,22 +36,24 @@ export interface LocationService { createLocation( location: LocationInput, dryRun: boolean, - options?: { - authorizationToken?: string; + options: { + credentials: BackstageCredentials; }, ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }>; - listLocations(options?: { authorizationToken?: string }): Promise; + listLocations(options: { + credentials: BackstageCredentials; + }): Promise; getLocation( id: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise; deleteLocation( id: string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise; getLocationByEntity( entityRef: CompoundEntityRef | string, - options?: { authorizationToken?: string }, + options: { credentials: BackstageCredentials }, ): Promise; } @@ -62,7 +65,7 @@ export interface LocationService { export type RefreshOptions = { /** The reference to a single entity that should be refreshed */ entityRef: string; - authorizationToken?: string; + credentials: BackstageCredentials; }; /** From 56969b6e550ff48c5c9cefab40958b50f8dbb99e Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 18:30:14 +0100 Subject: [PATCH 092/176] feat(events): add new events service Signed-off-by: Patrick Jungermann --- .changeset/breezy-cycles-count.md | 39 ++++++ .../events-backend-test-utils/api-report.md | 20 +++- .../src/deprecated.ts | 19 +++ .../events-backend-test-utils/src/index.ts | 1 + .../src/testUtils/TestEventBroker.ts | 5 +- .../src/testUtils/TestEventPublisher.ts | 5 +- .../src/testUtils/TestEventSubscriber.ts | 5 +- .../src/testUtils/TestEventsService.ts | 48 ++++++++ .../src/testUtils/index.ts | 4 +- plugins/events-backend/api-report.md | 9 +- plugins/events-backend/src/deprecated.ts | 18 +++ plugins/events-backend/src/index.ts | 3 +- .../src/service/DefaultEventBroker.test.ts | 18 +-- .../src/service/DefaultEventBroker.ts | 50 ++++---- .../src/service/EventsBackend.ts | 1 + plugins/events-node/api-report-alpha.md | 6 +- plugins/events-node/api-report.md | 39 +++++- plugins/events-node/package.json | 1 + .../src/api/DefaultEventsService.test.ts | 111 ++++++++++++++++++ .../src/api/DefaultEventsService.ts | 104 ++++++++++++++++ plugins/events-node/src/api/EventBroker.ts | 1 + plugins/events-node/src/api/EventPublisher.ts | 4 + .../events-node/src/api/EventSubscriber.ts | 4 + plugins/events-node/src/api/EventsService.ts | 57 +++++++++ plugins/events-node/src/api/index.ts | 9 +- plugins/events-node/src/deprecated.ts | 19 +++ plugins/events-node/src/extensions.ts | 9 ++ plugins/events-node/src/index.ts | 2 + plugins/events-node/src/service.ts | 47 ++++++++ yarn.lock | 1 + 30 files changed, 600 insertions(+), 59 deletions(-) create mode 100644 .changeset/breezy-cycles-count.md create mode 100644 plugins/events-backend-test-utils/src/deprecated.ts create mode 100644 plugins/events-backend-test-utils/src/testUtils/TestEventsService.ts create mode 100644 plugins/events-backend/src/deprecated.ts create mode 100644 plugins/events-node/src/api/DefaultEventsService.test.ts create mode 100644 plugins/events-node/src/api/DefaultEventsService.ts create mode 100644 plugins/events-node/src/api/EventsService.ts create mode 100644 plugins/events-node/src/deprecated.ts create mode 100644 plugins/events-node/src/service.ts diff --git a/.changeset/breezy-cycles-count.md b/.changeset/breezy-cycles-count.md new file mode 100644 index 0000000000..81998d9e8b --- /dev/null +++ b/.changeset/breezy-cycles-count.md @@ -0,0 +1,39 @@ +--- +'@backstage/plugin-events-backend-test-utils': patch +'@backstage/plugin-events-backend': patch +'@backstage/plugin-events-node': patch +--- + +Add new `EventsService` as well as `eventsServiceRef` for the new backend system. + +**Summary:** + +- new: + `EventsService`, `eventsServiceRef`, `TestEventsService` +- deprecated: + `EventBroker`, `EventPublisher`, `EventSubscriber`, `DefaultEventBroker`, `EventsBackend`, + most parts of `EventsExtensionPoint` (alpha), + `TestEventBroker`, `TestEventPublisher`, `TestEventSubscriber` + +Add the `eventsServiceRef` as dependency to your backend plugins +or backend plugin modules. + +**Details:** + +The previous implementation using the `EventsExtensionPoint` was added in the early stages +of the new backend system and does not respect the plugin isolation. +This made it not compatible anymore with the new backend system. + +Additionally, the previous interfaces had some room for simplification, +supporting less exposure of internal concerns as well. + +Hereby, this change adds a new `EventsService` interface as replacement for the now deprecated `EventBroker`. +The new interface does not require any `EventPublisher` or `EventSubscriber` interfaces anymore. +Instead, it is expected that the `EventsService` gets passed into publishers and subscribers, +and used internally. There is no need to expose anything of that at their own interfaces. + +Most parts of `EventsExtensionPoint` (alpha) are deprecated as well and were not usable +(by other plugins or their modules) anyway. + +The `DefaultEventBroker` implementation is deprecated and wraps the new `DefaultEventsService` implementation. +Optionally, an instance can be passed as argument to allow mixed setups to operate alongside. diff --git a/plugins/events-backend-test-utils/api-report.md b/plugins/events-backend-test-utils/api-report.md index 46131c4244..9630c3d4e2 100644 --- a/plugins/events-backend-test-utils/api-report.md +++ b/plugins/events-backend-test-utils/api-report.md @@ -6,9 +6,11 @@ import { EventBroker } from '@backstage/plugin-events-node'; import { EventParams } from '@backstage/plugin-events-node'; import { EventPublisher } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; +import { EventsServiceSubscribeOptions } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; -// @public (undocumented) +// @public @deprecated (undocumented) export class TestEventBroker implements EventBroker { // (undocumented) publish(params: EventParams): Promise; @@ -22,7 +24,7 @@ export class TestEventBroker implements EventBroker { readonly subscribed: EventSubscriber[]; } -// @public (undocumented) +// @public @deprecated (undocumented) export class TestEventPublisher implements EventPublisher { // (undocumented) get eventBroker(): EventBroker | undefined; @@ -31,6 +33,20 @@ export class TestEventPublisher implements EventPublisher { } // @public (undocumented) +export class TestEventsService implements EventsService { + // (undocumented) + publish(params: EventParams): Promise; + // (undocumented) + get published(): EventParams[]; + // (undocumented) + reset(): void; + // (undocumented) + subscribe(options: EventsServiceSubscribeOptions): Promise; + // (undocumented) + get subscribed(): EventsServiceSubscribeOptions[]; +} + +// @public @deprecated (undocumented) export class TestEventSubscriber implements EventSubscriber { constructor(name: string, topics: string[]); // (undocumented) diff --git a/plugins/events-backend-test-utils/src/deprecated.ts b/plugins/events-backend-test-utils/src/deprecated.ts new file mode 100644 index 0000000000..15072dcfb4 --- /dev/null +++ b/plugins/events-backend-test-utils/src/deprecated.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export { TestEventBroker } from './testUtils/TestEventBroker'; +export { TestEventPublisher } from './testUtils/TestEventPublisher'; +export { TestEventSubscriber } from './testUtils/TestEventSubscriber'; diff --git a/plugins/events-backend-test-utils/src/index.ts b/plugins/events-backend-test-utils/src/index.ts index efba5be0d0..da090488fc 100644 --- a/plugins/events-backend-test-utils/src/index.ts +++ b/plugins/events-backend-test-utils/src/index.ts @@ -20,4 +20,5 @@ * @packageDocumentation */ +export * from './deprecated'; export * from './testUtils'; diff --git a/plugins/events-backend-test-utils/src/testUtils/TestEventBroker.ts b/plugins/events-backend-test-utils/src/testUtils/TestEventBroker.ts index c697a6506f..78556cfc63 100644 --- a/plugins/events-backend-test-utils/src/testUtils/TestEventBroker.ts +++ b/plugins/events-backend-test-utils/src/testUtils/TestEventBroker.ts @@ -20,7 +20,10 @@ import { EventSubscriber, } from '@backstage/plugin-events-node'; -/** @public */ +/** + * @public + * @deprecated use `TestEventsService` instead + */ export class TestEventBroker implements EventBroker { readonly published: EventParams[] = []; readonly subscribed: EventSubscriber[] = []; diff --git a/plugins/events-backend-test-utils/src/testUtils/TestEventPublisher.ts b/plugins/events-backend-test-utils/src/testUtils/TestEventPublisher.ts index c1b2038afb..51bad11278 100644 --- a/plugins/events-backend-test-utils/src/testUtils/TestEventPublisher.ts +++ b/plugins/events-backend-test-utils/src/testUtils/TestEventPublisher.ts @@ -16,7 +16,10 @@ import { EventBroker, EventPublisher } from '@backstage/plugin-events-node'; -/** @public */ +/** + * @public + * @deprecated `EventPublisher` was replaced by `EventsService.publish` + */ export class TestEventPublisher implements EventPublisher { #eventBroker?: EventBroker; diff --git a/plugins/events-backend-test-utils/src/testUtils/TestEventSubscriber.ts b/plugins/events-backend-test-utils/src/testUtils/TestEventSubscriber.ts index ef5758b804..3db9023a9b 100644 --- a/plugins/events-backend-test-utils/src/testUtils/TestEventSubscriber.ts +++ b/plugins/events-backend-test-utils/src/testUtils/TestEventSubscriber.ts @@ -16,7 +16,10 @@ import { EventParams, EventSubscriber } from '@backstage/plugin-events-node'; -/** @public */ +/** + * @public + * @deprecated `EventSubscriber` was replaced by `EventsService.subscribe`. + */ export class TestEventSubscriber implements EventSubscriber { readonly name: string; readonly topics: string[]; diff --git a/plugins/events-backend-test-utils/src/testUtils/TestEventsService.ts b/plugins/events-backend-test-utils/src/testUtils/TestEventsService.ts new file mode 100644 index 0000000000..c87072711c --- /dev/null +++ b/plugins/events-backend-test-utils/src/testUtils/TestEventsService.ts @@ -0,0 +1,48 @@ +/* + * 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 { + EventParams, + EventsService, + EventsServiceSubscribeOptions, +} from '@backstage/plugin-events-node'; + +/** @public */ +export class TestEventsService implements EventsService { + #published: EventParams[] = []; + #subscribed: EventsServiceSubscribeOptions[] = []; + + async publish(params: EventParams): Promise { + this.#published.push(params); + } + + async subscribe(options: EventsServiceSubscribeOptions): Promise { + this.#subscribed.push(options); + } + + get published(): EventParams[] { + return this.#published; + } + + get subscribed(): EventsServiceSubscribeOptions[] { + return this.#subscribed; + } + + reset(): void { + this.#published = []; + this.#subscribed = []; + } +} diff --git a/plugins/events-backend-test-utils/src/testUtils/index.ts b/plugins/events-backend-test-utils/src/testUtils/index.ts index a571ba3075..d9eb544628 100644 --- a/plugins/events-backend-test-utils/src/testUtils/index.ts +++ b/plugins/events-backend-test-utils/src/testUtils/index.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -export { TestEventBroker } from './TestEventBroker'; -export { TestEventPublisher } from './TestEventPublisher'; -export { TestEventSubscriber } from './TestEventSubscriber'; +export { TestEventsService } from './TestEventsService'; diff --git a/plugins/events-backend/api-report.md b/plugins/events-backend/api-report.md index aeb6f9363d..9fffe719ba 100644 --- a/plugins/events-backend/api-report.md +++ b/plugins/events-backend/api-report.md @@ -7,14 +7,17 @@ import { Config } from '@backstage/config'; import { EventBroker } from '@backstage/plugin-events-node'; import { EventParams } from '@backstage/plugin-events-node'; import { EventPublisher } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; import express from 'express'; import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; +import { LoggerService } from '@backstage/backend-plugin-api'; -// @public +// @public @deprecated export class DefaultEventBroker implements EventBroker { - constructor(logger: Logger); + // @deprecated + constructor(logger: LoggerService, events?: EventsService); // (undocumented) publish(params: EventParams): Promise; // (undocumented) @@ -23,7 +26,7 @@ export class DefaultEventBroker implements EventBroker { ): void; } -// @public +// @public @deprecated export class EventsBackend { constructor(logger: Logger); // (undocumented) diff --git a/plugins/events-backend/src/deprecated.ts b/plugins/events-backend/src/deprecated.ts new file mode 100644 index 0000000000..cce853b2af --- /dev/null +++ b/plugins/events-backend/src/deprecated.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { EventsBackend } from './service/EventsBackend'; +export { DefaultEventBroker } from './service/DefaultEventBroker'; diff --git a/plugins/events-backend/src/index.ts b/plugins/events-backend/src/index.ts index be173b677c..63dfa5d252 100644 --- a/plugins/events-backend/src/index.ts +++ b/plugins/events-backend/src/index.ts @@ -20,6 +20,5 @@ * @packageDocumentation */ -export { EventsBackend } from './service/EventsBackend'; +export * from './deprecated'; export { HttpPostIngressEventPublisher } from './service/http'; -export { DefaultEventBroker } from './service/DefaultEventBroker'; diff --git a/plugins/events-backend/src/service/DefaultEventBroker.test.ts b/plugins/events-backend/src/service/DefaultEventBroker.test.ts index 99e5f6f72d..5317251726 100644 --- a/plugins/events-backend/src/service/DefaultEventBroker.test.ts +++ b/plugins/events-backend/src/service/DefaultEventBroker.test.ts @@ -85,15 +85,15 @@ describe('DefaultEventBroker', () => { } })(); - const errorSpy = jest.spyOn(logger, 'error'); + const warnSpy = jest.spyOn(logger, 'warn'); const eventBroker = new DefaultEventBroker(logger); eventBroker.subscribe(subscriber1); await eventBroker.publish({ topic, eventPayload: '1' }); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - 'Subscriber "Subscriber1" failed to process event', + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber1" failed to process event for topic "testTopic"', new Error('NOPE 1'), ); @@ -101,13 +101,13 @@ describe('DefaultEventBroker', () => { await eventBroker.publish({ topic, eventPayload: '2' }); // With two subscribers we should not halt on the first error but call all subscribers - expect(errorSpy).toHaveBeenCalledTimes(3); - expect(errorSpy).toHaveBeenCalledWith( - 'Subscriber "Subscriber1" failed to process event', + expect(warnSpy).toHaveBeenCalledTimes(3); + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber1" failed to process event for topic "testTopic"', new Error('NOPE 2'), ); - expect(errorSpy).toHaveBeenCalledWith( - 'Subscriber "Subscriber2" failed to process event', + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "Subscriber2" failed to process event for topic "testTopic"', new Error('NOPE 2'), ); }); diff --git a/plugins/events-backend/src/service/DefaultEventBroker.ts b/plugins/events-backend/src/service/DefaultEventBroker.ts index c3824b3e7d..27523f3118 100644 --- a/plugins/events-backend/src/service/DefaultEventBroker.ts +++ b/plugins/events-backend/src/service/DefaultEventBroker.ts @@ -14,12 +14,14 @@ * limitations under the License. */ +import { LoggerService } from '@backstage/backend-plugin-api'; import { + DefaultEventsService, EventBroker, EventParams, + EventsService, EventSubscriber, } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; /** * In process event broker which will pass the event to all registered subscribers @@ -27,44 +29,34 @@ import { Logger } from 'winston'; * Events will not be persisted in any form. * * @public + * @deprecated use `DefaultEventsService` from `@backstage/plugin-events-node` instead */ -// TODO(pjungermann): add prom metrics? (see plugins/catalog-backend/src/util/metrics.ts, etc.) export class DefaultEventBroker implements EventBroker { - constructor(private readonly logger: Logger) {} + private readonly events: EventsService; - private readonly subscribers: { - [topic: string]: EventSubscriber[]; - } = {}; + /** + * + * @param logger - logger + * @param events - replacement that gets wrapped to support not yet migrated implementations. + * An instance can be passed (required for a mixed mode), otherwise a new instance gets created internally. + * @deprecated use `DefaultEventsService` directly instead + */ + constructor(logger: LoggerService, events?: EventsService) { + this.events = events ?? DefaultEventsService.create({ logger }); + } async publish(params: EventParams): Promise { - this.logger.debug( - `Event received: topic=${params.topic}, metadata=${JSON.stringify( - params.metadata, - )}, payload=${JSON.stringify(params.eventPayload)}`, - ); - - const subscribed = this.subscribers[params.topic] ?? []; - await Promise.all( - subscribed.map(async subscriber => { - try { - await subscriber.onEvent(params); - } catch (error) { - this.logger.error( - `Subscriber "${subscriber.constructor.name}" failed to process event`, - error, - ); - } - }), - ); + return this.events.publish(params); } subscribe( ...subscribers: Array> ): void { - subscribers.flat().forEach(subscriber => { - subscriber.supportsEventTopics().forEach(topic => { - this.subscribers[topic] = this.subscribers[topic] ?? []; - this.subscribers[topic].push(subscriber); + subscribers.flat().forEach(async subscriber => { + await this.events.subscribe({ + id: subscriber.constructor.name, + topics: subscriber.supportsEventTopics(), + onEvent: subscriber.onEvent.bind(subscriber), }); }); } diff --git a/plugins/events-backend/src/service/EventsBackend.ts b/plugins/events-backend/src/service/EventsBackend.ts index 4415b8703a..2c93663b46 100644 --- a/plugins/events-backend/src/service/EventsBackend.ts +++ b/plugins/events-backend/src/service/EventsBackend.ts @@ -26,6 +26,7 @@ import { DefaultEventBroker } from './DefaultEventBroker'; * A builder that helps wire up all component parts of the event management. * * @public + * @deprecated `EventBroker`, `EventPublisher`, and `EventSubscriber` got replaced by `EventsService` and its methods. */ export class EventsBackend { private eventBroker: EventBroker; diff --git a/plugins/events-node/api-report-alpha.md b/plugins/events-node/api-report-alpha.md index fd30f54d45..f61048ac94 100644 --- a/plugins/events-node/api-report-alpha.md +++ b/plugins/events-node/api-report-alpha.md @@ -13,15 +13,15 @@ import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; export interface EventsExtensionPoint { // (undocumented) addHttpPostIngress(options: HttpPostIngressOptions): void; - // (undocumented) + // @deprecated (undocumented) addPublishers( ...publishers: Array> ): void; - // (undocumented) + // @deprecated (undocumented) addSubscribers( ...subscribers: Array> ): void; - // (undocumented) + // @deprecated (undocumented) setEventBroker(eventBroker: EventBroker): void; } diff --git a/plugins/events-node/api-report.md b/plugins/events-node/api-report.md index dfda48d97e..081c56549a 100644 --- a/plugins/events-node/api-report.md +++ b/plugins/events-node/api-report.md @@ -3,7 +3,21 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { LoggerService } from '@backstage/backend-plugin-api'; +import { ServiceRef } from '@backstage/backend-plugin-api'; + // @public +export class DefaultEventsService implements EventsService { + // (undocumented) + static create(options: { logger: LoggerService }): DefaultEventsService; + forPlugin(pluginId: string): EventsService; + // (undocumented) + publish(params: EventParams): Promise; + // (undocumented) + subscribe(options: EventsServiceSubscribeOptions): Promise; +} + +// @public @deprecated export interface EventBroker { publish(params: EventParams): Promise; subscribe( @@ -18,9 +32,9 @@ export interface EventParams { topic: string; } -// @public +// @public @deprecated export interface EventPublisher { - // (undocumented) + // @deprecated (undocumented) setEventBroker(eventBroker: EventBroker): Promise; } @@ -39,8 +53,29 @@ export abstract class EventRouter implements EventPublisher, EventSubscriber { } // @public +export interface EventsService { + publish(params: EventParams): Promise; + subscribe(options: EventsServiceSubscribeOptions): Promise; +} + +// @public (undocumented) +export type EventsServiceEventHandler = (params: EventParams) => Promise; + +// @public +export const eventsServiceRef: ServiceRef; + +// @public (undocumented) +export type EventsServiceSubscribeOptions = { + id: string; + topics: string[]; + onEvent: EventsServiceEventHandler; +}; + +// @public @deprecated export interface EventSubscriber { + // @deprecated onEvent(params: EventParams): Promise; + // @deprecated supportsEventTopics(): string[]; } diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json index 58ddd50413..de794dfa45 100644 --- a/plugins/events-node/package.json +++ b/plugins/events-node/package.json @@ -45,6 +45,7 @@ "@backstage/backend-plugin-api": "workspace:^" }, "devDependencies": { + "@backstage/backend-common": "workspace:^", "@backstage/cli": "workspace:^" }, "files": [ diff --git a/plugins/events-node/src/api/DefaultEventsService.test.ts b/plugins/events-node/src/api/DefaultEventsService.test.ts new file mode 100644 index 0000000000..33df923892 --- /dev/null +++ b/plugins/events-node/src/api/DefaultEventsService.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2022 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 { getVoidLogger } from '@backstage/backend-common'; +import { DefaultEventsService } from './DefaultEventsService'; +import { EventParams } from './EventParams'; + +const logger = getVoidLogger(); + +describe('DefaultEventsService', () => { + it('passes events to interested subscribers', async () => { + const events = DefaultEventsService.create({ logger }); + const eventsSubscriber1: EventParams[] = []; + const eventsSubscriber2: EventParams[] = []; + + await events.subscribe({ + id: 'subscriber1', + topics: ['topicA', 'topicB'], + onEvent: async event => { + eventsSubscriber1.push(event); + }, + }); + await events.subscribe({ + id: 'subscriber2', + topics: ['topicB', 'topicC'], + onEvent: async event => { + eventsSubscriber2.push(event); + }, + }); + await events.publish({ + topic: 'topicA', + eventPayload: { test: 'topicA' }, + }); + await events.publish({ + topic: 'topicB', + eventPayload: { test: 'topicB' }, + }); + await events.publish({ + topic: 'topicC', + eventPayload: { test: 'topicC' }, + }); + await events.publish({ + topic: 'topicD', + eventPayload: { test: 'topicD' }, + }); + + expect(eventsSubscriber1).toEqual([ + { topic: 'topicA', eventPayload: { test: 'topicA' } }, + { topic: 'topicB', eventPayload: { test: 'topicB' } }, + ]); + expect(eventsSubscriber2).toEqual([ + { topic: 'topicB', eventPayload: { test: 'topicB' } }, + { topic: 'topicC', eventPayload: { test: 'topicC' } }, + ]); + }); + + it('logs errors from subscribers', async () => { + const topic = 'testTopic'; + + const warnSpy = jest.spyOn(logger, 'warn'); + const events = DefaultEventsService.create({ logger }); + + await events.subscribe({ + id: 'subscriber1', + topics: [topic], + onEvent: event => { + throw new Error(`NOPE ${event.eventPayload}`); + }, + }); + await events.publish({ topic, eventPayload: '1' }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "subscriber1" failed to process event for topic "testTopic"', + new Error('NOPE 1'), + ); + + await events.subscribe({ + id: 'subscriber2', + topics: [topic], + onEvent: event => { + throw new Error(`NOPE ${event.eventPayload}`); + }, + }); + await events.publish({ topic, eventPayload: '2' }); + + // With two subscribers we should not halt on the first error but call all subscribers + expect(warnSpy).toHaveBeenCalledTimes(3); + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "subscriber1" failed to process event for topic "testTopic"', + new Error('NOPE 2'), + ); + expect(warnSpy).toHaveBeenCalledWith( + 'Subscriber "subscriber2" failed to process event for topic "testTopic"', + new Error('NOPE 2'), + ); + }); +}); diff --git a/plugins/events-node/src/api/DefaultEventsService.ts b/plugins/events-node/src/api/DefaultEventsService.ts new file mode 100644 index 0000000000..bb5c2a0ca8 --- /dev/null +++ b/plugins/events-node/src/api/DefaultEventsService.ts @@ -0,0 +1,104 @@ +/* + * 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 { LoggerService } from '@backstage/backend-plugin-api'; +import { EventParams } from './EventParams'; +import { EventsService, EventsServiceSubscribeOptions } from './EventsService'; + +/** + * In-process event broker which will pass the event to all registered subscribers + * interested in it. + * Events will not be persisted in any form. + * Events will not be passed to subscribers at other instances of the same cluster. + * + * @public + */ +// TODO(pjungermann): add opentelemetry? (see plugins/catalog-backend/src/util/opentelemetry.ts, etc.) +export class DefaultEventsService implements EventsService { + private readonly subscribers = new Map< + string, + Omit[] + >(); + + private constructor(private readonly logger: LoggerService) {} + + static create(options: { logger: LoggerService }): DefaultEventsService { + return new DefaultEventsService(options.logger); + } + + /** + * Returns a plugin-scoped context of the `EventService` + * that ensures to prefix subscriber IDs with the plugin ID. + * + * @param pluginId - The plugin that the `EventService` should be created for. + */ + forPlugin(pluginId: string): EventsService { + return { + publish: (params: EventParams): Promise => { + return this.publish(params); + }, + subscribe: (options: EventsServiceSubscribeOptions): Promise => { + return this.subscribe({ + ...options, + id: `${pluginId}.${options.id}`, + }); + }, + }; + } + + async publish(params: EventParams): Promise { + this.logger.debug( + `Event received: topic=${params.topic}, metadata=${JSON.stringify( + params.metadata, + )}, payload=${JSON.stringify(params.eventPayload)}`, + ); + + if (!this.subscribers.has(params.topic)) { + return; + } + + const onEventPromises: Promise[] = []; + this.subscribers.get(params.topic)?.forEach(subscription => { + onEventPromises.push( + (async () => { + try { + await subscription.onEvent(params); + } catch (error) { + this.logger.warn( + `Subscriber "${subscription.id}" failed to process event for topic "${params.topic}"`, + error, + ); + } + })(), + ); + }); + + await Promise.all(onEventPromises); + } + + async subscribe(options: EventsServiceSubscribeOptions): Promise { + options.topics.forEach(topic => { + if (!this.subscribers.has(topic)) { + this.subscribers.set(topic, []); + } + + this.subscribers.get(topic)!.push({ + id: options.id, + onEvent: options.onEvent, + }); + }); + } +} diff --git a/plugins/events-node/src/api/EventBroker.ts b/plugins/events-node/src/api/EventBroker.ts index 736c2a2bf0..f6afdf2f14 100644 --- a/plugins/events-node/src/api/EventBroker.ts +++ b/plugins/events-node/src/api/EventBroker.ts @@ -23,6 +23,7 @@ import { EventSubscriber } from './EventSubscriber'; * others can subscribe for future events for topics they are interested in. * * @public + * @deprecated use `EventsService` instead */ export interface EventBroker { /** diff --git a/plugins/events-node/src/api/EventPublisher.ts b/plugins/events-node/src/api/EventPublisher.ts index 285f427804..9089eb33fa 100644 --- a/plugins/events-node/src/api/EventPublisher.ts +++ b/plugins/events-node/src/api/EventPublisher.ts @@ -23,7 +23,11 @@ import { EventBroker } from './EventBroker'; * or from event brokers, queues, etc. * * @public + * @deprecated use the `EventsService` via the constructor, setter, or other means instead */ export interface EventPublisher { + /** + * @deprecated use the `EventsService` via the constructor, setter, or other means instead + */ setEventBroker(eventBroker: EventBroker): Promise; } diff --git a/plugins/events-node/src/api/EventSubscriber.ts b/plugins/events-node/src/api/EventSubscriber.ts index 439f49b890..3686a8db3f 100644 --- a/plugins/events-node/src/api/EventSubscriber.ts +++ b/plugins/events-node/src/api/EventSubscriber.ts @@ -22,10 +22,13 @@ import { EventParams } from './EventParams'; * or other actions to react on events. * * @public + * @deprecated use the `EventsService` via the constructor, setter, or other means instead */ export interface EventSubscriber { /** * Supported event topics like "github", "bitbucketCloud", etc. + * + * @deprecated use the `EventsService` via the constructor, setter, or other means instead */ supportsEventTopics(): string[]; @@ -33,6 +36,7 @@ export interface EventSubscriber { * React on a received event. * * @param params - parameters for the to be received event. + * @deprecated you are not required to expose this anymore when using `EventsService` */ onEvent(params: EventParams): Promise; } diff --git a/plugins/events-node/src/api/EventsService.ts b/plugins/events-node/src/api/EventsService.ts new file mode 100644 index 0000000000..7af13f9b07 --- /dev/null +++ b/plugins/events-node/src/api/EventsService.ts @@ -0,0 +1,57 @@ +/* + * 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 { EventParams } from './EventParams'; + +/** + * Allows a decoupled and asynchronous communication between components. + * Components can publish events for a given topic and + * others can subscribe for future events for topics they are interested in. + * + * @public + */ +export interface EventsService { + /** + * Publishes an event for the topic. + * + * @param params - parameters for the to be published event. + */ + publish(params: EventParams): Promise; + + /** + * Subscribes to one or more topics, registering an event handler for them. + * + * @param options - event subscription options. + */ + subscribe(options: EventsServiceSubscribeOptions): Promise; +} + +/** + * @public + */ +export type EventsServiceSubscribeOptions = { + /** + * Identifier for the subscription. E.g., used as part of log messages. + */ + id: string; + topics: string[]; + onEvent: EventsServiceEventHandler; +}; + +/** + * @public + */ +export type EventsServiceEventHandler = (params: EventParams) => Promise; diff --git a/plugins/events-node/src/api/index.ts b/plugins/events-node/src/api/index.ts index 91711c0e38..94d3014dff 100644 --- a/plugins/events-node/src/api/index.ts +++ b/plugins/events-node/src/api/index.ts @@ -14,10 +14,13 @@ * limitations under the License. */ -export type { EventBroker } from './EventBroker'; export type { EventParams } from './EventParams'; -export type { EventPublisher } from './EventPublisher'; export { EventRouter } from './EventRouter'; -export type { EventSubscriber } from './EventSubscriber'; +export type { + EventsService, + EventsServiceSubscribeOptions, + EventsServiceEventHandler, +} from './EventsService'; +export { DefaultEventsService } from './DefaultEventsService'; export * from './http'; export { SubTopicEventRouter } from './SubTopicEventRouter'; diff --git a/plugins/events-node/src/deprecated.ts b/plugins/events-node/src/deprecated.ts new file mode 100644 index 0000000000..615e7b81ea --- /dev/null +++ b/plugins/events-node/src/deprecated.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export type { EventBroker } from './api/EventBroker'; +export type { EventPublisher } from './api/EventPublisher'; +export type { EventSubscriber } from './api/EventSubscriber'; diff --git a/plugins/events-node/src/extensions.ts b/plugins/events-node/src/extensions.ts index 2e44b381af..90add52563 100644 --- a/plugins/events-node/src/extensions.ts +++ b/plugins/events-node/src/extensions.ts @@ -26,12 +26,21 @@ import { * @alpha */ export interface EventsExtensionPoint { + /** + * @deprecated use `eventsServiceRef` and `eventsServiceFactory` instead + */ setEventBroker(eventBroker: EventBroker): void; + /** + * @deprecated use `EventsService.publish` instead + */ addPublishers( ...publishers: Array> ): void; + /** + * @deprecated use `EventsService.subscribe` instead + */ addSubscribers( ...subscribers: Array> ): void; diff --git a/plugins/events-node/src/index.ts b/plugins/events-node/src/index.ts index 2bdf456f13..2bd93a8aea 100644 --- a/plugins/events-node/src/index.ts +++ b/plugins/events-node/src/index.ts @@ -21,3 +21,5 @@ */ export * from './api'; +export * from './deprecated'; +export { eventsServiceRef } from './service'; diff --git a/plugins/events-node/src/service.ts b/plugins/events-node/src/service.ts new file mode 100644 index 0000000000..e1d3047fca --- /dev/null +++ b/plugins/events-node/src/service.ts @@ -0,0 +1,47 @@ +/* + * 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 { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { EventsService, DefaultEventsService } from './api'; + +/** + * The {@link EventsService} that allows to publish events, and subscribe to topics. + * Uses the `root` scope so that events can be shared across all plugins, modules, and more. + * + * @public + */ +export const eventsServiceRef = createServiceRef({ + id: 'events.service', + scope: 'plugin', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + pluginMetadata: coreServices.pluginMetadata, + rootLogger: coreServices.rootLogger, + }, + async createRootContext({ rootLogger }) { + return DefaultEventsService.create({ logger: rootLogger }); + }, + async factory({ pluginMetadata }, eventsService) { + return eventsService.forPlugin(pluginMetadata.getId()); + }, + }), +}); diff --git a/yarn.lock b/yarn.lock index 7271c5f6bb..f49f9f73c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6495,6 +6495,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-events-node@workspace:plugins/events-node" dependencies: + "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" languageName: unknown From eff3ca9ddd5da779e0fabf3e677f6a76e790a056 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 19:20:26 +0100 Subject: [PATCH 093/176] feat(events)!: migrate `EventRouter` implementations from `EventBroker` to `EventsService` Signed-off-by: Patrick Jungermann --- .changeset/kind-students-cross.md | 76 +++++++++++++++++++ .../events-backend-module-azure/api-report.md | 5 +- .../events-backend-module-azure/package.json | 3 +- .../src/router/AzureDevOpsEventRouter.test.ts | 33 ++++---- .../src/router/AzureDevOpsEventRouter.ts | 12 ++- ...eventsModuleAzureDevOpsEventRouter.test.ts | 34 ++++----- .../eventsModuleAzureDevOpsEventRouter.ts | 12 +-- .../api-report.md | 5 +- .../package.json | 3 +- .../router/BitbucketCloudEventRouter.test.ts | 33 ++++---- .../src/router/BitbucketCloudEventRouter.ts | 12 ++- ...ntsModuleBitbucketCloudEventRouter.test.ts | 37 +++++---- .../eventsModuleBitbucketCloudEventRouter.ts | 12 +-- .../api-report.md | 5 +- .../events-backend-module-gerrit/package.json | 3 +- .../src/router/GerritEventRouter.test.ts | 33 ++++---- .../src/router/GerritEventRouter.ts | 12 ++- .../eventsModuleGerritEventRouter.test.ts | 34 ++++----- .../service/eventsModuleGerritEventRouter.ts | 10 +-- .../api-report.md | 5 +- .../events-backend-module-github/package.json | 3 +- .../src/router/GithubEventRouter.test.ts | 33 ++++---- .../src/router/GithubEventRouter.ts | 12 ++- .../eventsModuleGithubEventRouter.test.ts | 34 ++++----- .../service/eventsModuleGithubEventRouter.ts | 10 +-- .../api-report.md | 5 +- .../events-backend-module-gitlab/package.json | 3 +- .../src/router/GitlabEventRouter.test.ts | 33 ++++---- .../src/router/GitlabEventRouter.ts | 12 ++- .../eventsModuleGitlabEventRouter.test.ts | 34 ++++----- .../service/eventsModuleGitlabEventRouter.ts | 10 +-- plugins/events-node/api-report.md | 14 ++-- .../events-node/src/api/EventRouter.test.ts | 39 +++++----- plugins/events-node/src/api/EventRouter.ts | 44 ++++++++--- .../src/api/SubTopicEventRouter.test.ts | 35 ++++----- .../src/api/SubTopicEventRouter.ts | 12 +-- yarn.lock | 5 -- 37 files changed, 429 insertions(+), 288 deletions(-) create mode 100644 .changeset/kind-students-cross.md diff --git a/.changeset/kind-students-cross.md b/.changeset/kind-students-cross.md new file mode 100644 index 0000000000..9f9d2d40a6 --- /dev/null +++ b/.changeset/kind-students-cross.md @@ -0,0 +1,76 @@ +--- +'@backstage/plugin-events-backend-module-bitbucket-cloud': minor +'@backstage/plugin-events-backend-module-gerrit': minor +'@backstage/plugin-events-backend-module-github': minor +'@backstage/plugin-events-backend-module-gitlab': minor +'@backstage/plugin-events-backend-module-azure': minor +'@backstage/plugin-events-node': minor +--- + +BREAKING CHANGE: Migrate `EventRouter` implementations from `EventBroker` to `EventsService`. + +`EventRouter` uses the new `EventsService` instead of the `EventBroker` now, +causing a breaking change to its signature. + +All of its extensions and implementations got adjusted accordingly. +(`SubTopicEventRouter`, `AzureDevOpsEventRouter`, `BitbucketCloudEventRouter`, +`GerritEventRouter`, `GithubEventRouter`, `GitlabEventRouter`) + +Required adjustments were made to all backend modules for the new backend system, +now also making use of the `eventsServiceRef` instead of the `eventsExtensionPoint`. + +**Migration:** + +Example for implementations of `SubTopicEventRouter`: + +```diff + import { + EventParams, ++ EventsService, + SubTopicEventRouter, + } from '@backstage/plugin-events-node'; + + export class GithubEventRouter extends SubTopicEventRouter { +- constructor() { +- super('github'); ++ constructor(options: { events: EventsService }) { ++ super({ ++ events: options.events, ++ topic: 'github', ++ }); + } + ++ protected getSubscriberId(): string { ++ return 'GithubEventRouter'; ++ } ++ + // ... + } +``` + +Example for a direct extension of `EventRouter`: + +```diff + class MyEventRouter extends EventRouter { +- constructor(/* ... */) { ++ constructor(options: { ++ events: EventsService; ++ // ... ++ }) { +- super(); + // ... ++ super({ ++ events: options.events, ++ topics: topics, ++ }); + } ++ ++ protected getSubscriberId(): string { ++ return 'MyEventRouter'; ++ } +- +- supportsEventTopics(): string[] { +- return this.topics; +- } + } +``` diff --git a/plugins/events-backend-module-azure/api-report.md b/plugins/events-backend-module-azure/api-report.md index 4460aeb510..66529ef2d4 100644 --- a/plugins/events-backend-module-azure/api-report.md +++ b/plugins/events-backend-module-azure/api-report.md @@ -4,12 +4,15 @@ ```ts import { EventParams } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; // @public export class AzureDevOpsEventRouter extends SubTopicEventRouter { - constructor(); + constructor(options: { events: EventsService }); // (undocumented) protected determineSubTopic(params: EventParams): string | undefined; + // (undocumented) + protected getSubscriberId(): string; } ``` diff --git a/plugins/events-backend-module-azure/package.json b/plugins/events-backend-module-azure/package.json index 9187f74024..0ee9f2dbfc 100644 --- a/plugins/events-backend-module-azure/package.json +++ b/plugins/events-backend-module-azure/package.json @@ -42,8 +42,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", - "@backstage/plugin-events-node": "workspace:^", - "winston": "^3.2.1" + "@backstage/plugin-events-node": "workspace:^" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.test.ts b/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.test.ts index 56e761a8fc..837888a05a 100644 --- a/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.test.ts +++ b/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.test.ts @@ -14,37 +14,44 @@ * limitations under the License. */ -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { AzureDevOpsEventRouter } from './AzureDevOpsEventRouter'; describe('AzureDevOpsEventRouter', () => { - const eventRouter = new AzureDevOpsEventRouter(); + const events = new TestEventsService(); + const eventRouter = new AzureDevOpsEventRouter({ events: events }); const topic = 'azureDevOps'; const eventPayload = { eventType: 'test.type', test: 'payload' }; const metadata = {}; - it('no $.eventType', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); + beforeEach(() => { + events.reset(); + }); + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('AzureDevOpsEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no $.eventType', () => { eventRouter.onEvent({ topic, eventPayload: { invalid: 'payload' }, metadata, }); - expect(eventBroker.published).toEqual([]); + expect(events.published).toEqual([]); }); it('with $.eventType', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); - eventRouter.onEvent({ topic, eventPayload, metadata }); - expect(eventBroker.published.length).toBe(1); - expect(eventBroker.published[0].topic).toEqual('azureDevOps.test.type'); - expect(eventBroker.published[0].eventPayload).toEqual(eventPayload); - expect(eventBroker.published[0].metadata).toEqual(metadata); + expect(events.published).toHaveLength(1); + expect(events.published[0].topic).toEqual('azureDevOps.test.type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); }); }); diff --git a/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.ts b/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.ts index 11dd7546dd..de05abd032 100644 --- a/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.ts +++ b/plugins/events-backend-module-azure/src/router/AzureDevOpsEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams, + EventsService, SubTopicEventRouter, } from '@backstage/plugin-events-node'; @@ -27,8 +28,15 @@ import { * @public */ export class AzureDevOpsEventRouter extends SubTopicEventRouter { - constructor() { - super('azureDevOps'); + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'azureDevOps', + }); + } + + protected getSubscriberId(): string { + return 'AzureDevOpsEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { diff --git a/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.test.ts b/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.test.ts index d67b5eb071..d2afb81851 100644 --- a/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.test.ts +++ b/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.test.ts @@ -14,32 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsModuleAzureDevOpsEventRouter } from './eventsModuleAzureDevOpsEventRouter'; -import { AzureDevOpsEventRouter } from '../router/AzureDevOpsEventRouter'; describe('eventsModuleAzureDevOpsEventRouter', () => { it('should be correctly wired and set up', async () => { - let addedPublisher: AzureDevOpsEventRouter | undefined; - let addedSubscriber: AzureDevOpsEventRouter | undefined; - const extensionPoint = { - addPublishers: (publisher: any) => { - addedPublisher = publisher; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - addSubscribers: (subscriber: any) => { - addedSubscriber = subscriber; - }, - }; - - await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], - features: [eventsModuleAzureDevOpsEventRouter()], }); - expect(addedPublisher).not.toBeUndefined(); - expect(addedPublisher).toBeInstanceOf(AzureDevOpsEventRouter); - expect(addedSubscriber).not.toBeUndefined(); - expect(addedSubscriber).toBeInstanceOf(AzureDevOpsEventRouter); + await startTestBackend({ + features: [eventsServiceFactory(), eventsModuleAzureDevOpsEventRouter()], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('AzureDevOpsEventRouter'); }); }); diff --git a/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.ts b/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.ts index 50bc384502..9741015477 100644 --- a/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.ts +++ b/plugins/events-backend-module-azure/src/service/eventsModuleAzureDevOpsEventRouter.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { AzureDevOpsEventRouter } from '../router/AzureDevOpsEventRouter'; /** @@ -31,13 +31,13 @@ export const eventsModuleAzureDevOpsEventRouter = createBackendModule({ register(env) { env.registerInit({ deps: { - events: eventsExtensionPoint, + events: eventsServiceRef, }, async init({ events }) { - const eventRouter = new AzureDevOpsEventRouter(); - - events.addPublishers(eventRouter); - events.addSubscribers(eventRouter); + const eventRouter = new AzureDevOpsEventRouter({ + events, + }); + await eventRouter.subscribe(); }, }); }, diff --git a/plugins/events-backend-module-bitbucket-cloud/api-report.md b/plugins/events-backend-module-bitbucket-cloud/api-report.md index 4795edd89a..ba4f61d739 100644 --- a/plugins/events-backend-module-bitbucket-cloud/api-report.md +++ b/plugins/events-backend-module-bitbucket-cloud/api-report.md @@ -4,12 +4,15 @@ ```ts import { EventParams } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; // @public export class BitbucketCloudEventRouter extends SubTopicEventRouter { - constructor(); + constructor(options: { events: EventsService }); // (undocumented) protected determineSubTopic(params: EventParams): string | undefined; + // (undocumented) + protected getSubscriberId(): string; } ``` diff --git a/plugins/events-backend-module-bitbucket-cloud/package.json b/plugins/events-backend-module-bitbucket-cloud/package.json index 609fac199c..5273652f44 100644 --- a/plugins/events-backend-module-bitbucket-cloud/package.json +++ b/plugins/events-backend-module-bitbucket-cloud/package.json @@ -42,8 +42,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", - "@backstage/plugin-events-node": "workspace:^", - "winston": "^3.2.1" + "@backstage/plugin-events-node": "workspace:^" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.test.ts b/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.test.ts index b7a47984e4..65a4f1bf21 100644 --- a/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.test.ts +++ b/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.test.ts @@ -14,33 +14,40 @@ * limitations under the License. */ -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { BitbucketCloudEventRouter } from './BitbucketCloudEventRouter'; describe('BitbucketCloudEventRouter', () => { - const eventRouter = new BitbucketCloudEventRouter(); + const events = new TestEventsService(); + const eventRouter = new BitbucketCloudEventRouter({ events }); const topic = 'bitbucketCloud'; const eventPayload = { test: 'payload' }; const metadata = { 'x-event-key': 'test:type' }; - it('no x-event-key', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); + beforeEach(() => { + events.reset(); + }); + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('BitbucketCloudEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no x-event-key', () => { eventRouter.onEvent({ topic, eventPayload }); - expect(eventBroker.published).toEqual([]); + expect(events.published).toEqual([]); }); it('with x-event-key', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); - eventRouter.onEvent({ topic, eventPayload, metadata }); - expect(eventBroker.published.length).toBe(1); - expect(eventBroker.published[0].topic).toEqual('bitbucketCloud.test:type'); - expect(eventBroker.published[0].eventPayload).toEqual(eventPayload); - expect(eventBroker.published[0].metadata).toEqual(metadata); + expect(events.published.length).toBe(1); + expect(events.published[0].topic).toEqual('bitbucketCloud.test:type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); }); }); diff --git a/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.ts b/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.ts index 8350511d65..0f3ce09abf 100644 --- a/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.ts +++ b/plugins/events-backend-module-bitbucket-cloud/src/router/BitbucketCloudEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams, + EventsService, SubTopicEventRouter, } from '@backstage/plugin-events-node'; @@ -27,8 +28,15 @@ import { * @public */ export class BitbucketCloudEventRouter extends SubTopicEventRouter { - constructor() { - super('bitbucketCloud'); + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'bitbucketCloud', + }); + } + + protected getSubscriberId(): string { + return 'BitbucketCloudEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { diff --git a/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.test.ts b/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.test.ts index 025d994b4b..337e2206e4 100644 --- a/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.test.ts +++ b/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.test.ts @@ -14,32 +14,31 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsModuleBitbucketCloudEventRouter } from './eventsModuleBitbucketCloudEventRouter'; -import { BitbucketCloudEventRouter } from '../router/BitbucketCloudEventRouter'; describe('eventsModuleBitbucketCloudEventRouter', () => { it('should be correctly wired and set up', async () => { - let addedPublisher: BitbucketCloudEventRouter | undefined; - let addedSubscriber: BitbucketCloudEventRouter | undefined; - const extensionPoint = { - addPublishers: (publisher: any) => { - addedPublisher = publisher; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - addSubscribers: (subscriber: any) => { - addedSubscriber = subscriber; - }, - }; - - await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], - features: [eventsModuleBitbucketCloudEventRouter()], }); - expect(addedPublisher).not.toBeUndefined(); - expect(addedPublisher).toBeInstanceOf(BitbucketCloudEventRouter); - expect(addedSubscriber).not.toBeUndefined(); - expect(addedSubscriber).toBeInstanceOf(BitbucketCloudEventRouter); + await startTestBackend({ + features: [ + eventsServiceFactory(), + eventsModuleBitbucketCloudEventRouter(), + ], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('BitbucketCloudEventRouter'); }); }); diff --git a/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.ts b/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.ts index 841a463001..648d6c67fb 100644 --- a/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.ts +++ b/plugins/events-backend-module-bitbucket-cloud/src/service/eventsModuleBitbucketCloudEventRouter.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { BitbucketCloudEventRouter } from '../router/BitbucketCloudEventRouter'; /** @@ -31,13 +31,13 @@ export const eventsModuleBitbucketCloudEventRouter = createBackendModule({ register(env) { env.registerInit({ deps: { - events: eventsExtensionPoint, + events: eventsServiceRef, }, async init({ events }) { - const eventRouter = new BitbucketCloudEventRouter(); - - events.addPublishers(eventRouter); - events.addSubscribers(eventRouter); + const eventRouter = new BitbucketCloudEventRouter({ + events, + }); + await eventRouter.subscribe(); }, }); }, diff --git a/plugins/events-backend-module-gerrit/api-report.md b/plugins/events-backend-module-gerrit/api-report.md index ba3c4dd29f..c75857aa43 100644 --- a/plugins/events-backend-module-gerrit/api-report.md +++ b/plugins/events-backend-module-gerrit/api-report.md @@ -4,12 +4,15 @@ ```ts import { EventParams } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; // @public export class GerritEventRouter extends SubTopicEventRouter { - constructor(); + constructor(options: { events: EventsService }); // (undocumented) protected determineSubTopic(params: EventParams): string | undefined; + // (undocumented) + protected getSubscriberId(): string; } ``` diff --git a/plugins/events-backend-module-gerrit/package.json b/plugins/events-backend-module-gerrit/package.json index 889c60c685..436fbde891 100644 --- a/plugins/events-backend-module-gerrit/package.json +++ b/plugins/events-backend-module-gerrit/package.json @@ -42,8 +42,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", - "@backstage/plugin-events-node": "workspace:^", - "winston": "^3.2.1" + "@backstage/plugin-events-node": "workspace:^" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.test.ts b/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.test.ts index 7302a26012..6c635fcbdf 100644 --- a/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.test.ts +++ b/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.test.ts @@ -14,37 +14,44 @@ * limitations under the License. */ -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { GerritEventRouter } from './GerritEventRouter'; describe('GerritEventRouter', () => { - const eventRouter = new GerritEventRouter(); + const events = new TestEventsService(); + const eventRouter = new GerritEventRouter({ events: events }); const topic = 'gerrit'; const eventPayload = { type: 'test-type', test: 'payload' }; const metadata = {}; - it('no $.type', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); + beforeEach(() => { + events.reset(); + }); + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GerritEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no $.type', () => { eventRouter.onEvent({ topic, eventPayload: { invalid: 'payload' }, metadata, }); - expect(eventBroker.published).toEqual([]); + expect(events.published).toEqual([]); }); it('with $.type', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); - eventRouter.onEvent({ topic, eventPayload, metadata }); - expect(eventBroker.published.length).toBe(1); - expect(eventBroker.published[0].topic).toEqual('gerrit.test-type'); - expect(eventBroker.published[0].eventPayload).toEqual(eventPayload); - expect(eventBroker.published[0].metadata).toEqual(metadata); + expect(events.published.length).toBe(1); + expect(events.published[0].topic).toEqual('gerrit.test-type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); }); }); diff --git a/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.ts b/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.ts index 3d97508b62..dac5aa34d5 100644 --- a/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.ts +++ b/plugins/events-backend-module-gerrit/src/router/GerritEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams, + EventsService, SubTopicEventRouter, } from '@backstage/plugin-events-node'; @@ -27,8 +28,15 @@ import { * @public */ export class GerritEventRouter extends SubTopicEventRouter { - constructor() { - super('gerrit'); + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'gerrit', + }); + } + + protected getSubscriberId(): string { + return 'GerritEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { diff --git a/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.test.ts b/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.test.ts index c11f4c42db..4fc971fcce 100644 --- a/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.test.ts +++ b/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.test.ts @@ -14,32 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { eventsModuleGerritEventRouter } from './eventsModuleGerritEventRouter'; -import { GerritEventRouter } from '../router/GerritEventRouter'; describe('eventsModuleGerritEventRouter', () => { it('should be correctly wired and set up', async () => { - let addedPublisher: GerritEventRouter | undefined; - let addedSubscriber: GerritEventRouter | undefined; - const extensionPoint = { - addPublishers: (publisher: any) => { - addedPublisher = publisher; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - addSubscribers: (subscriber: any) => { - addedSubscriber = subscriber; - }, - }; - - await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], - features: [eventsModuleGerritEventRouter()], }); - expect(addedPublisher).not.toBeUndefined(); - expect(addedPublisher).toBeInstanceOf(GerritEventRouter); - expect(addedSubscriber).not.toBeUndefined(); - expect(addedSubscriber).toBeInstanceOf(GerritEventRouter); + await startTestBackend({ + features: [eventsServiceFactory(), eventsModuleGerritEventRouter()], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GerritEventRouter'); }); }); diff --git a/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.ts b/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.ts index 780ff7f878..8d9792c4f4 100644 --- a/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.ts +++ b/plugins/events-backend-module-gerrit/src/service/eventsModuleGerritEventRouter.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { GerritEventRouter } from '../router/GerritEventRouter'; /** @@ -31,13 +31,11 @@ export const eventsModuleGerritEventRouter = createBackendModule({ register(env) { env.registerInit({ deps: { - events: eventsExtensionPoint, + events: eventsServiceRef, }, async init({ events }) { - const eventRouter = new GerritEventRouter(); - - events.addPublishers(eventRouter); - events.addSubscribers(eventRouter); + const eventRouter = new GerritEventRouter({ events }); + await eventRouter.subscribe(); }, }); }, diff --git a/plugins/events-backend-module-github/api-report.md b/plugins/events-backend-module-github/api-report.md index bcf9b8ed74..5341f86039 100644 --- a/plugins/events-backend-module-github/api-report.md +++ b/plugins/events-backend-module-github/api-report.md @@ -5,6 +5,7 @@ ```ts import { Config } from '@backstage/config'; import { EventParams } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { RequestValidator } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; @@ -15,8 +16,10 @@ export function createGithubSignatureValidator( // @public export class GithubEventRouter extends SubTopicEventRouter { - constructor(); + constructor(options: { events: EventsService }); // (undocumented) protected determineSubTopic(params: EventParams): string | undefined; + // (undocumented) + protected getSubscriberId(): string; } ``` diff --git a/plugins/events-backend-module-github/package.json b/plugins/events-backend-module-github/package.json index f78858d981..209167f64c 100644 --- a/plugins/events-backend-module-github/package.json +++ b/plugins/events-backend-module-github/package.json @@ -44,8 +44,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/plugin-events-node": "workspace:^", - "@octokit/webhooks-methods": "^3.0.0", - "winston": "^3.2.1" + "@octokit/webhooks-methods": "^3.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/events-backend-module-github/src/router/GithubEventRouter.test.ts b/plugins/events-backend-module-github/src/router/GithubEventRouter.test.ts index 14cf6b9933..47f7eeb99f 100644 --- a/plugins/events-backend-module-github/src/router/GithubEventRouter.test.ts +++ b/plugins/events-backend-module-github/src/router/GithubEventRouter.test.ts @@ -14,33 +14,40 @@ * limitations under the License. */ -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { GithubEventRouter } from './GithubEventRouter'; describe('GithubEventRouter', () => { - const eventRouter = new GithubEventRouter(); + const events = new TestEventsService(); + const eventRouter = new GithubEventRouter({ events: events }); const topic = 'github'; const eventPayload = { test: 'payload' }; const metadata = { 'x-github-event': 'test_type' }; - it('no x-github-event', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); + beforeEach(() => { + events.reset(); + }); + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GithubEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no x-github-event', () => { eventRouter.onEvent({ topic, eventPayload }); - expect(eventBroker.published).toEqual([]); + expect(events.published).toEqual([]); }); it('with x-github-event', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); - eventRouter.onEvent({ topic, eventPayload, metadata }); - expect(eventBroker.published.length).toBe(1); - expect(eventBroker.published[0].topic).toEqual('github.test_type'); - expect(eventBroker.published[0].eventPayload).toEqual(eventPayload); - expect(eventBroker.published[0].metadata).toEqual(metadata); + expect(events.published.length).toBe(1); + expect(events.published[0].topic).toEqual('github.test_type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); }); }); diff --git a/plugins/events-backend-module-github/src/router/GithubEventRouter.ts b/plugins/events-backend-module-github/src/router/GithubEventRouter.ts index 10dd1c55c6..767ed784f2 100644 --- a/plugins/events-backend-module-github/src/router/GithubEventRouter.ts +++ b/plugins/events-backend-module-github/src/router/GithubEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams, + EventsService, SubTopicEventRouter, } from '@backstage/plugin-events-node'; @@ -27,8 +28,15 @@ import { * @public */ export class GithubEventRouter extends SubTopicEventRouter { - constructor() { - super('github'); + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'github', + }); + } + + protected getSubscriberId(): string { + return 'GithubEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { diff --git a/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.test.ts b/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.test.ts index 02151d0dcf..f147bbcb69 100644 --- a/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.test.ts +++ b/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.test.ts @@ -14,32 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsModuleGithubEventRouter } from './eventsModuleGithubEventRouter'; -import { GithubEventRouter } from '../router/GithubEventRouter'; describe('eventsModuleGithubEventRouter', () => { it('should be correctly wired and set up', async () => { - let addedPublisher: GithubEventRouter | undefined; - let addedSubscriber: GithubEventRouter | undefined; - const extensionPoint = { - addPublishers: (publisher: any) => { - addedPublisher = publisher; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - addSubscribers: (subscriber: any) => { - addedSubscriber = subscriber; - }, - }; - - await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], - features: [eventsModuleGithubEventRouter()], }); - expect(addedPublisher).not.toBeUndefined(); - expect(addedPublisher).toBeInstanceOf(GithubEventRouter); - expect(addedSubscriber).not.toBeUndefined(); - expect(addedSubscriber).toBeInstanceOf(GithubEventRouter); + await startTestBackend({ + features: [eventsServiceFactory(), eventsModuleGithubEventRouter()], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GithubEventRouter'); }); }); diff --git a/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.ts b/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.ts index 093307dfaf..694b4b162d 100644 --- a/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.ts +++ b/plugins/events-backend-module-github/src/service/eventsModuleGithubEventRouter.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { GithubEventRouter } from '../router/GithubEventRouter'; /** @@ -31,13 +31,11 @@ export const eventsModuleGithubEventRouter = createBackendModule({ register(env) { env.registerInit({ deps: { - events: eventsExtensionPoint, + events: eventsServiceRef, }, async init({ events }) { - const eventRouter = new GithubEventRouter(); - - events.addPublishers(eventRouter); - events.addSubscribers(eventRouter); + const eventRouter = new GithubEventRouter({ events }); + await eventRouter.subscribe(); }, }); }, diff --git a/plugins/events-backend-module-gitlab/api-report.md b/plugins/events-backend-module-gitlab/api-report.md index 8a0c513857..f348436375 100644 --- a/plugins/events-backend-module-gitlab/api-report.md +++ b/plugins/events-backend-module-gitlab/api-report.md @@ -5,6 +5,7 @@ ```ts import { Config } from '@backstage/config'; import { EventParams } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { RequestValidator } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; @@ -13,8 +14,10 @@ export function createGitlabTokenValidator(config: Config): RequestValidator; // @public export class GitlabEventRouter extends SubTopicEventRouter { - constructor(); + constructor(options: { events: EventsService }); // (undocumented) protected determineSubTopic(params: EventParams): string | undefined; + // (undocumented) + protected getSubscriberId(): string; } ``` diff --git a/plugins/events-backend-module-gitlab/package.json b/plugins/events-backend-module-gitlab/package.json index f59b6e5faf..08abb12b8f 100644 --- a/plugins/events-backend-module-gitlab/package.json +++ b/plugins/events-backend-module-gitlab/package.json @@ -43,8 +43,7 @@ "dependencies": { "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", - "@backstage/plugin-events-node": "workspace:^", - "winston": "^3.2.1" + "@backstage/plugin-events-node": "workspace:^" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.test.ts b/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.test.ts index 6ced12d3cb..bc9da24cbe 100644 --- a/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.test.ts +++ b/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.test.ts @@ -14,37 +14,44 @@ * limitations under the License. */ -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { GitlabEventRouter } from './GitlabEventRouter'; describe('GitlabEventRouter', () => { - const eventRouter = new GitlabEventRouter(); + const events = new TestEventsService(); + const eventRouter = new GitlabEventRouter({ events: events }); const topic = 'gitlab'; const eventPayload = { event_name: 'test_type', test: 'payload' }; const metadata = {}; - it('no $.event_name', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); + beforeEach(() => { + events.reset(); + }); + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GitlabEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no $.event_name', () => { eventRouter.onEvent({ topic, eventPayload: { invalid: 'payload' }, metadata, }); - expect(eventBroker.published).toEqual([]); + expect(events.published).toEqual([]); }); it('with $.event_name', () => { - const eventBroker = new TestEventBroker(); - eventRouter.setEventBroker(eventBroker); - eventRouter.onEvent({ topic, eventPayload, metadata }); - expect(eventBroker.published.length).toBe(1); - expect(eventBroker.published[0].topic).toEqual('gitlab.test_type'); - expect(eventBroker.published[0].eventPayload).toEqual(eventPayload); - expect(eventBroker.published[0].metadata).toEqual(metadata); + expect(events.published.length).toBe(1); + expect(events.published[0].topic).toEqual('gitlab.test_type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); }); }); diff --git a/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.ts b/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.ts index 16324340ee..23b0389b55 100644 --- a/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.ts +++ b/plugins/events-backend-module-gitlab/src/router/GitlabEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams, + EventsService, SubTopicEventRouter, } from '@backstage/plugin-events-node'; @@ -27,8 +28,15 @@ import { * @public */ export class GitlabEventRouter extends SubTopicEventRouter { - constructor() { - super('gitlab'); + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'gitlab', + }); + } + + protected getSubscriberId(): string { + return 'GitlabEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { diff --git a/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.test.ts b/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.test.ts index 34a68ccbe4..9195be7a73 100644 --- a/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.test.ts +++ b/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.test.ts @@ -14,32 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsModuleGitlabEventRouter } from './eventsModuleGitlabEventRouter'; -import { GitlabEventRouter } from '../router/GitlabEventRouter'; describe('eventsModuleGitlabEventRouter', () => { it('should be correctly wired and set up', async () => { - let addedPublisher: GitlabEventRouter | undefined; - let addedSubscriber: GitlabEventRouter | undefined; - const extensionPoint = { - addPublishers: (publisher: any) => { - addedPublisher = publisher; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - addSubscribers: (subscriber: any) => { - addedSubscriber = subscriber; - }, - }; - - await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], - features: [eventsModuleGitlabEventRouter()], }); - expect(addedPublisher).not.toBeUndefined(); - expect(addedPublisher).toBeInstanceOf(GitlabEventRouter); - expect(addedSubscriber).not.toBeUndefined(); - expect(addedSubscriber).toBeInstanceOf(GitlabEventRouter); + await startTestBackend({ + features: [eventsServiceFactory(), eventsModuleGitlabEventRouter()], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('GitlabEventRouter'); }); }); diff --git a/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.ts b/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.ts index fc44e95057..66245efb58 100644 --- a/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.ts +++ b/plugins/events-backend-module-gitlab/src/service/eventsModuleGitlabEventRouter.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { GitlabEventRouter } from '../router/GitlabEventRouter'; /** @@ -31,13 +31,11 @@ export const eventsModuleGitlabEventRouter = createBackendModule({ register(env) { env.registerInit({ deps: { - events: eventsExtensionPoint, + events: eventsServiceRef, }, async init({ events }) { - const eventRouter = new GitlabEventRouter(); - - events.addPublishers(eventRouter); - events.addSubscribers(eventRouter); + const eventRouter = new GitlabEventRouter({ events: events }); + await eventRouter.subscribe(); }, }); }, diff --git a/plugins/events-node/api-report.md b/plugins/events-node/api-report.md index 081c56549a..9574d6c098 100644 --- a/plugins/events-node/api-report.md +++ b/plugins/events-node/api-report.md @@ -39,17 +39,17 @@ export interface EventPublisher { } // @public -export abstract class EventRouter implements EventPublisher, EventSubscriber { +export abstract class EventRouter { + protected constructor(options: { events: EventsService; topics: string[] }); // (undocumented) protected abstract determineDestinationTopic( params: EventParams, ): string | undefined; // (undocumented) + protected abstract getSubscriberId(): string; + // (undocumented) onEvent(params: EventParams): Promise; - // (undocumented) - setEventBroker(eventBroker: EventBroker): Promise; - // (undocumented) - abstract supportsEventTopics(): string[]; + subscribe(): Promise; } // @public @@ -114,12 +114,10 @@ export type RequestValidator = ( // @public export abstract class SubTopicEventRouter extends EventRouter { - protected constructor(topic: string); + protected constructor(options: { events: EventsService; topic: string }); // (undocumented) protected determineDestinationTopic(params: EventParams): string | undefined; // (undocumented) protected abstract determineSubTopic(params: EventParams): string | undefined; - // (undocumented) - supportsEventTopics(): string[]; } ``` diff --git a/plugins/events-node/src/api/EventRouter.test.ts b/plugins/events-node/src/api/EventRouter.test.ts index 551c5ea67d..f7709ead64 100644 --- a/plugins/events-node/src/api/EventRouter.test.ts +++ b/plugins/events-node/src/api/EventRouter.test.ts @@ -14,11 +14,19 @@ * limitations under the License. */ -import { EventBroker } from './EventBroker'; import { EventParams } from './EventParams'; import { EventRouter } from './EventRouter'; +import { EventsService } from './EventsService'; class TestEventRouter extends EventRouter { + constructor(events: EventsService) { + super({ events, topics: ['my-topic'] }); + } + + protected getSubscriberId(): string { + return 'TestEventRouter'; + } + protected determineDestinationTopic(params: EventParams): string | undefined { const payload = params.eventPayload as { value?: number }; if (payload.value === undefined) { @@ -27,26 +35,21 @@ class TestEventRouter extends EventRouter { return payload.value % 2 === 0 ? 'even' : 'odd'; } - - supportsEventTopics(): string[] { - return ['my-topic']; - } } describe('EventRouter', () => { - const eventRouter = new TestEventRouter(); + const published: EventParams[] = []; + const events: EventsService = { + publish: async event => { + published.push(event); + }, + subscribe: async _subscription => {}, + }; + const eventRouter = new TestEventRouter(events); const topic = 'my-topic'; const metadata = { random: 'metadata' }; it('no destination topic', async () => { - const published: EventParams[] = []; - const eventBroker = { - publish: (params: EventParams) => { - published.push(params); - }, - } as EventBroker; - await eventRouter.setEventBroker(eventBroker); - await eventRouter.onEvent({ topic, eventPayload: { discarded: 'event' }, @@ -57,14 +60,6 @@ describe('EventRouter', () => { }); it('with destination topic', async () => { - const published: EventParams[] = []; - const eventBroker = { - publish: (params: EventParams) => { - published.push(params); - }, - } as EventBroker; - await eventRouter.setEventBroker(eventBroker); - const payloadEven = { value: 2 }; const payloadOdd = { value: 3 }; await eventRouter.onEvent({ topic, eventPayload: payloadEven, metadata }); diff --git a/plugins/events-node/src/api/EventRouter.ts b/plugins/events-node/src/api/EventRouter.ts index b435ef15f4..5e4492d25f 100644 --- a/plugins/events-node/src/api/EventRouter.ts +++ b/plugins/events-node/src/api/EventRouter.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import { EventBroker } from './EventBroker'; import { EventParams } from './EventParams'; -import { EventPublisher } from './EventPublisher'; -import { EventSubscriber } from './EventSubscriber'; +import { EventsService } from './EventsService'; /** * Subscribes to a topic and - depending on a set of conditions - @@ -26,13 +24,41 @@ import { EventSubscriber } from './EventSubscriber'; * @see {@link https://www.enterpriseintegrationpatterns.com/MessageRouter.html | Message Router pattern}. * @public */ -export abstract class EventRouter implements EventPublisher, EventSubscriber { - private eventBroker?: EventBroker; +export abstract class EventRouter { + private readonly events: EventsService; + private readonly topics: string[]; + private subscribed: boolean = false; + + protected constructor(options: { events: EventsService; topics: string[] }) { + this.events = options.events; + this.topics = options.topics; + } + + protected abstract getSubscriberId(): string; protected abstract determineDestinationTopic( params: EventParams, ): string | undefined; + /** + * Subscribes itself to the topic(s), + * after which events potentially can be received + * and processed by {@link EventRouter.onEvent}. + */ + async subscribe(): Promise { + if (this.subscribed) { + return; + } + + this.subscribed = true; + + await this.events.subscribe({ + id: this.getSubscriberId(), + topics: this.topics, + onEvent: this.onEvent.bind(this), + }); + } + async onEvent(params: EventParams): Promise { const topic = this.determineDestinationTopic(params); @@ -41,15 +67,9 @@ export abstract class EventRouter implements EventPublisher, EventSubscriber { } // republish to different topic - this.eventBroker?.publish({ + await this.events.publish({ ...params, topic, }); } - - async setEventBroker(eventBroker: EventBroker): Promise { - this.eventBroker = eventBroker; - } - - abstract supportsEventTopics(): string[]; } diff --git a/plugins/events-node/src/api/SubTopicEventRouter.test.ts b/plugins/events-node/src/api/SubTopicEventRouter.test.ts index d5c79895a5..6298e1549d 100644 --- a/plugins/events-node/src/api/SubTopicEventRouter.test.ts +++ b/plugins/events-node/src/api/SubTopicEventRouter.test.ts @@ -14,13 +14,17 @@ * limitations under the License. */ -import { EventBroker } from './EventBroker'; import { EventParams } from './EventParams'; +import { EventsService } from './EventsService'; import { SubTopicEventRouter } from './SubTopicEventRouter'; class TestSubTopicEventRouter extends SubTopicEventRouter { - constructor() { - super('my-topic'); + constructor(events: EventsService) { + super({ events, topic: 'my-topic' }); + } + + protected getSubscriberId(): string { + return 'TestSubTopicEventRouter'; } protected determineSubTopic(params: EventParams): string | undefined { @@ -29,34 +33,25 @@ class TestSubTopicEventRouter extends SubTopicEventRouter { } describe('SubTopicEventRouter', () => { - const eventRouter = new TestSubTopicEventRouter(); + const published: EventParams[] = []; + const events: EventsService = { + publish: async event => { + published.push(event); + }, + subscribe: async _subscription => {}, + }; + const eventRouter = new TestSubTopicEventRouter(events); const topic = 'my-topic'; const eventPayload = { test: 'payload' }; const metadata = { 'x-my-event': 'test.type' }; it('no x-my-event', async () => { - const published: EventParams[] = []; - const eventBroker = { - publish: (params: EventParams) => { - published.push(params); - }, - } as EventBroker; - await eventRouter.setEventBroker(eventBroker); - await eventRouter.onEvent({ topic, eventPayload }); expect(published).toEqual([]); }); it('with x-my-event', async () => { - const published: EventParams[] = []; - const eventBroker = { - publish: (params: EventParams) => { - published.push(params); - }, - } as EventBroker; - await eventRouter.setEventBroker(eventBroker); - await eventRouter.onEvent({ topic, eventPayload, metadata }); expect(published.length).toBe(1); diff --git a/plugins/events-node/src/api/SubTopicEventRouter.ts b/plugins/events-node/src/api/SubTopicEventRouter.ts index 04abe14009..5a96ad6788 100644 --- a/plugins/events-node/src/api/SubTopicEventRouter.ts +++ b/plugins/events-node/src/api/SubTopicEventRouter.ts @@ -16,6 +16,7 @@ import { EventParams } from './EventParams'; import { EventRouter } from './EventRouter'; +import { EventsService } from './EventsService'; /** * Subscribes to the provided (generic) topic @@ -27,8 +28,11 @@ import { EventRouter } from './EventRouter'; * @public */ export abstract class SubTopicEventRouter extends EventRouter { - protected constructor(private readonly topic: string) { - super(); + protected constructor(options: { events: EventsService; topic: string }) { + super({ + events: options.events, + topics: [options.topic], + }); } protected abstract determineSubTopic(params: EventParams): string | undefined; @@ -37,8 +41,4 @@ export abstract class SubTopicEventRouter extends EventRouter { const subTopic = this.determineSubTopic(params); return subTopic ? `${params.topic}.${subTopic}` : undefined; } - - supportsEventTopics(): string[] { - return [this.topic]; - } } diff --git a/yarn.lock b/yarn.lock index f49f9f73c5..39bfa662d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6404,7 +6404,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" - winston: ^3.2.1 languageName: unknown linkType: soft @@ -6417,7 +6416,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" - winston: ^3.2.1 languageName: unknown linkType: soft @@ -6430,7 +6428,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" - winston: ^3.2.1 languageName: unknown linkType: soft @@ -6445,7 +6442,6 @@ __metadata: "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" "@octokit/webhooks-methods": ^3.0.0 - winston: ^3.2.1 languageName: unknown linkType: soft @@ -6459,7 +6455,6 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" - winston: ^3.2.1 languageName: unknown linkType: soft From c4bd79422ab24f92bf706a79ccc1fb11446ff932 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 20:28:35 +0100 Subject: [PATCH 094/176] feat(events)!: migrate `HttpPostIngressEventPublisher` and `eventsPlugin` to use `EventsService` Signed-off-by: Patrick Jungermann --- .changeset/young-flies-wash.md | 35 +++++++++++ packages/backend/src/index.ts | 8 ++- packages/backend/src/plugins/events.ts | 11 +--- packages/backend/src/types.ts | 6 +- plugins/events-backend/api-report.md | 7 +-- .../src/service/EventsPlugin.test.ts | 56 +++++++++++------- .../src/service/EventsPlugin.ts | 59 ++++++------------- .../HttpPostIngressEventPublisher.test.ts | 40 ++++++------- .../http/HttpPostIngressEventPublisher.ts | 23 +++----- 9 files changed, 132 insertions(+), 113 deletions(-) create mode 100644 .changeset/young-flies-wash.md diff --git a/.changeset/young-flies-wash.md b/.changeset/young-flies-wash.md new file mode 100644 index 0000000000..dcedc9bb7c --- /dev/null +++ b/.changeset/young-flies-wash.md @@ -0,0 +1,35 @@ +--- +'@backstage/plugin-events-backend': minor +--- + +BREAKING CHANGE: Migrate `HttpPostIngressEventPublisher` and `eventsPlugin` to use `EventsService`. + +Uses the `EventsService` instead of `EventBroker` at `HttpPostIngressEventPublisher`, +dropping the use of `EventPublisher` including `setEventBroker(..)`. + +Now, `HttpPostIngressEventPublisher.fromConfig` requires `events: EventsService` as option. + +```diff + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, ++ events: env.events, + logger: env.logger, + }); + http.bind(eventsRouter); + + // e.g. at packages/backend/src/plugins/events.ts +- await new EventsBackend(env.logger) +- .setEventBroker(env.eventBroker) +- .addPublishers(http) +- .start(); + + // or for other kinds of setups +- await Promise.all(http.map(publisher => publisher.setEventBroker(eventBroker))); +``` + +`eventsPlugin` uses the `eventsServiceRef` as dependency. +Unsupported (and deprecated) extension point methods will throw an error to prevent unintended behavior. + +```ts +import { eventsServiceRef } from '@backstage/plugin-events-node'; +``` diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bade028597..8931e6832b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -70,6 +70,7 @@ import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { DefaultEventBroker } from '@backstage/plugin-events-backend'; +import { DefaultEventsService } from '@backstage/plugin-events-node'; import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; @@ -99,7 +100,11 @@ function makeCreateEnv(config: Config) { discovery, }); - const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); + const eventsService = DefaultEventsService.create({ logger: root }); + const eventBroker = new DefaultEventBroker( + root.child({ type: 'plugin' }), + eventsService, + ); const signalService = DefaultSignalService.create({ eventBroker, }); @@ -119,6 +124,7 @@ function makeCreateEnv(config: Config) { config, reader, eventBroker, + events: eventsService, discovery, tokenManager, permissions, diff --git a/packages/backend/src/plugins/events.ts b/packages/backend/src/plugins/events.ts index f3ff354240..fd60a9bb14 100644 --- a/packages/backend/src/plugins/events.ts +++ b/packages/backend/src/plugins/events.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - EventsBackend, - HttpPostIngressEventPublisher, -} from '@backstage/plugin-events-backend'; +import { HttpPostIngressEventPublisher } from '@backstage/plugin-events-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -28,14 +25,10 @@ export default async function createPlugin( const http = HttpPostIngressEventPublisher.fromConfig({ config: env.config, + events: env.events, logger: env.logger, }); http.bind(eventsRouter); - await new EventsBackend(env.logger) - .setEventBroker(env.eventBroker) - .addPublishers(http) - .start(); - return eventsRouter; } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d76e68c1c9..7d9cd19310 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -26,7 +26,7 @@ import { import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; -import { EventBroker } from '@backstage/plugin-events-node'; +import { EventBroker, EventsService } from '@backstage/plugin-events-node'; import { SignalService } from '@backstage/plugin-signals-node'; export type PluginEnvironment = { @@ -40,6 +40,10 @@ export type PluginEnvironment = { permissions: PermissionEvaluator; scheduler: PluginTaskScheduler; identity: IdentityApi; + /** + * @deprecated use `events` instead + */ eventBroker: EventBroker; + events: EventsService; signalService: SignalService; }; diff --git a/plugins/events-backend/api-report.md b/plugins/events-backend/api-report.md index 9fffe719ba..8b5d4e8d31 100644 --- a/plugins/events-backend/api-report.md +++ b/plugins/events-backend/api-report.md @@ -43,18 +43,17 @@ export class EventsBackend { } // @public -export class HttpPostIngressEventPublisher implements EventPublisher { +export class HttpPostIngressEventPublisher { // (undocumented) bind(router: express.Router): void; // (undocumented) static fromConfig(env: { config: Config; + events: EventsService; ingresses?: { [topic: string]: Omit; }; - logger: Logger; + logger: LoggerService; }): HttpPostIngressEventPublisher; - // (undocumented) - setEventBroker(eventBroker: EventBroker): Promise; } ``` diff --git a/plugins/events-backend/src/service/EventsPlugin.test.ts b/plugins/events-backend/src/service/EventsPlugin.test.ts index 7f555a0b6f..e39951e8dc 100644 --- a/plugins/events-backend/src/service/EventsPlugin.test.ts +++ b/plugins/events-backend/src/service/EventsPlugin.test.ts @@ -14,22 +14,27 @@ * limitations under the License. */ -import { createBackendModule } from '@backstage/backend-plugin-api'; -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; import { - TestEventBroker, - TestEventPublisher, - TestEventSubscriber, -} from '@backstage/plugin-events-backend-test-utils'; + createBackendModule, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import request from 'supertest'; import { eventsPlugin } from './EventsPlugin'; -describe('eventPlugin', () => { +describe('eventsPlugin', () => { it('should be initialized properly', async () => { - const eventBroker = new TestEventBroker(); - const publisher = new TestEventPublisher(); - const subscriber = new TestEventSubscriber('sub', ['fake']); + const eventsService = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return eventsService; + }, + }); const testModule = createBackendModule({ pluginId: 'events', @@ -40,9 +45,9 @@ describe('eventPlugin', () => { events: eventsExtensionPoint, }, async init({ events }) { - events.setEventBroker(eventBroker); - events.addPublishers(publisher); - events.addSubscribers(subscriber); + events.addHttpPostIngress({ + topic: 'fake-ext', + }); }, }); }, @@ -51,6 +56,7 @@ describe('eventPlugin', () => { const { server } = await startTestBackend({ extensionPoints: [], features: [ + eventsServiceFactory(), eventsPlugin(), testModule(), mockServices.logger.factory(), @@ -66,18 +72,24 @@ describe('eventPlugin', () => { ], }); - expect(publisher.eventBroker).toBe(eventBroker); - expect(eventBroker.subscribed.length).toEqual(1); - expect(eventBroker.subscribed[0]).toBe(subscriber); - - const response = await request(server) + const response1 = await request(server) .post('/api/events/http/fake') .timeout(1000) .send({ test: 'fake' }); - expect(response.status).toBe(202); + expect(response1.status).toBe(202); - expect(eventBroker.published.length).toEqual(1); - expect(eventBroker.published[0].topic).toEqual('fake'); - expect(eventBroker.published[0].eventPayload).toEqual({ test: 'fake' }); + const response2 = await request(server) + .post('/api/events/http/fake-ext') + .timeout(1000) + .send({ test: 'fake-ext' }); + expect(response2.status).toBe(202); + + expect(eventsService.published).toHaveLength(2); + expect(eventsService.published[0].topic).toEqual('fake'); + expect(eventsService.published[0].eventPayload).toEqual({ test: 'fake' }); + expect(eventsService.published[1].topic).toEqual('fake-ext'); + expect(eventsService.published[1].eventPayload).toEqual({ + test: 'fake-ext', + }); }); }); diff --git a/plugins/events-backend/src/service/EventsPlugin.ts b/plugins/events-backend/src/service/EventsPlugin.ts index 27456b5b2a..5e1df975c5 100644 --- a/plugins/events-backend/src/service/EventsPlugin.ts +++ b/plugins/events-backend/src/service/EventsPlugin.ts @@ -18,59 +18,42 @@ import { createBackendPlugin, coreServices, } from '@backstage/backend-plugin-api'; -import { loggerToWinstonLogger } from '@backstage/backend-common'; import { eventsExtensionPoint, EventsExtensionPoint, } from '@backstage/plugin-events-node/alpha'; import { - EventBroker, - EventPublisher, - EventSubscriber, + eventsServiceRef, HttpPostIngressOptions, } from '@backstage/plugin-events-node'; -import { DefaultEventBroker } from './DefaultEventBroker'; import Router from 'express-promise-router'; import { HttpPostIngressEventPublisher } from './http'; class EventsExtensionPointImpl implements EventsExtensionPoint { - #eventBroker: EventBroker | undefined; #httpPostIngresses: HttpPostIngressOptions[] = []; - #publishers: EventPublisher[] = []; - #subscribers: EventSubscriber[] = []; - setEventBroker(eventBroker: EventBroker): void { - this.#eventBroker = eventBroker; + setEventBroker(_: any): void { + throw new Error( + 'setEventBroker is not supported anymore; use eventsServiceRef instead', + ); } - addPublishers( - ...publishers: Array> - ): void { - this.#publishers.push(...publishers.flat()); + addPublishers(_: any): void { + throw new Error( + 'addPublishers is not supported anymore; use EventsService instead', + ); } - addSubscribers( - ...subscribers: Array> - ): void { - this.#subscribers.push(...subscribers.flat()); + addSubscribers(_: any): void { + throw new Error( + 'addSubscribers is not supported anymore; use EventsService instead', + ); } addHttpPostIngress(options: HttpPostIngressOptions) { this.#httpPostIngresses.push(options); } - get eventBroker() { - return this.#eventBroker; - } - - get publishers() { - return this.#publishers; - } - - get subscribers() { - return this.#subscribers; - } - get httpPostIngresses() { return this.#httpPostIngresses; } @@ -90,12 +73,11 @@ export const eventsPlugin = createBackendPlugin({ env.registerInit({ deps: { config: coreServices.rootConfig, + events: eventsServiceRef, logger: coreServices.logger, router: coreServices.httpRouter, }, - async init({ config, logger, router }) { - const winstonLogger = loggerToWinstonLogger(logger); - + async init({ config, events, logger, router }) { const ingresses = Object.fromEntries( extensionPoint.httpPostIngresses.map(ingress => [ ingress.topic, @@ -105,20 +87,13 @@ export const eventsPlugin = createBackendPlugin({ const http = HttpPostIngressEventPublisher.fromConfig({ config, + events, ingresses, - logger: winstonLogger, + logger, }); const eventsRouter = Router(); http.bind(eventsRouter); router.use(eventsRouter); - - const eventBroker = - extensionPoint.eventBroker ?? new DefaultEventBroker(winstonLogger); - - eventBroker.subscribe(extensionPoint.subscribers); - [extensionPoint.publishers, http] - .flat() - .forEach(publisher => publisher.setEventBroker(eventBroker)); }, }); }, diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts index 72e0b94c57..665ac0b4a9 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts @@ -16,7 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import express from 'express'; import Router from 'express-promise-router'; import request from 'supertest'; @@ -36,9 +36,11 @@ describe('HttpPostIngressEventPublisher', () => { const router = Router(); const app = express().use(router); + const events = new TestEventsService(); const publisher = HttpPostIngressEventPublisher.fromConfig({ config, + events, ingresses: { testB: {}, }, @@ -46,9 +48,6 @@ describe('HttpPostIngressEventPublisher', () => { }); publisher.bind(router); - const eventBroker = new TestEventBroker(); - await publisher.setEventBroker(eventBroker); - const notFoundResponse = await request(app) .post('/http/unknown') .timeout(1000) @@ -69,18 +68,18 @@ describe('HttpPostIngressEventPublisher', () => { .send({ testB: 'data' }); expect(response2.status).toBe(202); - expect(eventBroker.published.length).toEqual(2); - expect(eventBroker.published[0].topic).toEqual('testA'); - expect(eventBroker.published[0].eventPayload).toEqual({ testA: 'data' }); - expect(eventBroker.published[0].metadata).toEqual( + expect(events.published).toHaveLength(2); + expect(events.published[0].topic).toEqual('testA'); + expect(events.published[0].eventPayload).toEqual({ testA: 'data' }); + expect(events.published[0].metadata).toEqual( expect.objectContaining({ 'content-type': 'application/json', 'x-custom-header': 'test-value', }), ); - expect(eventBroker.published[1].topic).toEqual('testB'); - expect(eventBroker.published[1].eventPayload).toEqual({ testB: 'data' }); - expect(eventBroker.published[1].metadata).toEqual( + expect(events.published[1].topic).toEqual('testB'); + expect(events.published[1].eventPayload).toEqual({ testB: 'data' }); + expect(events.published[1].metadata).toEqual( expect.objectContaining({ 'content-type': 'application/json', 'x-custom-header': 'test-value', @@ -99,9 +98,11 @@ describe('HttpPostIngressEventPublisher', () => { const router = Router(); const app = express().use(router); + const events = new TestEventsService(); const publisher = HttpPostIngressEventPublisher.fromConfig({ config, + events, ingresses: { testB: { validator: async (req, context) => { @@ -146,9 +147,6 @@ describe('HttpPostIngressEventPublisher', () => { }); publisher.bind(router); - const eventBroker = new TestEventBroker(); - await publisher.setEventBroker(eventBroker); - const response1 = await request(app) .post('/http/testA') .timeout(1000) @@ -191,12 +189,12 @@ describe('HttpPostIngressEventPublisher', () => { expect(response6.status).toBe(403); expect(response6.body).toEqual({}); - expect(eventBroker.published.length).toEqual(2); - expect(eventBroker.published[0].topic).toEqual('testA'); - expect(eventBroker.published[0].eventPayload).toEqual({ test: 'data' }); - expect(eventBroker.published[1].topic).toEqual('testB'); - expect(eventBroker.published[1].eventPayload).toEqual({ test: 'data' }); - expect(eventBroker.published[1].metadata).toEqual( + expect(events.published).toHaveLength(2); + expect(events.published[0].topic).toEqual('testA'); + expect(events.published[0].eventPayload).toEqual({ test: 'data' }); + expect(events.published[1].topic).toEqual('testB'); + expect(events.published[1].eventPayload).toEqual({ test: 'data' }); + expect(events.published[1].metadata).toEqual( expect.objectContaining({ 'x-test-signature': 'testB-signature', }), @@ -205,10 +203,12 @@ describe('HttpPostIngressEventPublisher', () => { it('without configuration', async () => { const config = new ConfigReader({}); + const events = new TestEventsService(); expect(() => HttpPostIngressEventPublisher.fromConfig({ config, + events, logger, }), ).not.toThrow(); diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts index b5a6ccbca1..06dc4e463a 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts @@ -15,16 +15,15 @@ */ import { errorHandler } from '@backstage/backend-common'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { - EventBroker, - EventPublisher, + EventsService, HttpPostIngressOptions, RequestValidator, } from '@backstage/plugin-events-node'; import express from 'express'; import Router from 'express-promise-router'; -import { Logger } from 'winston'; import { RequestValidationContextImpl } from './validation'; /** @@ -34,13 +33,12 @@ import { RequestValidationContextImpl } from './validation'; * @public */ // TODO(pjungermann): add prom metrics? (see plugins/catalog-backend/src/util/metrics.ts, etc.) -export class HttpPostIngressEventPublisher implements EventPublisher { - private eventBroker?: EventBroker; - +export class HttpPostIngressEventPublisher { static fromConfig(env: { config: Config; + events: EventsService; ingresses?: { [topic: string]: Omit }; - logger: Logger; + logger: LoggerService; }): HttpPostIngressEventPublisher { const topics = env.config.getOptionalStringArray('events.http.topics') ?? []; @@ -54,11 +52,12 @@ export class HttpPostIngressEventPublisher implements EventPublisher { } }); - return new HttpPostIngressEventPublisher(env.logger, ingresses); + return new HttpPostIngressEventPublisher(env.events, env.logger, ingresses); } private constructor( - private readonly logger: Logger, + private readonly events: EventsService, + private readonly logger: LoggerService, private readonly ingresses: { [topic: string]: Omit; }, @@ -68,10 +67,6 @@ export class HttpPostIngressEventPublisher implements EventPublisher { router.use('/http', this.createRouter(this.ingresses)); } - async setEventBroker(eventBroker: EventBroker): Promise { - this.eventBroker = eventBroker; - } - private createRouter(ingresses: { [topic: string]: Omit; }): express.Router { @@ -108,7 +103,7 @@ export class HttpPostIngressEventPublisher implements EventPublisher { } const eventPayload = request.body; - await this.eventBroker!.publish({ + await this.events.publish({ topic, eventPayload, metadata: request.headers, From 8f6afa94a9dc80ae11d5c1acd3ad70e67c9134f1 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 20:40:22 +0100 Subject: [PATCH 095/176] chore(events): migrate `DemoEventBasedEntityProvider` to use `EventsService` Signed-off-by: Patrick Jungermann --- .../plugins/DemoEventBasedEntityProvider.ts | 42 +++++++++---------- packages/backend/src/plugins/catalog.ts | 3 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/plugins/DemoEventBasedEntityProvider.ts b/packages/backend/src/plugins/DemoEventBasedEntityProvider.ts index 7a032198a3..11d073400a 100644 --- a/packages/backend/src/plugins/DemoEventBasedEntityProvider.ts +++ b/packages/backend/src/plugins/DemoEventBasedEntityProvider.ts @@ -18,40 +18,36 @@ import { EntityProvider, EntityProviderConnection, } from '@backstage/plugin-catalog-node'; -import { - EventBroker, - EventParams, - EventSubscriber, -} from '@backstage/plugin-events-node'; +import { EventParams, EventsService } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; -export class DemoEventBasedEntityProvider - implements EntityProvider, EventSubscriber -{ +export class DemoEventBasedEntityProvider implements EntityProvider { private readonly logger: Logger; + private readonly events: EventsService; private readonly topics: string[]; constructor(opts: { - eventBroker: EventBroker; + events: EventsService; logger: Logger; topics: string[]; }) { - const { eventBroker, logger, topics } = opts; - this.logger = logger; - this.topics = topics; - eventBroker.subscribe(this); + this.events = opts.events; + this.logger = opts.logger; + this.topics = opts.topics; } - async onEvent(params: EventParams): Promise { - this.logger.info( - `onEvent: topic=${params.topic}, metadata=${JSON.stringify( - params.metadata, - )}, payload=${JSON.stringify(params.eventPayload)}`, - ); - } - - supportsEventTopics(): string[] { - return this.topics; + async subscribe() { + await this.events.subscribe({ + id: 'DemoEventBasedEntityProvider', + topics: this.topics, + onEvent: async (params: EventParams): Promise => { + this.logger.info( + `onEvent: topic=${params.topic}, metadata=${JSON.stringify( + params.metadata, + )}, payload=${JSON.stringify(params.eventPayload)}`, + ); + }, + }); } async connect(_: EntityProviderConnection): Promise { diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 223acab818..00fe7ff4a0 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -28,10 +28,11 @@ export default async function createPlugin( builder.addProcessor(new ScaffolderEntitiesProcessor()); const demoProvider = new DemoEventBasedEntityProvider({ + events: env.events, logger: env.logger, topics: ['example'], - eventBroker: env.eventBroker, }); + await demoProvider.subscribe(); builder.addEntityProvider(demoProvider); const { processingEngine, router } = await builder.build(); From 132d672747d688d81c5eab76b2241712bf697b30 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 21:01:42 +0100 Subject: [PATCH 096/176] feat(events)!: migrate `AwsSqsConsumingEventPublisher` and its backend module to use `EventsService` Signed-off-by: Patrick Jungermann --- .changeset/long-emus-talk.md | 32 +++++++++++++++++++ .../api-report.md | 12 +++---- .../package.json | 3 +- .../AwsSqsConsumingEventPublisher.test.ts | 30 +++++++++-------- .../AwsSqsConsumingEventPublisher.ts | 29 +++++++++-------- ...oduleAwsSqsConsumingEventPublisher.test.ts | 28 +++++++--------- ...entsModuleAwsSqsConsumingEventPublisher.ts | 15 ++++----- yarn.lock | 1 - 8 files changed, 88 insertions(+), 62 deletions(-) create mode 100644 .changeset/long-emus-talk.md diff --git a/.changeset/long-emus-talk.md b/.changeset/long-emus-talk.md new file mode 100644 index 0000000000..ac8d5cbc68 --- /dev/null +++ b/.changeset/long-emus-talk.md @@ -0,0 +1,32 @@ +--- +'@backstage/plugin-events-backend-module-aws-sqs': minor +--- + +BREAKING CHANGE: Migrate `AwsSqsConsumingEventPublisher` and its backend module to use `EventsService`. + +Uses the `EventsService` instead of `EventBroker` at `AwsSqsConsumingEventPublisher`, +dropping the use of `EventPublisher` including `setEventBroker(..)`. + +Now, `AwsSqsConsumingEventPublisher.fromConfig` requires `events: EventsService` as option. + +```diff + const sqs = AwsSqsConsumingEventPublisher.fromConfig({ + config: env.config, ++ events: env.events, + logger: env.logger, + scheduler: env.scheduler, + }); ++ await Promise.all(sqs.map(publisher => publisher.start())); + + // e.g. at packages/backend/src/plugins/events.ts +- await new EventsBackend(env.logger) +- .setEventBroker(env.eventBroker) +- .addPublishers(sqs) +- .start(); + + // or for other kinds of setups +- await Promise.all(sqs.map(publisher => publisher.setEventBroker(eventBroker))); +``` + +`eventsModuleAwsSqsConsumingEventPublisher` uses the `eventsServiceRef` as dependency, +instead of `eventsExtensionPoint`. diff --git a/plugins/events-backend-module-aws-sqs/api-report.md b/plugins/events-backend-module-aws-sqs/api-report.md index ffa9c888d6..ff863e4d8d 100644 --- a/plugins/events-backend-module-aws-sqs/api-report.md +++ b/plugins/events-backend-module-aws-sqs/api-report.md @@ -4,20 +4,20 @@ ```ts import { Config } from '@backstage/config'; -import { EventBroker } from '@backstage/plugin-events-node'; -import { EventPublisher } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; +import { EventsService } from '@backstage/plugin-events-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; // @public -export class AwsSqsConsumingEventPublisher implements EventPublisher { +export class AwsSqsConsumingEventPublisher { // (undocumented) static fromConfig(env: { config: Config; - logger: Logger; + events: EventsService; + logger: LoggerService; scheduler: PluginTaskScheduler; }): AwsSqsConsumingEventPublisher[]; // (undocumented) - setEventBroker(eventBroker: EventBroker): Promise; + start(): Promise; } ``` diff --git a/plugins/events-backend-module-aws-sqs/package.json b/plugins/events-backend-module-aws-sqs/package.json index 0b70eb57bf..41f91d804e 100644 --- a/plugins/events-backend-module-aws-sqs/package.json +++ b/plugins/events-backend-module-aws-sqs/package.json @@ -48,8 +48,7 @@ "@backstage/config": "workspace:^", "@backstage/plugin-events-node": "workspace:^", "@backstage/types": "workspace:^", - "luxon": "^3.0.0", - "winston": "^3.2.1" + "luxon": "^3.0.0" }, "devDependencies": { "@aws-sdk/types": "^3.347.0", diff --git a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.test.ts b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.test.ts index e32245b483..60930fdcf7 100644 --- a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.test.ts +++ b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.test.ts @@ -22,7 +22,7 @@ import { import { getVoidLogger } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { ConfigReader } from '@backstage/config'; -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { mockClient } from 'aws-sdk-client-mock'; import { AwsSqsConsumingEventPublisher } from './AwsSqsConsumingEventPublisher'; @@ -53,12 +53,14 @@ describe('AwsSqsConsumingEventPublisher', () => { }, }); const logger = getVoidLogger(); + const events = new TestEventsService(); const scheduler = { scheduleTask: jest.fn(), } as unknown as PluginTaskScheduler; const publishers = AwsSqsConsumingEventPublisher.fromConfig({ config, + events, logger, scheduler, }); @@ -85,21 +87,21 @@ describe('AwsSqsConsumingEventPublisher', () => { }, }); const logger = getVoidLogger(); + const events = new TestEventsService(); const scheduler = { scheduleTask: jest.fn(), } as unknown as PluginTaskScheduler; const publishers = AwsSqsConsumingEventPublisher.fromConfig({ config, + events, logger, scheduler, }); expect(publishers.length).toEqual(1); const publisher = publishers[0]; - - const eventBroker = new TestEventBroker(); - await publisher.setEventBroker(eventBroker); + await publisher.start(); // publisher.connect(..) was causing the polling for events to be scheduled expect(scheduler.scheduleTask).toHaveBeenCalledWith( @@ -133,6 +135,7 @@ describe('AwsSqsConsumingEventPublisher', () => { }, }); const logger = getVoidLogger(); + const events = new TestEventsService(); let taskFn: (() => Promise) | undefined = undefined; const scheduler = { scheduleTask: (spec: { fn: () => Promise }) => { @@ -196,32 +199,31 @@ describe('AwsSqsConsumingEventPublisher', () => { const publishers = AwsSqsConsumingEventPublisher.fromConfig({ config, + events, logger, scheduler, }); expect(publishers.length).toEqual(1); const publisher = publishers[0]; - - const eventBroker = new TestEventBroker(); - await publisher.setEventBroker(eventBroker); + await publisher.start(); await taskFn!(); await taskFn!(); await taskFn!(); - expect(eventBroker.published.length).toEqual(2); - expect(eventBroker.published[0].topic).toEqual('fake1'); - expect(eventBroker.published[0].eventPayload).toEqual({ + expect(events.published).toHaveLength(2); + expect(events.published[0].topic).toEqual('fake1'); + expect(events.published[0].eventPayload).toEqual({ event: 'payload1', }); - expect(eventBroker.published[0].metadata).toEqual({ + expect(events.published[0].metadata).toEqual({ 'X-Custom-Attr': 'value', }); - expect(eventBroker.published[1].topic).toEqual('fake1'); - expect(eventBroker.published[1].eventPayload).toEqual({ + expect(events.published[1].topic).toEqual('fake1'); + expect(events.published[1].eventPayload).toEqual({ event: 'payload2', }); - expect(eventBroker.published[1].metadata).toEqual({}); + expect(events.published[1].metadata).toEqual({}); }); }); diff --git a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts index a2f6a00fe7..26eba88c18 100644 --- a/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts +++ b/plugins/events-backend-module-aws-sqs/src/publisher/AwsSqsConsumingEventPublisher.ts @@ -21,10 +21,10 @@ import { ReceiveMessageCommandInput, SQSClient, } from '@aws-sdk/client-sqs'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { Config } from '@backstage/config'; -import { EventBroker, EventPublisher } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; +import { EventsService } from '@backstage/plugin-events-node'; import { AwsSqsEventSourceConfig, readConfig } from './config'; /** @@ -34,28 +34,34 @@ import { AwsSqsEventSourceConfig, readConfig } from './config'; * @public */ // TODO(pjungermann): add prom metrics? (see plugins/catalog-backend/src/util/metrics.ts, etc.) -export class AwsSqsConsumingEventPublisher implements EventPublisher { +export class AwsSqsConsumingEventPublisher { private readonly topic: string; private readonly receiveParams: ReceiveMessageCommandInput; private readonly sqs: SQSClient; private readonly queueUrl: string; private readonly taskTimeoutSeconds: number; private readonly waitTimeAfterEmptyReceiveMs; - private eventBroker?: EventBroker; static fromConfig(env: { config: Config; - logger: Logger; + events: EventsService; + logger: LoggerService; scheduler: PluginTaskScheduler; }): AwsSqsConsumingEventPublisher[] { return readConfig(env.config).map( config => - new AwsSqsConsumingEventPublisher(env.logger, env.scheduler, config), + new AwsSqsConsumingEventPublisher( + env.logger, + env.events, + env.scheduler, + config, + ), ); } private constructor( - private readonly logger: Logger, + private readonly logger: LoggerService, + private readonly events: EventsService, private readonly scheduler: PluginTaskScheduler, config: AwsSqsEventSourceConfig, ) { @@ -80,12 +86,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher { config.waitTimeAfterEmptyReceive.as('milliseconds'); } - async setEventBroker(eventBroker: EventBroker): Promise { - this.eventBroker = eventBroker; - return this.start(); - } - - private async start(): Promise { + async start(): Promise { const id = `events.awsSqs.publisher:${this.topic}`; const logger = this.logger.child({ class: AwsSqsConsumingEventPublisher.prototype.constructor.name, @@ -172,7 +173,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher { } }); - this.eventBroker!.publish({ + this.events.publish({ topic: this.topic, eventPayload, metadata, diff --git a/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.test.ts b/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.test.ts index 57e1831667..cbf29f96cd 100644 --- a/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.test.ts +++ b/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.test.ts @@ -14,26 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; -import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; import { eventsModuleAwsSqsConsumingEventPublisher } from './eventsModuleAwsSqsConsumingEventPublisher'; -import { AwsSqsConsumingEventPublisher } from '../publisher/AwsSqsConsumingEventPublisher'; describe('eventsModuleAwsSqsConsumingEventPublisher', () => { it('should be correctly wired and set up', async () => { - let addedPublishers: AwsSqsConsumingEventPublisher[] | undefined; - const extensionPoint = { - addPublishers: (publishers: any) => { - addedPublishers = publishers; + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; }, - }; + }); const scheduler = mockServices.scheduler.mock(); await startTestBackend({ - extensionPoints: [[eventsExtensionPoint, extensionPoint]], features: [ + eventsServiceFactory(), eventsModuleAwsSqsConsumingEventPublisher(), mockServices.rootConfig.factory({ data: { @@ -65,14 +67,6 @@ describe('eventsModuleAwsSqsConsumingEventPublisher', () => { ], }); - expect(addedPublishers).not.toBeUndefined(); - expect(addedPublishers!.length).toEqual(2); - - const eventBroker = new TestEventBroker(); - await Promise.all( - addedPublishers!.map(publisher => publisher.setEventBroker(eventBroker)), - ); - // publisher.connect(..) was causing the polling for events to be scheduled expect(scheduler.scheduleTask).toHaveBeenCalledWith( expect.objectContaining({ id: 'events.awsSqs.publisher:fake1' }), diff --git a/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.ts b/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.ts index eabab94708..ea0f094bee 100644 --- a/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.ts +++ b/plugins/events-backend-module-aws-sqs/src/service/eventsModuleAwsSqsConsumingEventPublisher.ts @@ -18,8 +18,7 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { loggerToWinstonLogger } from '@backstage/backend-common'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { AwsSqsConsumingEventPublisher } from '../publisher/AwsSqsConsumingEventPublisher'; /** @@ -34,19 +33,19 @@ export const eventsModuleAwsSqsConsumingEventPublisher = createBackendModule({ env.registerInit({ deps: { config: coreServices.rootConfig, - events: eventsExtensionPoint, + events: eventsServiceRef, logger: coreServices.logger, scheduler: coreServices.scheduler, }, async init({ config, events, logger, scheduler }) { - const winstonLogger = loggerToWinstonLogger(logger); const sqs = AwsSqsConsumingEventPublisher.fromConfig({ - config: config, - logger: winstonLogger, - scheduler: scheduler, + config, + events, + logger, + scheduler, }); - events.addPublishers(sqs); + await Promise.all(sqs.map(publisher => publisher.start())); }, }); }, diff --git a/yarn.lock b/yarn.lock index 39bfa662d7..a876413ec3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6391,7 +6391,6 @@ __metadata: "@backstage/types": "workspace:^" aws-sdk-client-mock: ^3.0.0 luxon: ^3.0.0 - winston: ^3.2.1 languageName: unknown linkType: soft From 52479092dc8efcd3057218bb2398ab5f81d0b09c Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 21:09:52 +0100 Subject: [PATCH 097/176] feat(events): add `events: EventsService` to `LegacyPluginEnvironment` Signed-off-by: Patrick Jungermann --- .changeset/six-nails-hammer.md | 5 +++++ packages/backend-dynamic-feature-service/api-report.md | 2 ++ .../backend-dynamic-feature-service/src/manager/types.ts | 2 ++ 3 files changed, 9 insertions(+) create mode 100644 .changeset/six-nails-hammer.md diff --git a/.changeset/six-nails-hammer.md b/.changeset/six-nails-hammer.md new file mode 100644 index 0000000000..d0cf7a0ea7 --- /dev/null +++ b/.changeset/six-nails-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Add `events: EventsService` to `LegacyPluginEnvironment`. diff --git a/packages/backend-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index 64bffb468c..8075acabfb 100644 --- a/packages/backend-dynamic-feature-service/api-report.md +++ b/packages/backend-dynamic-feature-service/api-report.md @@ -10,6 +10,7 @@ import { Config } from '@backstage/config'; import { ConfigSchema } from '@backstage/config-loader'; import { EventBroker } from '@backstage/plugin-events-node'; import { EventsBackend } from '@backstage/plugin-events-backend'; +import { EventsService } from '@backstage/plugin-events-node'; import { FeatureDiscoveryService } from '@backstage/backend-plugin-api/alpha'; import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; import { IdentityApi } from '@backstage/plugin-auth-node'; @@ -215,6 +216,7 @@ export type LegacyPluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; + events: EventsService; pluginProvider: BackendPluginProvider; }; diff --git a/packages/backend-dynamic-feature-service/src/manager/types.ts b/packages/backend-dynamic-feature-service/src/manager/types.ts index 0038470686..60b2eb831e 100644 --- a/packages/backend-dynamic-feature-service/src/manager/types.ts +++ b/packages/backend-dynamic-feature-service/src/manager/types.ts @@ -29,6 +29,7 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker, + EventsService, HttpPostIngressOptions, } from '@backstage/plugin-events-node'; @@ -64,6 +65,7 @@ export type LegacyPluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; + events: EventsService; pluginProvider: BackendPluginProvider; }; From 3c5f58622e866ba63f6de855476dacd1a831a36d Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 22:36:52 +0100 Subject: [PATCH 098/176] docs(events): update docs about events-backend used with the new backend system Signed-off-by: Patrick Jungermann --- .../building-backends/08-migrating.md | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md index 9eada30ef6..27e4eb7324 100644 --- a/docs/backend-system/building-backends/08-migrating.md +++ b/docs/backend-system/building-backends/08-migrating.md @@ -632,7 +632,7 @@ A basic installation of the events plugin looks as follows. ```ts title="packages/backend/src/index.ts" const backend = createBackend(); /* highlight-add-next-line */ -backend.add(import('@backstage/plugin-events-backend')); +backend.add(import('@backstage/plugin-events-backend/alpha')); ``` If you have other customizations made to `plugins/events.ts`, such as adding @@ -646,6 +646,7 @@ depends on the appropriate extension point and interacts with it. ```ts title="packages/backend/src/index.ts" /* highlight-add-start */ +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; import { createBackendModule } from '@backstage/backend-plugin-api'; /* highlight-add-end */ @@ -663,7 +664,28 @@ const eventsModuleCustomExtensions = createBackendModule({ async init({ events /* ..., other dependencies */ }) { // Here you have the opportunity to interact with the extension // point before the plugin itself gets instantiated - events.addSubscribers(new MySubscriber()); // just an example + events.addHttpPostIngress({ + // ... + }); + }, + }); + }, +}); +/* highlight-add-end */ + +/* highlight-add-start */ +const otherPluginModuleCustomExtensions = createBackendModule({ + pluginId: 'other-plugin', // name of the plugin that the module is targeting + moduleId: 'custom-extensions', + register(env) { + env.registerInit({ + deps: { + events: eventsServiceRef, + // ... and other dependencies as needed + }, + async init({ events /* ..., other dependencies */ }) { + // Here you have the opportunity to interact with the extension + // point before the plugin itself gets instantiated }, }); }, @@ -671,17 +693,11 @@ const eventsModuleCustomExtensions = createBackendModule({ /* highlight-add-end */ const backend = createBackend(); -backend.add(import('@backstage/plugin-events-backend')); +backend.add(import('@backstage/plugin-events-backend/alpha')); /* highlight-add-next-line */ backend.add(eventsModuleCustomExtensions()); -``` - -This also requires that you have a dependency on the corresponding node package, -if you didn't already have one. - -```bash -# from the repository root -yarn --cwd packages/backend add @backstage/plugin-events-node +/* highlight-add-next-line */ +backend.add(otherPluginModuleCustomExtensions()); ``` Here we've placed the module directly in the backend index file just to get From 1ab76e523de915c497277ed8d7ebdf03aad09579 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Wed, 24 Jan 2024 03:06:54 +0100 Subject: [PATCH 099/176] docs(events): describe the new events setup, update README.md files Signed-off-by: Patrick Jungermann --- .../events-backend-module-aws-sqs/README.md | 48 ++- plugins/events-backend-module-azure/README.md | 32 +- .../README.md | 32 +- .../events-backend-module-gerrit/README.md | 28 +- .../events-backend-module-github/README.md | 52 ++-- .../events-backend-module-gitlab/README.md | 52 ++-- plugins/events-backend-test-utils/README.md | 7 +- plugins/events-backend/README.md | 277 +++++------------- plugins/events-node/README.md | 83 +++++- 9 files changed, 295 insertions(+), 316 deletions(-) diff --git a/plugins/events-backend-module-aws-sqs/README.md b/plugins/events-backend-module-aws-sqs/README.md index 7ca4bb280d..bd8e0c51d3 100644 --- a/plugins/events-backend-module-aws-sqs/README.md +++ b/plugins/events-backend-module-aws-sqs/README.md @@ -1,12 +1,12 @@ -# events-backend-module-aws-sqs +# `@backstage/plugins-events-backend-module-aws-sqs` -Welcome to the `events-backend-module-aws-sqs` backend plugin! +Welcome to the `events-backend-module-aws-sqs` backend module! -This plugin is a module for the `events-backend` backend plugin -and extends it with an `AwsSqsConsumingEventPublisher`. +This package is a module for the `events-backend` backend plugin +and extends the events system with an `AwsSqsConsumingEventPublisher`. -This event publisher will allow you to receive events from -an AWS SQS queue and will publish these to the used event broker. +This event publisher will allow you to receive events from an AWS SQS queue +and will publish these to the used `EventsService` implementation. ## Configuration @@ -32,15 +32,43 @@ events: ## Installation -1. Install the [`events-backend` plugin](../events-backend/README.md). -2. Install this module -3. Add your configuration. +1. Install this module +2. Add your configuration. ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-aws-sqs ``` -```ts title="packages/backend/src/index.ts" +```ts +// packages/backend/src/index.ts backend.add(import('@backstage/plugin-events-backend-module-aws-sqs/alpha')); ``` + +### Legacy Backend System + +```ts +// packages/backend/src/plugins/events.ts +// ... +import { AwsSqsConsumingEventPublisher } from '@backstage/plugin-events-backend-module-aws-sqs'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const eventsRouter = Router(); + + // ... + + const sqs = AwsSqsConsumingEventPublisher.fromConfig({ + config: env.config, + events: env.events, + logger: env.logger, + scheduler: env.scheduler, + }); + await Promise.all(sqs.map(publisher => publisher.start())); + + return eventsRouter; +} +``` diff --git a/plugins/events-backend-module-azure/README.md b/plugins/events-backend-module-azure/README.md index 61b3b63175..7c3920d499 100644 --- a/plugins/events-backend-module-azure/README.md +++ b/plugins/events-backend-module-azure/README.md @@ -1,9 +1,9 @@ # events-backend-module-azure -Welcome to the `events-backend-module-azure` backend plugin! +Welcome to the `events-backend-module-azure` backend module! -This plugin is a module for the `events-backend` backend plugin -and extends it with an `AzureDevOpsEventRouter`. +This package is a module for the `events-backend` backend plugin +and extends the event system with an `AzureDevOpsEventRouter`. The event router will subscribe to the topic `azureDevOps` and route the events to more concrete topics based on the value @@ -22,30 +22,22 @@ and [webhooks](https://learn.microsoft.com/en-us/azure/devops/service-hooks/serv ## Installation -Install the [`events-backend` plugin](../events-backend/README.md). - -Install this module: - ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-azure ``` -### Add to backend - -```ts title="packages/backend/src/index.ts" +```ts +// packages/backend/src/index.ts backend.add(import('@backstage/plugin-events-backend-module-azure/alpha')); ``` -### Add to backend (old) +### Legacy Backend System -Add the event router to the `EventsBackend` instance in `packages/backend/src/plugins/events.ts`: - -```diff -+const azureEventRouter = new AzureDevOpsEventRouter(); - -new EventsBackend(env.logger) -+ .addPublishers(azureEventRouter) -+ .addSubscribers(azureEventRouter); -// [...] +```ts +// packages/backend/src/plugins/events.ts +const eventRouter = new AzureDevOpsEventRouter({ + events: env.events, +}); +await eventRouter.subscribe(); ``` diff --git a/plugins/events-backend-module-bitbucket-cloud/README.md b/plugins/events-backend-module-bitbucket-cloud/README.md index 0a40ab2eea..7ff743c06d 100644 --- a/plugins/events-backend-module-bitbucket-cloud/README.md +++ b/plugins/events-backend-module-bitbucket-cloud/README.md @@ -1,9 +1,9 @@ # events-backend-module-bitbucket-cloud -Welcome to the `events-backend-module-bitbucket-cloud` backend plugin! +Welcome to the `events-backend-module-bitbucket-cloud` backend module! -This plugin is a module for the `events-backend` backend plugin -and extends it with an `BitbucketCloudEventRouter`. +This package is a module for the `events-backend` backend plugin +and extends the event system with an `BitbucketCloudEventRouter`. The event router will subscribe to the topic `bitbucketCloud` and route the events to more concrete topics based on the value @@ -22,32 +22,24 @@ Please find all possible webhook event types at the ## Installation -Install the [`events-backend` plugin](../events-backend/README.md). - -Install this module: - ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-bitbucket-cloud ``` -### Add to backend - -```ts title="packages/backend/src/index.ts" +```ts +// packages/backend/src/index.ts backend.add( import('@backstage/plugin-events-backend-module-bitbucket-cloud/alpha'), ); ``` -### Add to backend (old) +### Legacy Backend System -Add the event router to the `EventsBackend` instance in `packages/backend/src/plugins/events.ts`: - -```diff -+const bitbucketCloudEventRouter = new BitbucketCloudEventRouter(); - -new EventsBackend(env.logger) -+ .addPublishers(bitbucketCloudEventRouter) -+ .addSubscribers(bitbucketCloudEventRouter); -// [...] +```ts +// packages/backend/src/plugins/events.ts +const eventRouter = new BitbucketCloudEventRouter({ + events: env.events, +}); +await eventRouter.subscribe(); ``` diff --git a/plugins/events-backend-module-gerrit/README.md b/plugins/events-backend-module-gerrit/README.md index b658fba366..d5b9f9683a 100644 --- a/plugins/events-backend-module-gerrit/README.md +++ b/plugins/events-backend-module-gerrit/README.md @@ -1,8 +1,8 @@ # events-backend-module-gerrit -Welcome to the `events-backend-module-gerrit` backend plugin! +Welcome to the `events-backend-module-gerrit` backend module! -This plugin is a module for the `events-backend` backend plugin +This package is a module for the `events-backend` backend plugin and extends it with an `GerritEventRouter`. The event router will subscribe to the topic `gerrit` @@ -21,30 +21,20 @@ Please find all possible webhook event types at the ## Installation -Install the [`events-backend` plugin](../events-backend/README.md). - -Install this module: - ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-gerrit ``` -### Add to backend - -```ts title="packages/backend/src/index.ts" +```ts +// packages/backend/src/index.ts backend.add(import('@backstage/plugin-events-backend-module-gerrit/alpha')); ``` -### Add to backend (old) +### Legacy Backend System -Add the event router to the `EventsBackend` instance in `packages/backend/src/plugins/events.ts`: - -```diff -+const gerritEventRouter = new GerritEventRouter(); - -new EventsBackend(env.logger) -+ .addPublishers(gerritEventRouter) -+ .addSubscribers(gerritEventRouter); -// [...] +```ts +// packages/backend/src/plugins/events.ts +const eventRouter = new GerritEventRouter({ events: env.events }); +await eventRouter.subscribe(); ``` diff --git a/plugins/events-backend-module-github/README.md b/plugins/events-backend-module-github/README.md index 072877ee86..fec27b62e3 100644 --- a/plugins/events-backend-module-github/README.md +++ b/plugins/events-backend-module-github/README.md @@ -1,9 +1,9 @@ # events-backend-module-github -Welcome to the `events-backend-module-github` backend plugin! +Welcome to the `events-backend-module-github` backend module! -This plugin is a module for the `events-backend` backend plugin -and extends it with an `GithubEventRouter`. +This package is a module for the `events-backend` backend plugin +and extends the event system with an `GithubEventRouter`. The event router will subscribe to the topic `github` and route the events to more concrete topics based on the value @@ -22,37 +22,49 @@ Please find all possible webhook event types at the ## Installation -Install the [`events-backend` plugin](../events-backend/README.md). - -Install this module: - ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-github ``` -Add the event router to the `EventsBackend` instance in `packages/backend/src/plugins/events.ts`: +### Event Router -```diff -+const githubEventRouter = new GithubEventRouter(); +```ts +// packages/backend/src/index.ts +import { eventsModuleGithubEventRouter } from '@backstage/plugin-events-backend-module-github/alpha'; +// ... +backend.add(eventsModuleGithubEventRouter()); +``` -new EventsBackend(env.logger) -+ .addPublishers(githubEventRouter) -+ .addSubscribers(githubEventRouter); -// [...] +#### Legacy Backend System + +```ts +// packages/backend/src/plugins/events.ts +const eventRouter = new GithubEventRouter({ events: env.events }); +await eventRouter.subscribe(); ``` ### Signature Validator +```ts +// packages/backend/src/index.ts +import { eventsModuleGithubWebhook } from '@backstage/plugin-events-backend-module-github/alpha'; +// ... +backend.add(eventsModuleGithubWebhook()); +``` + +#### Legacy Backend System + Add the signature validator for the topic `github`: ```diff -// at packages/backend/src/plugins/events.ts +// packages/backend/src/plugins/events.ts + import { createGithubSignatureValidator } from '@backstage/plugin-events-backend-module-github'; -// [...] - const http = HttpPostIngressEventPublisher.fromConfig({ - config: env.config, - ingresses: { + // [...] + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + events: env.events, + ingresses: { + github: { + validator: createGithubSignatureValidator(env.config), + }, @@ -61,7 +73,7 @@ Add the signature validator for the topic `github`: }); ``` -Additionally, you need to add the configuration: +## Configuration ```yaml events: diff --git a/plugins/events-backend-module-gitlab/README.md b/plugins/events-backend-module-gitlab/README.md index 3d4919302f..73d5bf2d87 100644 --- a/plugins/events-backend-module-gitlab/README.md +++ b/plugins/events-backend-module-gitlab/README.md @@ -1,9 +1,9 @@ # events-backend-module-gitlab -Welcome to the `events-backend-module-gitlab` backend plugin! +Welcome to the `events-backend-module-gitlab` backend module! -This plugin is a module for the `events-backend` backend plugin -and extends it with an `GitlabEventRouter`. +This package is a module for the `events-backend` backend plugin +and extends the event system with an `GitlabEventRouter`. The event router will subscribe to the topic `gitlab` and route the events to more concrete topics based on the value @@ -21,37 +21,49 @@ Please find all possible webhook event types at the ## Installation -Install the [`events-backend` plugin](../events-backend/README.md). - -Install this module: - ```bash # From your Backstage root directory yarn --cwd packages/backend add @backstage/plugin-events-backend-module-gitlab ``` -Add the event router to the `EventsBackend` instance in `packages/backend/src/plugins/events.ts`: +### Event Router -```diff -+const gitlabEventRouter = new GitlabEventRouter(); +```ts +// packages/backend/src/index.ts +import { eventsModuleGitlabEventRouter } from '@backstage/plugin-events-backend-module-gitlab/alpha'; +// ... +backend.add(eventsModuleGitlabEventRouter()); +``` -new EventsBackend(env.logger) -+ .addPublishers(gitlabEventRouter) -+ .addSubscribers(gitlabEventRouter); -// [...] +#### Legacy Backend System + +```ts +// packages/backend/src/plugins/events.ts +const eventRouter = new GitlabEventRouter({ events: env.events }); +await eventRouter.subscribe(); ``` ### Token Validator +```ts +// packages/backend/src/index.ts +import { eventsModuleGitlabWebhook } from '@backstage/plugin-events-backend-module-gitlab/alpha'; +// ... +backend.add(eventsModuleGitlabWebhook()); +``` + +#### Legacy Backend System + Add the token validator for the topic `gitlab`: ```diff -// at packages/backend/src/plugins/events.ts +// packages/backend/src/plugins/events.ts + import { createGitlabTokenValidator } from '@backstage/plugin-events-backend-module-gitlab'; -// [...] - const http = HttpPostIngressEventPublisher.fromConfig({ - config: env.config, - ingresses: { + // [...] + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + events: env.events, + ingresses: { + gitlab: { + validator: createGitlabTokenValidator(env.config), + }, @@ -60,7 +72,7 @@ Add the token validator for the topic `gitlab`: }); ``` -Additionally, you need to add the configuration: +## Configuration ```yaml events: diff --git a/plugins/events-backend-test-utils/README.md b/plugins/events-backend-test-utils/README.md index c8727b536b..84a1be754e 100644 --- a/plugins/events-backend-test-utils/README.md +++ b/plugins/events-backend-test-utils/README.md @@ -1,4 +1,5 @@ -# plugin-events-backend-test-utils +# `@backstage/plugin-events-backend-test-utils` -Houses implementations of plugin-events-node interfaces -which can be useful for test for events-backend and its modules. +This is a package that can be used as `devDependency` +and provides a test implementation for the `EventsService` +by [`events-node` package](../events-node/README.md): `TestEventsService`. diff --git a/plugins/events-backend/README.md b/plugins/events-backend/README.md index b8470101aa..2dba259986 100644 --- a/plugins/events-backend/README.md +++ b/plugins/events-backend/README.md @@ -1,166 +1,55 @@ -# events-backend +# `@backstage/plugin-events-backend` Welcome to the events-backend backend plugin! -This plugin provides the wiring of all extension points -for managing events as defined by [plugin-events-node](../events-node) -including backend plugin `EventsPlugin` and `EventsBackend`. - -Additionally, it uses a simple in-process implementation for -the `EventBroker` by default which you can replace with a more sophisticated -implementation of your choice as you need (e.g., via module). - -Some of these (non-exhaustive) may provide added persistence, -or use external systems like AWS EventBridge, AWS SNS, Kafka, etc. +This package is based on [events-node](../events-node) and its `eventsServiceRef` +that is at the core of the event support. +It provides an `eventsPlugin` (exported as `default`). By default, the plugin ships with support to receive events via HTTP endpoints -`POST /api/events/http/{topic}` and will publish these -to the used event broker. +`POST /api/events/http/{topic}` and will publish these to the `EventsService`. + +HTTP ingresses can be enabled by config, or using the extension point +of the `eventsPlugin`. +Additionally, the latter allows to add a request validator +(e.g., signature verification). ## Installation ```bash # From your Backstage root directory -yarn --cwd packages/backend add @backstage/plugin-events-backend @backstage/plugin-events-node +yarn --cwd packages/backend add @backstage/plugin-events-backend ``` -### Add to backend - -```ts title="packages/backend/src/index.ts" +```ts +// packages/backend/src/index.ts backend.add(import('@backstage/plugin-events-backend/alpha')); ``` -### Add to backend (old) +### Legacy Backend System -#### Event Broker +```ts +// packages/backend/src/plugins/events.ts +import { HttpPostIngressEventPublisher } from '@backstage/plugin-events-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; -First you will need to add and implementation of the `EventBroker` interface to the backend plugin environment. -This will allow event broker instance any backend plugins to publish and subscribe to events in order to communicate -between them. - -Add the following to `makeCreateEnv` - -```diff -// packages/backend/src/index.ts -+ import { DefaultEventBroker } from '@backstage/plugin-events-backend'; -+ const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); -``` - -Then update plugin environment to include the event broker. - -```diff -// packages/backend/src/types.ts -+ import { EventBroker } from '@backstage/plugin-events-node'; -+ eventBroker: EventBroker; -``` - -#### Publishing and Subscribing to events with the broker - -Backend plugins are passed the event broker in the plugin environment at startup of the application. The plugin can -make use of this to communicate between parts of the application. - -Here is an example of a plugin publishing a payload to a topic. - -```typescript jsx export default async function createPlugin( env: PluginEnvironment, ): Promise { - env.eventBroker.publish({ - topic: 'publish.example', - eventPayload: { message: 'Hello, World!' }, - metadata: {}, + const eventsRouter = Router(); + + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + events: env.events, + logger: env.logger, }); + http.bind(eventsRouter); + + return eventsRouter; } ``` -Here is an example of a plugin subscribing to a topic. - -```typescript jsx -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - env.eventBroker.subscribe([ - { - supportsEventTopics: ['publish.example'], - onEvent: async (params: EventParams) => { - env.logger.info(`receieved ${params.topic} event`); - }, - }, - ]); -} -``` - -#### Implementing an `EventSubscriber` class - -More complex solutions might need the creation of a class that implements the `EventSubscriber` interface. e.g. - -```typescript jsx -import { EventSubscriber } from './EventSubscriber'; - -class ExampleSubscriber implements EventSubscriber { - // ... - - supportsEventTopics() { - return ['publish.example']; - } - - async onEvent(params: EventParams) { - env.logger.info(`receieved ${params.topic} event`); - } -} -``` - -#### Events Backend - -The events backend plugin provides a router to handler http events and publish the http requests onto the event -broker. - -To configure it add a file [`packages/backend/src/plugins/events.ts`](../../packages/backend/src/plugins/events.ts) -to your Backstage project. - -Additionally, add the events plugin to your backend. - -```diff -// packages/backend/src/index.ts -// [...] -+import events from './plugins/events'; -// [...] -+ const eventsEnv = useHotMemoize(module, () => createEnv('events')); -// [...] -+ apiRouter.use('/events', await events(eventsEnv)); -// [...] -``` - -#### Configuration - -In order to create HTTP endpoints to receive events for a certain -topic, you need to add them at your configuration: - -```yaml -events: - http: - topics: - - bitbucketCloud - - github - - whatever -``` - -Only those topics added to the configuration will result in -available endpoints. - -The example above would result in the following endpoints: - -``` -POST /api/events/http/bitbucketCloud -POST /api/events/http/github -POST /api/events/http/whatever -``` - -You may want to use these for webhooks by SCM providers -in combination with suitable event subscribers. - -However, it is not limited to these use cases. - ### Event-based Entity Providers You can implement the `EventSubscriber` interface on an `EntityProviders` to allow it to handle events from other plugins e.g. the event backend plugin @@ -189,74 +78,42 @@ Assuming you have configured the `eventBroker` into the `PluginEnvironment` you } ``` +## Configuration + +In order to create HTTP endpoints to receive events for a certain +topic, you need to add them at your configuration: + +```yaml +events: + http: + topics: + - bitbucketCloud + - github + - whatever +``` + +Only those topics added to the configuration will result in +available endpoints. + +The example above would result in the following endpoints: + +``` +POST /api/events/http/bitbucketCloud +POST /api/events/http/github +POST /api/events/http/whatever +``` + +You may want to use these for webhooks by SCM providers +in combination with suitable event subscribers. + +However, it is not limited to these use cases. + ## Use Cases -### Custom Event Broker - -Example using the `EventsBackend`: - -```ts -new EventsBackend(env.logger) - .setEventBroker(yourEventBroker) - // [...] - .start(); -``` - -Example using a module: - -```ts -import { eventsExtensionPoint } from '@backstage/plugin-events-node'; - -// [...] - -export const yourModuleEventsModule = createBackendModule({ - pluginId: 'events', - moduleId: 'your-module', - register(env) { - // [...] - env.registerInit({ - deps: { - // [...] - events: eventsExtensionPoint, - // [...] - }, - async init({ /* ... */ events /*, ... */ }) { - // [...] - const yourEventBroker = new YourEventBroker(); - // [...] - events.setEventBroker(yourEventBroker); - }, - }); - }, -}); -``` - ### Request Validator -Example using the `EventsBackend`: - ```ts -const http = HttpPostIngressEventPublisher.fromConfig({ - config: env.config, - ingresses: { - yourTopic: { - validator: yourValidator, - }, - }, - logger: env.logger, -}); -http.bind(router); - -await new EventsBackend(env.logger) - .addPublishers(http) - // [...] - .start(); -``` - -Example using a module: - -```ts -import { eventsExtensionPoint } from '@backstage/plugin-events-node'; +import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; // [...] @@ -282,3 +139,19 @@ export const eventsModuleYourFeature = createBackendModule({ }, }); ``` + +#### Legacy Backend System + +```ts +const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + events: env.events, + ingresses: { + yourTopic: { + validator: yourValidator, + }, + }, + logger: env.logger, +}); +http.bind(router); +``` diff --git a/plugins/events-node/README.md b/plugins/events-node/README.md index 44738222bc..ce4d705025 100644 --- a/plugins/events-node/README.md +++ b/plugins/events-node/README.md @@ -1,3 +1,82 @@ -# plugin-events-node +# `@backstage/plugin-events-node` -Houses types and utilities for building events-related modules. +This package defined basic types for event-based interactions inside of Backstage. + +Additionally, it provides the core event service `eventsServiceRef` of type `EventsService` +with its default implementation that uses the `DefaultEventsService` implementation. + +`DefaultEventsService` is a simple in-memory implementation +that requires the co-deployment of producers and consumers of events. + +## Installation + +Add `@backstage/plugin-events-node` as dependency to your plugin or plugin module package +to which you want to add event support. + +Use `eventsServiceRef` as a dependency at your plugin or plugin module. + +### Legacy Backend System + +Create an `EventsService` instance and add it to the environment. + +```ts +// packages/backend/src/plugins/events.ts +import { DefaultEventsService } from '@backstage/plugin-events-node'; + +// ... + +function makeCreateEnv(config: Config) { + // ... + const eventsService = DefaultEventsService.create({ logger: root }); + // ... + return (plugin: string): PluginEnvironment => { + // ... + return { + // ... + events: eventsService, + // ... + }; + }; +} +``` + +Use the `events` from the `PluginEnvironment` as desired: + +```ts +// packages/backend/src/plugins/events.ts +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + // ... + env.events; // ... + // ... +} +``` + +## Use Case + +### Exchange service implementation + +Create your custom service factory implementation: + +```ts +import { eventsServiceRef } from '@backstage/plugin-events-node'; +// ... +export const customEventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: { + // add needed dependencies here + }, + async factory({ logger }) { + // add your custom logic here + return customEventsService; + }, +}); +``` + +and your custom implementation: + +```diff +// packages/backend/src/index.ts ++ backend.add(customEventsServiceFactory()); +``` From 9e527c920065fc015f2b6a045079545d2e6961c1 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Tue, 23 Jan 2024 22:44:32 +0100 Subject: [PATCH 100/176] fix(events,catalog,bitbucket-cloud)!: fix new backend system support; migrates to `EventsService` - Fixes the support for the new backend system that was broken entirely (with and without events support). - Migrates the `BitbucketCloudEntityProvider` to use the `EventsService`. Signed-off-by: Patrick Jungermann --- .changeset/silver-flowers-trade.md | 40 +++++++++++++++ docs/integrations/bitbucketCloud/discovery.md | 33 ++++++++++++- .../api-report.md | 18 +++---- .../package.json | 4 +- ...ModuleBitbucketCloudEntityProvider.test.ts | 32 +++++++----- ...talogModuleBitbucketCloudEntityProvider.ts | 12 ++--- .../BitbucketCloudEntityProvider.test.ts | 24 ++++----- .../providers/BitbucketCloudEntityProvider.ts | 49 ++++++++++--------- yarn.lock | 2 +- 9 files changed, 141 insertions(+), 73 deletions(-) create mode 100644 .changeset/silver-flowers-trade.md diff --git a/.changeset/silver-flowers-trade.md b/.changeset/silver-flowers-trade.md new file mode 100644 index 0000000000..8701de375c --- /dev/null +++ b/.changeset/silver-flowers-trade.md @@ -0,0 +1,40 @@ +--- +'@backstage/plugin-catalog-backend-module-bitbucket-cloud': minor +--- + +BREAKING CHANGE: Migrates the `BitbucketCloudEntityProvider` to use the `EventsService`; fix new backend system support. + +`BitbucketCloudEntityProvider.fromConfig` accepts `events: EventsService` as optional argument to its `options`. +With provided `events`, the event-based updates/refresh will be available. +However, the `EventSubscriber` interface was removed including its `supportsEventTopics()` and `onEvent(params)`. + +The event subscription happens on `connect(connection)` if the `events` is available. + +**Migration:** + +```diff + const bitbucketCloudProvider = BitbucketCloudEntityProvider.fromConfig( + env.config, + { + catalogApi: new CatalogClient({ discoveryApi: env.discovery }), ++ events: env.events, + logger: env.logger, + scheduler: env.scheduler, + tokenManager: env.tokenManager, + }, + ); +- env.eventBroker.subscribe(bitbucketCloudProvider); +``` + +**New Backend System:** + +Before this change, using this module with the new backend system was broken. +Now, you can add the catalog module for Bitbucket Cloud incl. event support backend. +Event support will always be enabled. +However, no updates/refresh will happen without receiving events. + +```ts +backend.add( + import('@backstage/plugin-catalog-backend-module-bitbucket-cloud/alpha'), +); +``` diff --git a/docs/integrations/bitbucketCloud/discovery.md b/docs/integrations/bitbucketCloud/discovery.md index a7477a2a61..81770afbf8 100644 --- a/docs/integrations/bitbucketCloud/discovery.md +++ b/docs/integrations/bitbucketCloud/discovery.md @@ -24,7 +24,35 @@ package. yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-bitbucket-cloud ``` -### Installation without Events Support +### Installation with New Backend System + +```ts +// optional if you want HTTP endpojnts to receive external events +// backend.add(import('@backstage/plugin-events-backend/alpha')); +// optional if you want to use AWS SQS instead of HTTP endpoints to receive external events +// backend.add(import('@backstage/plugin-events-backend-module-aws-sqs/alpha')); +backend.add( + import('@backstage/plugin-events-backend-module-bitbucket-cloud/alpha'), +); +backend.add( + import('@backstage/plugin-catalog-backend-module-bitbucket-cloud/alpha'), +); +``` + +You need to decide how you want to receive events from external sources like + +- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md) +- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md) + +Further documentation: + +- +- +- + +### Installation with Legacy Backend System + +#### Installation without Events Support And then add the entity provider to your catalog builder: @@ -49,7 +77,7 @@ export default async function createPlugin( } ``` -### Installation with Events Support +#### Installation with Events Support Please follow the installation instructions at @@ -83,6 +111,7 @@ export default async function createPlugin( env.config, { catalogApi: new CatalogClient({ discoveryApi: env.discovery }), + events: env.events, logger: env.logger, scheduler: env.scheduler, tokenManager: env.tokenManager, diff --git a/plugins/catalog-backend-module-bitbucket-cloud/api-report.md b/plugins/catalog-backend-module-bitbucket-cloud/api-report.md index 22eeddea3a..22845cf260 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/api-report.md +++ b/plugins/catalog-backend-module-bitbucket-cloud/api-report.md @@ -7,18 +7,15 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; -import { EventParams } from '@backstage/plugin-events-node'; import { Events } from '@backstage/plugin-bitbucket-cloud-common'; -import { EventSubscriber } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; +import { EventsService } from '@backstage/plugin-events-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { TaskRunner } from '@backstage/backend-tasks'; import { TokenManager } from '@backstage/backend-common'; // @public -export class BitbucketCloudEntityProvider - implements EntityProvider, EventSubscriber -{ +export class BitbucketCloudEntityProvider implements EntityProvider { // (undocumented) connect(connection: EntityProviderConnection): Promise; // (undocumented) @@ -26,7 +23,8 @@ export class BitbucketCloudEntityProvider config: Config, options: { catalogApi?: CatalogApi; - logger: Logger; + events?: EventsService; + logger: LoggerService; schedule?: TaskRunner; scheduler?: PluginTaskScheduler; tokenManager?: TokenManager; @@ -37,12 +35,8 @@ export class BitbucketCloudEntityProvider // (undocumented) getTaskId(): string; // (undocumented) - onEvent(params: EventParams): Promise; - // (undocumented) onRepoPush(event: Events.RepoPushEvent): Promise; // (undocumented) - refresh(logger: Logger): Promise; - // (undocumented) - supportsEventTopics(): string[]; + refresh(logger: LoggerService): Promise; } ``` diff --git a/plugins/catalog-backend-module-bitbucket-cloud/package.json b/plugins/catalog-backend-module-bitbucket-cloud/package.json index 418e88a003..441781331e 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/package.json +++ b/plugins/catalog-backend-module-bitbucket-cloud/package.json @@ -56,13 +56,13 @@ "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", - "uuid": "^9.0.0", - "winston": "^3.2.1" + "uuid": "^9.0.0" }, "devDependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/plugin-events-backend-test-utils": "workspace:^", "luxon": "^3.0.0", "msw": "^1.0.0" }, diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts index c771a40acc..9fff322eab 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts @@ -14,18 +14,28 @@ * limitations under the License. */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; import { TaskScheduleDefinition } from '@backstage/backend-tasks'; import { startTestBackend, mockServices } from '@backstage/backend-test-utils'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { Duration } from 'luxon'; import { catalogModuleBitbucketCloudEntityProvider } from './catalogModuleBitbucketCloudEntityProvider'; import { BitbucketCloudEntityProvider } from '../providers/BitbucketCloudEntityProvider'; describe('catalogModuleBitbucketCloudEntityProvider', () => { it('should register provider at the catalog extension point', async () => { + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; + }, + }); let addedProviders: Array | undefined; - let addedSubscribers: Array | undefined; let usedSchedule: TaskScheduleDefinition | undefined; const catalogExtensionPointImpl = { @@ -33,11 +43,7 @@ describe('catalogModuleBitbucketCloudEntityProvider', () => { addedProviders = providers; }, }; - const eventsExtensionPointImpl = { - addSubscribers: (subscribers: any) => { - addedSubscribers = subscribers; - }, - }; + const connection = jest.fn() as unknown as EntityProviderConnection; const runner = jest.fn(); const scheduler = mockServices.scheduler.mock({ createScheduledTaskRunner(schedule) { @@ -49,9 +55,9 @@ describe('catalogModuleBitbucketCloudEntityProvider', () => { await startTestBackend({ extensionPoints: [ [catalogProcessingExtensionPoint, catalogExtensionPointImpl], - [eventsExtensionPoint, eventsExtensionPointImpl], ], features: [ + eventsServiceFactory(), catalogModuleBitbucketCloudEntityProvider(), mockServices.rootConfig.factory({ data: { @@ -75,10 +81,14 @@ describe('catalogModuleBitbucketCloudEntityProvider', () => { expect(usedSchedule?.frequency).toEqual(Duration.fromISO('P1M')); expect(usedSchedule?.timeout).toEqual(Duration.fromISO('PT3M')); expect(addedProviders?.length).toEqual(1); - expect(addedProviders?.pop()?.getProviderName()).toEqual( + expect(runner).not.toHaveBeenCalled(); + const provider = addedProviders!.pop()!; + expect(provider.getProviderName()).toEqual( 'bitbucketCloud-provider:default', ); - expect(addedSubscribers).toEqual(addedProviders); - expect(runner).not.toHaveBeenCalled(); + await provider.connect(connection); + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('bitbucketCloud-provider:default'); + expect(runner).toHaveBeenCalledTimes(1); }); }); diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts index 39265dc1a5..21d86ce8e6 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { loggerToWinstonLogger } from '@backstage/backend-common'; import { coreServices, createBackendModule, @@ -23,7 +22,7 @@ import { catalogProcessingExtensionPoint, catalogServiceRef, } from '@backstage/plugin-catalog-node/alpha'; -import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { BitbucketCloudEntityProvider } from '../providers/BitbucketCloudEntityProvider'; /** @@ -38,9 +37,7 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({ catalog: catalogProcessingExtensionPoint, catalogApi: catalogServiceRef, config: coreServices.rootConfig, - // TODO(pjungermann): How to make this optional for those which only want the provider without event support? - // Do we even want to support this? - events: eventsExtensionPoint, + events: eventsServiceRef, logger: coreServices.logger, scheduler: coreServices.scheduler, tokenManager: coreServices.tokenManager, @@ -54,16 +51,15 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({ scheduler, tokenManager, }) { - const winstonLogger = loggerToWinstonLogger(logger); const providers = BitbucketCloudEntityProvider.fromConfig(config, { catalogApi, - logger: winstonLogger, + events, + logger, scheduler, tokenManager, }); catalog.addEntityProvider(providers); - events.addSubscribers(providers); }, }); }, diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.test.ts index 573f21ce60..6a21d95afa 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.test.ts @@ -29,6 +29,7 @@ import { locationSpecToLocationEntity, } from '@backstage/plugin-catalog-node'; import { Events } from '@backstage/plugin-bitbucket-cloud-common'; +import { DefaultEventsService } from '@backstage/plugin-events-node'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { @@ -436,6 +437,7 @@ describe('BitbucketCloudEntityProvider', () => { 'added-module/catalog-custom.yaml', ); + const events = DefaultEventsService.create({ logger }); const catalogApi = { getEntities: async ( request: { filter: Record }, @@ -457,6 +459,7 @@ describe('BitbucketCloudEntityProvider', () => { }; const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, { catalogApi: catalogApi as any as CatalogApi, + events, logger, schedule, tokenManager, @@ -537,7 +540,7 @@ describe('BitbucketCloudEntityProvider', () => { ); await provider.connect(entityProviderConnection); - await provider.onEvent(repoPushEventParams); + await events.publish(repoPushEventParams); const addedEntities = [ { @@ -566,31 +569,22 @@ describe('BitbucketCloudEntityProvider', () => { }); }); - it('onRepoPush fail on incomplete setup', async () => { - const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, { - logger, - schedule, - })[0]; - - await expect(provider.onEvent(repoPushEventParams)).rejects.toThrow( - 'bitbucketCloud-provider:myProvider not well configured to handle repo:push. Missing CatalogApi and/or TokenManager.', - ); - }); - it('no onRepoPush update on non-matching workspace slug', async () => { const catalogApi = { getEntities: jest.fn(), refreshEntity: jest.fn(), }; + const events = DefaultEventsService.create({ logger }); const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, { catalogApi: catalogApi as any as CatalogApi, + events, logger, schedule, tokenManager, })[0]; await provider.connect(entityProviderConnection); - await provider.onEvent({ + await events.publish({ ...repoPushEventParams, eventPayload: { ...repoPushEventParams.eventPayload, @@ -613,15 +607,17 @@ describe('BitbucketCloudEntityProvider', () => { getEntities: jest.fn(), refreshEntity: jest.fn(), }; + const events = DefaultEventsService.create({ logger }); const provider = BitbucketCloudEntityProvider.fromConfig(defaultConfig, { catalogApi: catalogApi as any as CatalogApi, + events, logger, schedule, tokenManager, })[0]; await provider.connect(entityProviderConnection); - await provider.onEvent({ + await events.publish({ ...repoPushEventParams, eventPayload: { ...repoPushEventParams.eventPayload, diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts index 14f561473b..9135e0717f 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/providers/BitbucketCloudEntityProvider.ts @@ -15,6 +15,7 @@ */ import { TokenManager } from '@backstage/backend-common'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks'; import { CatalogApi } from '@backstage/catalog-client'; import { LocationEntity } from '@backstage/catalog-model'; @@ -35,13 +36,12 @@ import { locationSpecToLocationEntity, } from '@backstage/plugin-catalog-node'; import { LocationSpec } from '@backstage/plugin-catalog-common'; -import { EventParams, EventSubscriber } from '@backstage/plugin-events-node'; +import { EventsService } from '@backstage/plugin-events-node'; import { BitbucketCloudEntityProviderConfig, readProviderConfigs, } from './BitbucketCloudEntityProviderConfig'; import * as uuid from 'uuid'; -import { Logger } from 'winston'; const DEFAULT_BRANCH = 'master'; const TOPIC_REPO_PUSH = 'bitbucketCloud.repo:push'; @@ -62,14 +62,13 @@ interface IngestionTarget { * * @public */ -export class BitbucketCloudEntityProvider - implements EntityProvider, EventSubscriber -{ +export class BitbucketCloudEntityProvider implements EntityProvider { private readonly client: BitbucketCloudClient; private readonly config: BitbucketCloudEntityProviderConfig; - private readonly logger: Logger; + private readonly logger: LoggerService; private readonly scheduleFn: () => Promise; private readonly catalogApi?: CatalogApi; + private readonly events?: EventsService; private readonly tokenManager?: TokenManager; private connection?: EntityProviderConnection; @@ -79,7 +78,8 @@ export class BitbucketCloudEntityProvider config: Config, options: { catalogApi?: CatalogApi; - logger: Logger; + events?: EventsService; + logger: LoggerService; schedule?: TaskRunner; scheduler?: PluginTaskScheduler; tokenManager?: TokenManager; @@ -114,6 +114,7 @@ export class BitbucketCloudEntityProvider options.logger, taskRunner, options.catalogApi, + options.events, options.tokenManager, ); }); @@ -122,9 +123,10 @@ export class BitbucketCloudEntityProvider private constructor( config: BitbucketCloudEntityProviderConfig, integration: BitbucketCloudIntegration, - logger: Logger, + logger: LoggerService, taskRunner: TaskRunner, catalogApi?: CatalogApi, + events?: EventsService, tokenManager?: TokenManager, ) { this.client = BitbucketCloudClient.fromConfig(integration.config); @@ -134,6 +136,7 @@ export class BitbucketCloudEntityProvider }); this.scheduleFn = this.createScheduleFn(taskRunner); this.catalogApi = catalogApi; + this.events = events; this.tokenManager = tokenManager; } @@ -176,9 +179,23 @@ export class BitbucketCloudEntityProvider async connect(connection: EntityProviderConnection): Promise { this.connection = connection; await this.scheduleFn(); + + if (this.events) { + await this.events.subscribe({ + id: this.getProviderName(), + topics: [TOPIC_REPO_PUSH], + onEvent: async params => { + if (params.topic !== TOPIC_REPO_PUSH) { + return; + } + + await this.onRepoPush(params.eventPayload as Events.RepoPushEvent); + }, + }); + } } - async refresh(logger: Logger) { + async refresh(logger: LoggerService) { if (!this.connection) { throw new Error('Not initialized'); } @@ -198,20 +215,6 @@ export class BitbucketCloudEntityProvider ); } - /** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.supportsEventTopics} */ - supportsEventTopics(): string[] { - return [TOPIC_REPO_PUSH]; - } - - /** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.onEvent} */ - async onEvent(params: EventParams): Promise { - if (params.topic !== TOPIC_REPO_PUSH) { - return; - } - - await this.onRepoPush(params.eventPayload as Events.RepoPushEvent); - } - private canHandleEvents(): boolean { if (this.catalogApi && this.tokenManager) { return true; diff --git a/yarn.lock b/yarn.lock index a876413ec3..5439b536e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5303,11 +5303,11 @@ __metadata: "@backstage/plugin-bitbucket-cloud-common": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" luxon: ^3.0.0 msw: ^1.0.0 uuid: ^9.0.0 - winston: ^3.2.1 languageName: unknown linkType: soft From ff33ee2ef42b3c73bad18eb41c718d8ceb8eb948 Mon Sep 17 00:00:00 2001 From: Harrison Hogg Date: Mon, 26 Feb 2024 11:46:00 +0000 Subject: [PATCH 101/176] Removed hardcoded font-family on select input Signed-off-by: Harrison Hogg --- .changeset/five-beers-accept.md | 5 +++++ packages/core-components/src/components/Select/Select.tsx | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/five-beers-accept.md diff --git a/.changeset/five-beers-accept.md b/.changeset/five-beers-accept.md new file mode 100644 index 0000000000..d5dbcf88d7 --- /dev/null +++ b/.changeset/five-beers-accept.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Removed hardcoded font-family on select input diff --git a/packages/core-components/src/components/Select/Select.tsx b/packages/core-components/src/components/Select/Select.tsx index ec7f87eb94..b1a2139b85 100644 --- a/packages/core-components/src/components/Select/Select.tsx +++ b/packages/core-components/src/components/Select/Select.tsx @@ -55,7 +55,6 @@ const BootstrapInput = withStyles( fontSize: theme.typography.body1.fontSize, padding: theme.spacing(1.25, 3.25, 1.25, 1.5), transition: theme.transitions.create(['border-color', 'box-shadow']), - fontFamily: 'Helvetica Neue', '&:focus': { background: theme.palette.background.paper, borderRadius: theme.shape.borderRadius, From 5e639219f7bfd31421703953eba26433b5e39b95 Mon Sep 17 00:00:00 2001 From: Axel Koehler Date: Mon, 26 Feb 2024 13:06:56 +0100 Subject: [PATCH 102/176] Fix typo in extending model docs Signed-off-by: Axel Koehler --- docs/features/software-catalog/extending-the-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index fec924fa91..72b7c20e07 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -451,7 +451,7 @@ You can generate an isomorphic plugin package by running:`yarn new --select plug or you can run `yarn new` and then select "plugin-common" from the list of options There's at this point no existing templates for generating isomorphic plugins -using the `@backstage/cli`. Perhaps the simplest wat to get started right now is +using the `@backstage/cli`. Perhaps the simplest way to get started right now is to copy the contents of one of the existing packages in the main repository, such as `plugins/scaffolder-common`, and rename the folder and file contents to the desired name. This example uses _foobar_ as the plugin name so the plugin From 27177d74648a590f9887f55b6fde10aa3a0bf1a9 Mon Sep 17 00:00:00 2001 From: Peter Macdonald Date: Mon, 26 Feb 2024 13:55:25 +0100 Subject: [PATCH 103/176] adds the opa permissions wrapper plugin to the microsite Signed-off-by: Peter Macdonald --- .../data/plugins/opa-permissions-wrapper.yaml | 10 ++++++++++ microsite/static/img/opapermlogo.png | Bin 0 -> 585867 bytes 2 files changed, 10 insertions(+) create mode 100644 microsite/data/plugins/opa-permissions-wrapper.yaml create mode 100644 microsite/static/img/opapermlogo.png diff --git a/microsite/data/plugins/opa-permissions-wrapper.yaml b/microsite/data/plugins/opa-permissions-wrapper.yaml new file mode 100644 index 0000000000..64af859dbc --- /dev/null +++ b/microsite/data/plugins/opa-permissions-wrapper.yaml @@ -0,0 +1,10 @@ +--- +title: OPA Permissions Wrapper +author: Peter Macdonald +authorUrl: https://github.com/Parsifal-M +category: Authentication/Authorization +description: Manage your Backstage permissions with OPA (Open Policy Agent)! +documentation: https://github.com/Parsifal-M/backstage-opa-plugins/blob/main/plugins/permission-backend-module-opa-wrapper/README.md +iconUrl: /img/opapermlogo.png +npmPackageName: '@parsifal-m/plugin-permission-backend-module-opa-wrapper' +addedDate: '2024-02-26' diff --git a/microsite/static/img/opapermlogo.png b/microsite/static/img/opapermlogo.png new file mode 100644 index 0000000000000000000000000000000000000000..703e53dbf297c2e892b31685eb1b1a777eb90e3c GIT binary patch literal 585867 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≦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⪼ z9HG_pK)FLN8o;HOOdR5h12cHtbAJ@Oogtib0Fz*nm~p0J^ruW?c)WHq+9@0p^&-k|KK zS%C8nU5p!_{Y*5wEyVV45+f&@JM*+F0Kk?Ghfr*%7o&Pk2LQ0cT+Ll>l?SHcR|L@r zp6$Y?8+_2m^_N_QLkG`7K3qe`Wl>)5i0TE6O?rPu`Z+sMe77D_zDH*sFZjim;&p%f zP7Ff>R(F?B6XQ{j$cA(p#ECyi<)CM|G;8GPK7q6E0Orm)2XPuB@v5oX?0eVW4`;_I zDWU}cKn6)|zR-k;CUL1^oHXqEfuu6B^8ZS-QU$P3@(fi-2~Ah;pzD-jjT0-{=!z$R z8xCvlFU|=7a2~K2HxX(Fyws`#05g z4~e~ik*KUX))T^gyVTN ziH}>p!Ez$H8Y7@8(J10P72RL`SM_ud>r(Cc6`RpYjF*Nl$bG5Jw<7gJNr?ymlm$hx zp6DyK_6v0YfbQ81&t)PeQhICjdl)S*BU@b)zY3F#8OFewEUbM5jP$H62<12T4gk=W zzb%rZ?FvAX2Y7-{0ydjSEv@67Ezd{Cit)6ET#JkM%wlhM z2I;Vmptpv$*MLXRkIXU4LCh2e*MQ&j5f2%useEY~jRx)*oq#*n#h%M9!nHR%3f}&` z$g(lUS%_Ab5~MQ#0QgvI1s7vWtNV}Ea2BQ=yU2`4BTL0*5j5;$^VYB8<^EO`bmp9?9 z@BJv&-5DfwYPW4Mlp`QbuuB$wn!Rs{{nHpr8o;GbSDoU%<>%V~0BXBpQ@~R3f8+(y zfNt+T%E7J)+U149-2SR`4GQXw)t6L!UBah- zz6$g+nFrJLq#l?epHz}%lvl>77&8+IKrwDi^{Jgl>dUWyUDZoJA91Uwj?bw805Q|F z3&~yDle8aNzdqp+SK*~kejGZ z=VhlOuzvE(`QN?uW<3AJzYffvjX~xMv(0XHq;p@rwgmvVk5(A~+H+;)u@m)`TqB6a5#}k)#=}g%5_eY6D^)kg`YI>#2Q$7s? zAjn6P4m%4MayWDcmD*N$0c~ zY>qH9vw%49@a5Z&;U|9n*YTlGeF^aA(d=|Fj^b&NAyR}KVMMP1N4Xc%SHf+gao_;j zyXO(u%2cvs0O$Y!KU2*FkVb8l1LLUxfGPvP(IgvNY$lbswwR+N#jw~ZJS*63(>t)* zflcqw62K}CkUS{T4ZQYz68sS_2n7JJvsl-J*1r#fK1|1zc}M^t$~@&YV4LECK0baJ zyJnj3^AY|(_Pzt&lCv)RnLel9vc2yno3`m79Rfi>&?ui&>F|Y6j36LI1VN;IK|sJz z0ti8rrhp&_h#*ZsBqCr4q$j)Cdh2P^XFi|*J9E#>+|AvrA?5e;4&-*aPo{=U(`9Y+KjGnuLA&NIX2s=AFfRIu-zsrjm-DGmZE)mdHQf z(Bab=TA#pI|NY1K)K|ZT?_YHt4vjjnI_u%Ky6Dm1EgHhHN%y6tMuJfes1z|@1_7E} z)6e&k2Gx$8Nr%Jo^}v72`N|FJ^gK7bkOj#uJva5D2V|{YWe0d{Jj%aOeE{d-$^gL1 zfSJlF93NU<@bD*Gr@}ZCDF;ou#Got;kPLkUM`jT&EFc?2GAH!){=d z*w7|7VZX28Eg$$KzPZ1TBYov8Z@Q#nlLQng_DY^1Up>EfFaS{Ns2dKo=k)o}pxeAx zG6vEE+Fdl)Y=U#DIWL4$Kkw(K3JjwQ^%K%mO}P5Vf0s03u?LSrqQTOeRg11lmIXXUYG^c zD(gsVxzK5+fzS(J1Rf?Gy2^)GKheg6?|Bwn8X(qU_v&Y8VKhRw(}Han0t6T^5qcrw z)IpO}!H<0wZ+!cEal?TgoX#Wy7Th$g5?6K&29p42rZ);;agMnSB>f)HZDISp&c%G} zuP6biGXQA*Ro*ZjO@k?VdP8k2>jh;U01&;m$sJKSNhBd8E#go<7}bVd2Pr zH1r5-CYs`=JiGsT+~*!=;f2qA1|Gb14QzxGLspo#mEZ5N006{fSd;(&vUv0qLrcfV zu<-pKU5Sr>;a~9WE3QK1u0hmXhhAVZ!%PYtmjGNegqFqvdJt$7v%`#e+!2DL8aQW- zRsaAX07*naRHK;GOcD*ddJATkv0!*e@lG-mJsfgVP>I*fuW$Zba?(pDKw{0xkFUAx zlf_L|kvbo@CIJ|a;Ib9%7yv*-9Xx#|B;HGh5k=@Q8-8ICi?`g2XpRPX1f>%7xLR!8 zoecmq68gI^x-*lnl>d~EMx4PiZ1}?fX_TV7X&t<91kWFm4Gw$XUYxUK9nL@f9Gtd! z3pT|zbk6x;1T75U>ydb;XqJXB36YH~G@C8CCwQKR)Q#b5F$QLg*ao(pvIh@()Wd-- zTaY9Rh?~{-vkU-`YHoZ1)+GSP0s#7fj!wISC>V&hN!x9zYglpnG5fu5l^6gS!iY}m ziLh?OI9C{08X~=ce%e4nHUL3gN=af5wicsdrbt2`UJ$@-twZK^aPy)MbNvZ;&f7nT zU+kO3?A)T5tZHwnaZsv4S+YJ&*)C=%B0AL&g;41bGOZyFpTpW7_#Wj=K z0str^NOcxKB?eGpMeP*elQxXbX{$BYYVmWo8G+Twc(*wf|9js^vhTau%|qZR&v^m<@e)fjfnb3U|6X*j=PqLx4iBZc*?Jxi-}H#VQ&sr z!$p$WlFJ>jL?)GFE>QA;V+DKL@x(h z3c!RkQ_vZoABVy@)#b3b>0R&n6`zb5>R&f9Ot4VskZ)R(}3>{gxc=5T=>}#uXydN(VgzX zac#umY<_ms=gSfWJQP&HSo35&xY^}b+a&Fmo(e4J4apJb&j*76y4@}o*nzT4*pgRI z$8u^Z3xI+3Q0@HN%7tFuJ-a2|h+&dAd&dr(yZaQh;UgL>qD}K7(?u5dCDA~)4fv4< zLmLAUBABRDJQ)^Z2d{k18}XN)_&n_PG;DJ{hJk@BYmg2BgQi3|gMqt5$=znm88fvl z6T2U9U+kNkL)LPb3Wso{V(~hij(m-38%}x@sihGg4sTE z+#6`PZ7f6%e0I7Ca#$9c;)G4#3vTrD?s|!JQ9mD?qROmOX`U@+$d@~4(*5U}qf8TJ zjzke0*OAUBuTk4}L}DO`BLxMZ#F$?)36z}f+IpCGRr!8Zm(`T7A}JBKZCk!CeqT2Y zs~04O#dE6UD12SkewFpv572G3V8j`Qi#_!B&mbEt3b;estsM`R{504(4OxWe;~{L5 z8p8>7#>p{mn{`isdJb#d_5HTqg$pNyMC(KIBb?ms;9=*Ug$HfifeFf`78fyXq*xgA z;WlZMXNZ>!0S223L98M6BP6Xzy2J#FM&1Zv+rs8OC*jod?}w>VPrxXRV6-eGDPuBI zbX$sX=J$QH+%}L_*N3boVbGR&EYy0=bZ%5jzZ_{T$DAIlM}G1X^!Hy6&4{tl>PUJ_ z)a%KeHg!nVr{@QOQ3GDhqkz|H`5+U5uaOcs*Gvqix8p6BUWPAx`v+LmyNH^bMNSa= zLy6u@qal)r$sj3OE=@!&_zAOEY}p^^$wKXv)fg&EC;aG=7LBH}IUvgWGJ)OlQ7?dV z!Qaw=5$xD-@~N2GxeZp!#%z!vv2+k448^}gt;gzmkI-QnME(N&+hi41UN2{86*0ou z5+JOYb-e#IYzJ0GiAjV8SsNll3&lv0mxuBQ$R#}*WM7k-8b)MOW+qg0uzFtP>H_f4 z%TVW6GGO?cNd`+ZguTMpJ+4Qsb_)%=MaxU_ASC`W){dQGQ(oWdCdR($L(kV|gMJWP z{z>cM#M4q3k&Vc(5fBh+I*5}QbVf(;q_cP6Wlwt?HU$Lj6|0Jn6dy-+3IQs~axz2d z@Gs1V=&sp{Af3dU-u?l+>jQs_-rNAjK+eT~CJy&b4+q%fEKJE@5fz`=)hY#`U3mj_%;+wyd9SMOy zeA7GdcVGDrg!Uv7#%D1OP&!!ua{xe`>w*F7_7t+P55vk3j0Qj&;i->#6fS$?3*n{< zQV?jC2`|WGqd(~RXtySiMx-nU7{xtgwt>X%U_P|*)(`$IzW$##;b5mD0085aL`R&v zc*|x=^w-u>2Uy5~yt#3sK}wtG97sVQo92lla=9R=OA6`~S5giwG@VscTMgH(cL?rQ z+#L!hHUG#dHvJLY;`O1jt)hul2w{#Bl$QV|e`O0(4rSufPmPlUBceUmr3gNq;iL z2NV!G^3%$Y#pX{~=52`SXX#u_g#CVgVA(hzKc=^_R&(6_3txtKI48n<0swmi3Fej7 zR)-*p_Le}t)n62>2TDSKs?1#Os(N%zsKelUILOdc&LK_+8Y==X|I1iZ;e>)A zy#iehEiGL6zF~T6`e6o3`t!zj5@Z7KDEVTjhp{s1P_k4T1Iu%kOsK^Wfi)H^QmAOL^f=mON%bicpGq=f&qAdxY+v- zCq+kBz2Ds!gz@4DU20~+_F}o$ zbjiBH_ZGH6>PX#WNu2aDK>?`Zmhcc&GI=>rQ8?^6 zHL_o6y!SH1{H?j2ZOl6lQUP(E0EZS6lQLDF;dNXhZ2%D`uY>a3d#3E-1V#us`GyC? zd_^sXLX|1}zXF@e6c=+TLQlw}XDu|(y<)y#M~{_Ij-4ZHwwARd;oTJkK$h=AvU2LU zL%}cVQ+B6?7`%GLEu^;#U)NC_TsHqTGnw)bnSx-Wq|SQ1rAF;<`#{p;UIC2Nx?5@cP=x2HX-WVKCzu<6t!@o7?S=PcOKwoLsn1_7AmVg1xSx@NS!aA;7}}M;nw3ZhYU>@{%DgQI_UrF-7A30?)*h3K;&K& zPsMjRaf`>^!yjqoh6NpB3M)j^4C_cVM#J7b(?;CK{4 z0rMmz5IzVK_~Mnt#chH*eO*cvA|`9LG(%|c%?3(STPNJ~Sm1rQ)m z&C;W+l2yPPRk!fWN3sw{LWG*a^Lq4sj$l1gof|WBaij2zW9^%KQK%u^jQiyXnYv)gYrU8VK*Q#dY_g^9$P9* znxl)<=nw#iLMrMr zsBy!(#rZDgpO&pP2O=%!qzgAWTckA(Yqv!8fD@6uOh^Jp-*1*`>>$zoSL&Nm-e%JI z?H5K7g8pPBaoVojjn5g{`>i2deMD9Z>f$jOAXTu|F*?L-v&%*Wu1#Q6@70iK{W}m- z5qU3%3qS3=@1x9SPL_3iOE$O9_`5H8Tcc<@u8ntEmp?(!mn&Qg zH`@tj-3l>=EqA~|J<$tSS)i@IL8P%RCQwe6SR(#dT`AMHDP!Bk(A+kHbm==Y?|B-Q?tHhDSm=J|O=W2P?i0|SP)dYP-kliTUu z=m6xeXis*p9ea14G-=1l9-TRjv7K-0$XamkQ)@LyV~-C9HL{ zb>JDVIgJ?~T4GyCEpuuy#@tFO=XJ{vCf97>zcKEv9lO4q`*1_ID9yzhEyS{~^Z7Q9 zb@$^f5XG=R9M8vTg6RahL`S)7!g&Fw^k$oi642Vpz=QiH&)! zFzedAP`l=PxjH149u${f?ds&hJSeIz_uj}@KN%=DPg?S(tPyL&eadB*wmgk3%tf|> zV@iLi4s#s^RM~M$L}9wY0+BZErdr3fRpwjSK_ICT`^~0KRF_(g!QdLoz%xuN7{xd^ zOW{+rD7rtes50(BJi5UOm0YTwB=_PWVM`N#r*G+ydyMfZ>wAbeu{5r1SenB5qm^|0 zWDTY3Q>D?s@`rTmFsZB6Ax~KxI*^?_(e{5K`f3&fJ2?Hsqy2Oh%R+$zqDN_m80lI> z#bJ^Pe>a+p4=fB!DrFnY$jAzI=#YdEbP$VGM8M-A7+M#hlZ|SFO8F1jCnT`P&NWnS ziQP?e_@EUX;8FCFhBc?{|KiEv)gY1&&3}oV$T2b8#f2krn`7Ez1-|8b{0;o7YS-t| z`6abrk^Xq!B^WS`cKrG`&t&ew9Zdyf zAVjm2HsNH63u}szwmkYARGSpW16?x62|}unN5jlKOI+0$K30-0r~qu)*pLuAn{20kki zE?sRg4WaCuG*Z;+Aw|ZA{1ylg%!_u1uBBY0`8|o=^uwQy>v*K0h(zYmN5$;ARRTQg zF0d#Cw{i?AfQ>KFm(QYFfk?z^3Vad6gnbcksPCDZ(Yy`_$~W2(&X(oScd(rUpWNid zh=#1G6x=`vKf{trX#DfZ^wVl%=z*V#b`F27iIagPgUVR5YAo)gRjj&X+3_m4pD+!O zb7?f!E!|4VHII+^y?QQ1C13O2b#qh*g-MPmA&%pd0xnSDoV0U!j-!ZygVOoWRp;vd z%nAUqL4TG$QBg1v>p6;1xK0zaUN@=qnsLNdh%1|-KvZ(2AAn$ZD%{d&haX#vyzLL{ zn%muxhW^IW!Pf3^v+6%#6A_0Zc^=he>as!0P!d#>3$rWpB{Cg0{=d zInOfB$8ukH!r0E^pw>v0&f}5T&zm6`mBR)PNPba%Pj^^2W(1i-N(y`)DERh-_RQdtw+1;+`$ifyqD#nW? zT{uGCvr#p82h?2Y$kCVt35d{L@|2wuq$jOWy}CSZF01@ocBm;5+Zf;Ce;gLA+U9|Z zI-aGHgWI3dT$zVA)Vsb;KMY@ApC1|M`}&&24VMfdvZ2wI+w(Z@7L#&34gFg6??!f` z^95jGbyRB+nNh8N06F-HQkr!fPx#)PaA$T#C7AMg`@z1jplFeAn!0W|gncSNctN}L z{uMHf1Wt?!OxNxd^MTgzpRCq6^)Fc(IXGj=SQIQ-Q_D;C28h8Lu9cNIHr2G<{n)$eXwH zH8}-e;I_j%eXj^D`ai3Ol+WDSCU;sU0Ja`iB%H>(>phI3GzH|O{fvZP&Y&*8IhUQ2 z6TAac>1+K+^WF2|gV6Np?_KB1%Jd8C)wW4naDJt+)xo~=-9?noJkTqbH)P8l z%o{vBUqxbR$!&(C_^zLn=J1$2S{9NO&JOJYSVLy<+xiPdJp%>X@o_bLjSQFfAW#H} zBqXZ|PSJ{o$o5^A+m~9ZH(s}=8*ml5?w+1TO%7PbEf%RAnouICyho2r1)x_3Cmd;g z+GCF;G;Hy{5YeP#azhL)7Dk_a9x5!8?LcckH>ezt;bmARPn8Q%bA&veRD3$dFzQ9I zQ~w^kFN%vNFi;&g&$AaV&3D1+)UVNSrS?9~7OSEJne3!;&QHu*o9K}?4J+C96PBeh zBFdd&Wlxh(!;W!D7KkruisY#if*96(ydxoGwm?)%WboM;+YSU!g z{*E7a2E=wjGbUF$2<4LU=?^_w7*cT=iT3YXG6Tjl?}two=$IMcQh6&y@Xlr}UQ7VJ zr-hs@O}_`jLl|D*27m7(YdB35OQ9H|(H>W>F}cxipY~?!O`;gp9R5FTl! z^P%=Aa69Yb3+5Fy+K@YVl8gqU9=R5d>iS3o_QY#NZAhm3)6K;b$5(ezQjZ8`*%@&q z4c7MQt`>`*KG=xL_+1%U()?mbA}50dDdWa#c;U7RJh;qXY)vq3l-)Y=bTOg`2(dqW z=%(Ul;wxWO`Ah z{;V+D8jdOF6Jf>73J*AnLftr87b+y zGDCzZ%)?6HQr);RAO~4=+OlOkdh**I+34}=a7M78!TThiq#bDAuxlYTBL>q>Pn+Y@PcVZMN{qe>GT)B+So=CA*YYBHBt^HZ(tiL42 zo#~!5egFej=`xD+my@YhJKJI}B6cg)7zVrHA?@hEAj6+}>PW8U%`zoiAu8HfPGd|G zWh5`hc7&9+*MI!ir`(qhj3S@VdlRGrt%?8U72ERiW&IZjLvp0Q`vVVPKl6Q@ZkIDg zH@*{Iy}p@o8lJ~E)IuDM+f^e$s@9blb7#9NM+E3tJwB2K5@{WJc|g$&Lo0nwLY>x_ z;nTP@o818M&&M2Mfb#c6MiKAa73Q+R>0Zy!FJOq&pnNb(=&RpYy)fdhE$bo6Wewkr&J1fk}8Lxf*uG{q&Hue(&Irl!Z8{K<2c7 zWBGX^>Dz$c-^Axdn6%21vD9t$UUv^tDc%M9A*JEoMA7!8E^rRGI6WW}V6F$a`YMkR zwo&8KGxIk&d^@4;O@8`RZQoG_f+SmXVu|#g^6Ny2Ddr)dFys@hv33=V3*vdzJM@$$ zYH)qMnApAi!j+K6zo%lq8loF>v*kW-n%i(0gXM?*cJaqA^s}VVVi+?@;TGA_zftrd zdRGeaRZaf?4Oc!o@#{62II68*oPWjpSM92ps)bgdF6IU3hekE63vb`6eZT$kb5H*d(oO&-FWw_jz-DL6G&M+9bd)RaCk z6=L^!4lJ-zqC-f)uzb}rasW>HAH=^#6el%Aj<10STLy^?^ECpJe);bJfa_hNR-lCn zwJd4l!7GLT$=$7ydjm~`K;nP*Z%}poTNvvFXZ}D6F<~`^JSP#=NOooS3J44STK_CRrov!q*TEdk1U0-ev^E zDR@t`1&Yhd?t5?i=a5bapY*hDmK@#eW3{Rhx0Wm12f+q){4egcILt|PtM`KD9z|Pa zV4yVqD*)L0;{VttkyZ`9>W%_xK-Z#3l_jTF>q($ERn zU}ZggXkW;c767zi)A8{~(+Lh<(UzaoJ3qy+Ew)*6Gb~=l2y+@2;!v*v!=GVrZVMEN zCnha6&f=G0F(1lG?|<}Rrg%u0Sdg>X2td3W!*L&EyI}mUo=tYaN%HqCZR2$B zf%f9h1&xWxY4M74{g%HSpWPe&EM3Vmt!)B%Uf&?_u&jpI|YHX%kO!4BS z-Y%-b)EjYvAvX@$FZXXs=TKYkJJOB78aGA@Tr|*_tZ@&7{zrCeRRK@JqxCYwbfNqf z;f#EMBkscIW-kn25Cj(k_M3DeY(@1T6Qz!c-0{t46>>|#? zR9qVH4|&o#{-Q_L)DV%0B2Q+{(G~=2FeDF5|7-Pzg_6=L-8sSHCEwaUy*e_Se3cqf zDlU+VV<$6Ctk?oso^}PQ2oPsHJ|0+Nd(!?rrYHBG8atT#~U)!|Fq!sgz&kIjaK=d!Gg@xD z!@|x3hGspX)T`?0YBJzA9D^I8cvq@AaPS>3`6(pr-n%Ilcd`cgzt1Kt@(kj({}{?w zTxd>zAeHvq#NkJlRziSb`f2!RG7e@Uz>NM^Y-({y(JN>{OoJQ8i`qM8X*Kui;pvG8 zwewFDN$^(*jajZ8+3jz{*0R{4c3=cgWV`^NbGA_Sl+GX0pPVpg+kI5ORiCk4qG8NG zM`SdB20XyvYA~Vjfhg>=h^s#7lM^D=Y8!cF#{-QW`hK;{uEtjxb66a5GnmsaT_pPa z^@2t3BGr;+dbw&PgM$RtN^}MHJp<;N?D|rQ=E)_I-0!qyI*cId_SoV)&&-%FQs-4S z-yH4MdE9f>ildd4_;gop>-?d{2sz|MC+hNjIVv*@D1nY6q#o9Cx?CWZr>z5Q1&$x5TnwZ3Qdgyz z#ci|>L(?IEwk=-vl}tZ~K`Yl9lkZvPvmnVVe7{^seOdEQ^-a$DX#56sT`}V%J$lAQ z=<9IcHzXj;gAg4EqJGiE?Cv<0>7;uP5nQDv$@(BXq{(pWWDd(VxA3cg`2T0DvF>wRH`3uuMozh?!sdaI14-OwUkz}_brZxMd4t0_`QucMLK!87A9 zZhvrTWG%_*cny<9m*Bc;gv<{F6mdIsU2D04h^Be+nQo^ah$RjyVb9qv4E4^+KYiwm zYss6r%8>ynZFF{dlBLbZR@2b7kN9Poq zlfgh8TvWAhf@Ec8#}2H?gM6=ui0?09MCOsO97OHwG{W@Kzqv1JYJ$fJd(8?GK7pjY zIK}$lf>%DHfhT6+v#$HDYu^L4!$S%lKMIGoV04n#XnMdXTZs8~9=`o1i*g4e1^SVr z4o7pfm2!qLJEjSW?BEj6irZV@KWL-%keEOGm}WkQ4=+GibS5UI^RxZR3l5%)Hik0# z&wmg~9|Far(f2hKia&ubbbu)U!MxsE9BeKv1mNib$PRl{q4((HYS9_B-?x$|b}{OA zfOvEFUeA||t!`6sl`sCPs+K5cO~pm2!k_2_E{o?nog6@sNRA*(Ok@7zC#6=La-D&P zGen?PkxRw;2^OQG$~HA)R+vZzugLlC^1)GZUu2W1v(vGTv$D|ev!}t?8 zC;$jG@c!LjO$o(pAC_WXhoGsfZmOs$J{qEWdRzbwNdK&{Sq%@ls=C5?`iiI4PP$SX zFDnbq3cmf$kc6~j@3;f7W zdPMCuLWff0r(tL+;Q)eg#If{#p$!*-^@n*Zs!}{Oi84Y^e=GZlVgh^t1S3a-v-qBps&xEy#t zLea;^N}8H<*?*WcYXlA6%vy_=aryqPCPjDutpmx%H@`M?Pt9b*61XBR_bd9|nl|$q zUwl$>tvM%F2-zA;1(AQc>|=yW!|Pj{7f5`9M8YdY{YCM9*zk+O^4WjSFWvzes=F}7 zD4h~dWyWwyLVTx@uGn6H_LmFcOOTD*g(2(saDIJvT}v1)%RzTd56l{U^s*|9d&;4N zkxto-K~t+l`V4&yl}&9K%P4Y?=Y5)v+=DjlfwqZKutt<|JVZF09=1AePds)DC_iZ5 z!vp9e5ViM%v?Tg_0{h8wNG7=(=L*In61F_(Q)d-Dd4VpCFJQ{fmpU*1_)v}WXK_8( zOKu!Q90?gwH#Ycf4g-lpZV1ci6_RX^)>SNH2?M7PcjSBsGfz5(p5H4*YIe2+8gqaF zmIKMoEZ&c8%^z7nMn=RGfH&5^2+u^BESTHN#JINE2#Q(i+)@j9MLM`#91tdU$qt^i z3smzrDdr*TL}tr#cEO`tGrT*jMd#%Ep%bEXFM!m(N8_d*^kSdi+=kGT=k#a@7iAL# zOl72Ck|@QvtUjhEKZ#D5SAr35->aBWdB3(ss!1&~cYYH1TSkY;!VtBZ8V^a%k0U%U z9@Mo|{Av!x_IMBCo$X8NAasivM4UajWU>{s#?+i|RN!q)#l z5y<(Lt&r`0oKl&@Vxu|_zSR7!rj-KK`|y(rxrcM2uC*0&BpdtKhBB6aAtAz2ikVUW zreYhf%dLMDL;4*;1OH#)FYgP>0CW_y=Yn7ztz(^(q^s zHDgwo&vcYwtV4sQK2=q&t5rK|%9XhrfF-f_OX zc0h@0tVyfagO)PI$$D{DBBRJP`|6qZf8b;d3&>w|c+M)KToX-m24~+}z@Z^JN(B^1Q~ zDs+6)V!G08opMI`_5!VF#MMtBTSip(S|r%EA@uja9&N(lx_GBC9QZ1Y<`<^XQa6=+ ztCp73^rhwZaJd0L5@??ZrY!f@Y!zO<2jA1%nYq-Ba@5>~fdWh>_L<4DnLpcf;oLah z%ww_0ATrwiCs%k|2Lejpu@UF-{<<(i%z~9s>_`(+JKV9`TswKidQ-7Mj?r|F7me)T zbLb|a)AGp{2A5TF!EUrdIzte#}i8q&^C2%fypTzvUD$HAS``vmpAlvr=z{p zqSHl(ya6RGThve8jcv%xvU_AL(|>m+lm++@r?YY>E&EnB`%KYDy8_nWj3py5CZ;aW}zzTZ9tIlNqk0m3joq zVx9w$79YB{Y(pyqCDUDbd<4aW2uOr@jZ9WP^nPRERJ$Q$0t4@Se3bFn4MnAU%`Ulr z=*;PB8qpcEbOXg2vRt0JNzh24SXFt0rr zR@UA;_FYeV^<9R^6y}0pDsHgmcTnZPSu~7Idm5>DdCBm@n(zG^w&{P<9&<|c1A4&6 z?Uy#Y?rXT%g?X*+UUNFtsKA$NW{w};W>NF3V>yC=xPC{JwC^~n-b@dXMS2(KAi7TH z<|JtI0-cCjZblBo{@tFJ`8=4DMVnhfrLxt3r*-Ki0R+?#1b%LcCuU4l1fQnbY^|@j zZgYC6J76$QFJ!-Jb{W*(}(7zzFCVpj7C3GcbYmYeCGceyv3@Hr2^lM}+0 zigXgRr*fIn6{b5Vo^u!y)Jx%CGXRWGe8(noI87>c$U>0rcQ*ZX=>pvHmjZ}ChtUlc z)%Vd=sj*zZwJ~qt{jR~5uPrGzPLGy#=SU<$)+PO)OAFKR@t<|n{y|=g zjjsy@A^E{}y6Wg}bDE-K*C8?0UkJ4C72C7FfdVgYlO-+dVjJ)aGAh$bm&B8UIT{># z$7{y@YlJ%P=yZ~&)vBhDO)i?h_WKvz=8ZGux5jYEalUC-{zV7qJi{t7y7)ar&N^(7)^9Q` zjl2R;ltFgf*hapY)lzu-EC9C4XEPqBz6rD6$#q; zd1h;0%41#u9X7U}(!gPQyH=fd+?rQDM`G;^`*5eyPOqj0&18oVT3(1?rI&g_rCBjP zz7>w*l`=L`@vTj(u`hn9&P8nvz@Q?Z68^03lA%D3okzhax?CJALuBgk28ya^Cm>O( zqah$TO`X$Avn9{<1eI9l@qar}BA@Wvx~}$`zX8e<5v$?p2ECP*R1v^4k-t_^j8!T(OMF0N{Ie{A`}7^~m7@NB72C z*B`!y|HLVzbo$#P4M}ssXaID>-#s*A>pWk|XzEgcwqe*w2aX%rF)R_!NOG`}eW$m@o~kjK5I<;LvI?tP?eOvl0fS?wgIZ9;Kzt%SwdkiAj<>Jw%F09e1j9czV+)7-4LJGi=B1Cu}G?mD{?*o z#Ki$QXk~w~(FMMA$EbfR+V}*6&ert(EnE{O%n>#zUr^pjQKw%4gQSdn%sXw$1@fK! z4gH{8hyb3P{DCf!tip!NgaKP;Y#(4$;{4*9%IQFR1(rjw68yk)NjyGoTI(ma-S^Yj z^wN)|I-td;n3Df0PcodN+z-0qouP*HLJtN6v)1I1B3UcYl!b$c@0=_wQSSxz=rZff z33;0_bwnt`rPM5{$P^%^=@rgvB|M!}v@GaFsU{#ITTY7*kyP7etrj=Dv*#78z#11q zuI4;15h-5rWc0ejVY2+QG^b}GVEUcmRR&Po&R(55^g@Pd=qDoM5?XkzV)28Yq`UOb zM!?S$6H}2k7&bh02%Er%PAkZULBbt3(rR1QZs)HG6fAn3McH*|*Ez}&cvK%p&mx$- zAQ|7UhPcRoH&>u@5iOv2tY*`uMnT|RIbs4jL62C@sVw1 z@}TA=F%wA2nYTL#uWR|urS)Yfwnt;4MNE)qV$ZFZQ#bGQ$q)cm*Gdu5wCUi153iGq z7a?L^jNPtqY5S~C(;Q4?YdU+lEot~52;Gk~9N1EW zbF{&8ZHQEp)prMaEHr)50eBp~AO_bN0@U{fZNqCw%vAEBuDi*C5)s?_F<-e!?S~HN zU)3D$rhu7fhE=Anv5rQ>B_+Pd7IyB5zd;qJbunwO^csXz7(+&3U<7)&r`2FCZ&GPw zkpQ=8Gl$7OgSB|eTDu=ry3N39W|wAPV5@BUqpOGTZENIV;@p>Vo=HlINA&u#YCwb} zwsq4OSqjc55}%5}Lyd&0<=fR{PiUY2N%LRldZ80DT1nGCG6nv5#NEZ5Mu)T{m{o|K z0$$2J+5cAhkiTQRznfM|0i@Cxv;0=<svf_Rzx9uZP%4mw}ms#n}evko(JdW9tNaKK1ti#cI&y}iVC&UI_J{y>$>4uQCr~= zqeriav0OpU0Jy^S2!?DM@A&S|!~-)$3`$aDy-g`Na!kWiF&q}%fF|y^nnG}SN5tRRVS23}8eYk*K^#LaF=xy&2Qg$Cv!Gx4p zn7=d}Vr2upsNtyV-P0k0tpohV)=qy2UV2qfST=620vMS`k z?nxMwPYQ^@aWY=I5`!#*v_HY!WO{71%iqZ798UrYrzdNz4t}X(wc>8K57J4@gm-YK zZdw<6YPR3mt`O|xRn%_+aFqGa$uh6Yu>5PBG1TQ5=Us!OC7U1#*0V$g4H|H&0DmwN zatJk7#sNE=g$1X}b)8mb7U++cZZbwsS^6wWMgcr9OAP37+lz$s)g=G|JY<;YQh%&j z3o-QW?hU&SppRb2ryn^;^gVb|&6}|Xm?v7<#p{x6t=Y__)}9AGuN6RYUlKy4$2Uty z9gWSR9A({xTfU|gI*mD}t6C?c+$d#K9h|xt)#@BRQnV%q2L~gBKiV~{T-0dIBM`j7 z*DkrSd;Q0RBK~2>gi&mBugifK#fn)5&81s(1WwEnpC2x@`oVIyBxQopIBC+3N8RGj zONO2op5JePk@IYB)0Gh(pu&r?rlZkim75P(VFqDK8imWyAu)VS2*!nhK~YAX5Hng| zJtp28Mj2{15X78Qqi|nRs`4x8!*L)2-UZRJE*D9W20`q8w;3EOD@LhG@Zcw<+NqLD zD{3#{@omF1B{Tj*hUv-B(!Q!&>`NyC6_44U9xE9Jksk=AwES}B1W))=_U1x21Zb-S zInOhl4Sas>5czNUzpqgcdtz=qj&}6_SPhP!lCrBc&zn=HB#uO!+T|K(;|Xr!{A2&S zMu(NWuH{&DqUJAkKo`#G^655O&J&fgs59!P4)(RTDJH~^2Jiq^)|ycNZm&@aqa1l} zTA=?-FVr`Pu=NgCZ)3rzVl8CQW-E;FQ%TxaVYq_7zd$OGsI0!!Ep7&$nUJsCYo%B- z3B^JSdMn9U8m{lyc&%%(5k&1G`+P!NH%;wJKxLGho$ucKJ)FOlFEw7K zgEUj<019-*o}0TdkEQnp@ciM2PI$c%IWu7OewP840PK%5H5^0{Q=5Y&woQ>CoZ(AQ zfbwl}pz7^E(mXFk2BTj@Mh8obJ5B7qAD(v#=*rdnqfF8=C}!`yipS`jmnr(F`{g8 z(-sX8hB05p-ywNcbAtLG6Gu)9j@>kT`lIXfN~wYvgk?bVG~7Wtloc=6^ypbwx#A*p z(-g(`_Zb!%*7!`bjLy4L1qoCT)fd+k4iH_yhlPiTA9`+8DZjG>G6E5V4j7`#lQ9#( z!|q8@lB#1(lp5o6HOIxieJA+23tU)%WK{1B`c)v`RzBf-$xdVcI5ZZ#yqrZ^L#k}L z2(Ln{;ou?ph8d?`If41jCL%;^(5iyh-w!%8_Yf%dhIT`e7$(SQTzxQfPOc;wDR-hB zv>4UtpqWWBX@c>?q-WkfDoXX;?oTqqJjoAMC#!=gAb_B4HS8KaOicV|+i-#Wh^_cm zN7YTuGI3NO5Eu{)H0!^Mnd^;wl`z>JRk|#;ZyI3L6s>pHxt>eWlqv#-kGYnT zk4|K0wCr&!EEqI3A%S-{JoTrdC?^eeUAIIg4$c%+Y>P^S)*qZ<-9=%bjbREFc#$Nq zh>sr6p)Ug$dB7HNSA9tIkCJGHd(O$tU}O4Z?z=DO#?DxmAvdeC@W3VsZ=HjU^}mCr zFpW7o>^B@xVA;JT2>tp7uVM|JHfcufEKprH*IPpxtr(du!I8z5YJWNNm?~rPRA!Li zZaZMj_VVt)FlY?Loc;#kw9EPjb}tF~*$5YXof+$YtK4o+D*VVsJN>kh!dFu&y=>gH z;*JF$n?OCZ+#IVYg>rQuC);dExtSI@tHA?Sb8q2R@iu_>Ri#!GPF2`&?$MUr* z`8Tn7o#vSD6Bxlh#1L$+AS|QmUN#ViudnCB0*h z-)bvzWi_JYVrnd?tScjsosS6)pweGY^^a&bkQss!)TuF_lVdfbz|HSKikCh!O z(-1zNPJjpn(lypPmsYLVeS8xH@HI^HMZ(J}n@K1iJ2#B>FW5ZhNH-t`sd+8Va8M^e zS7p+b)vN%4KR#BY{ z{l~|HfdxiCP1nmN|NVY#Ex0K4$W(H}kI`j>gFHW3DMwIebrz1b0Ej$+7SuOIam}XK zN4ozJSCW2MspE>=`Sn$8$Iv_uANLpTuZ|o4!;lXrpU=dMO3)21;Q^~DZZlsTv`|bM zL2p^t`jDmH^76?{tuV5v_z}u;EIbzTcI@bX%;}(Cq6OyF5Pe=W0=SrT|9XN=Ygmp~ zgICd1!;38eialfnQYxYDN>o2xA!zNyi>_Vy9?uoR6|qaQciU{35h%jxA6=3e$SclZO*DMtj0vk4zB!$k}GhjY)PyZKq;NU441h@hIdCT@mXx zaS`#JbTfPmac- zkm&F~Fxc%v_(g+qPozZLD1~fLVZ^983%nSr*V%iy--BB3%_QNc$l%`pq(RR);Qd15 zU!K+)dZ|YgA{=^|&%^cgzCn2Y*ZQZQCTKa5+NiN`JVBi8F~1g6*yyQ|@FJ23+e37w zV=(~_h9xw3ADZym(|#VUd29`xRO`t#9M*Ge1J5q7^VZM%fx-r0ffs?SIz#_KEuSd4 z0TDRF@ZKNU=yox<_)d3*zr7gnuNDV$PA7rkW8&x&lrskl_MW)4c=ZS~xQg-JJOsN_ ztB0G<>^{ z88CQ|i9MD03_?JY?Pc$VC&&8D6QQY633$K>TZTkEntU22o@UUx`JO%%rUf_gL#Eck zV*PGc9!4g7z}NFS9?gMy79a_vn4oH-!l>uGBTY$EKR5s$4i;>aEmW2Z93j2eD)?<& z$jK$&$5UN79gInP$=UjdjkutP7X77j1q2X%i=rdme)((&LGwMqxtMO}8t3FHJhhQd zm&I;8BK!v{6W>j8%vx?oN6yFBPrqTaSoGS&7JXUAqRk@i9MhA_&#(X0@7Hl%zR}fm zFG^VYi|Qly!>mU#Cik~$^I*)cQvv=)M4K9vZ*&#V`Gs61$Nuw<<2tJ!%g8G%>WV60 z{$fnY>v*SzPP}Fzkb58323|*2|auE@|FOUMCl=PIN}1$7i~8 zsnUV1x$cB_8WPAao4ceW1(o#%EgHSIx;a%n!W3c@f{bjsIJ_BMu-<)-H}&`7_91LAhWyLbE@ zrrhvGmj9EcZTJO11UIP>EHNALbN;+ArlgUmLltO0_1l<_?oUnZ`Qfa^ zWlRkKWK4*=fkqpd0oxC+RPBBv)KSZ}TO~aI{%)pY9Lg0I(rSk-M#T;TM8^@%)wTWx zo^QvNG{HjydZM}%LyQ?V1|uKwU_GAV{VI<}3x6VzXL;7@gqrQ96*wWn?(Z<%{}{o# zZj(X44Zvg=`w^0l30Rr@NcZ|2#xCpjI!DSYL#u+S(>(8jfrccxXdU5D0D6!3AzE;KyL$WfRQ@f%KTiiN*tT6O555amwBLlj2A4=BKQ+{;abAHc1nZ!b!hvT8K^@m3=y9v1_qX?DA9n;R)%Y~ z8H_!GoVd#(&=`U1LfJglIzck=9?VC^Id(PIXMOfEYiBBE`W+LfT;r0LN2_X zFEg-O#MYlmDzT}IOx8qVKL$fJ&#yJbUi5|F$`Y?D$lvBPJAUCT`u%gSS?D+kd>GvD zgZ+YeVF$SS)`;7Hzn(#8b8j0#L$hF*vwi#!4N*{ z7p2z)=u~5h4>A#=dU`?!JLV~^SkEbH?Uh8Jt3lgzYqw?=~1A*PM5hX?;lSufX3W zoTy1i(G-1-9Jn)(;wJ^gbK$_2>^VnzB3m!a&xn~f{I@01w)@Yb)NW|w>Du|gtyl+% zM#c{#-#3GoGs-qHAr(R526!~9CSH?{Sbi8QgR3@ysF>1tdiJ9CngUs<2u*-biKRa@ zeN6%myGOHH)){@p4fH3xClu58^@apK9${h`Ru6Vu^RUBbNaNXkHcN1`Z(&qh9N+6- zt7nzVLlQ&x?o)wBfQ4m0Fy5bKmFHvW2n9?}|&E!b64kKu@2XWSfix)oj*YJb~p1C3e;OkfW zc==l|MSpTF4x|=Bk4%***oKY4+#IHCV?_X9F&!$6y==UdXM@rZD|1Kbte0W{#VE3% zTN6yl-R)%zjv@i57o3des$38CobqD;00f~5N`IMMbZZQNWfFke0<}!iQ3Dhhp{Y^< z2n3P_U!C7Z%at*U#ORJ=m7-yq0sxT0Y=se;wu_)Q!2HY%l3NZ)0m6f0T+gw;vjKo6 zDb+d<=^05uA|a}rpMWupi6}j6)4(9(DouKr0oP4o-F!99-Lw%8IqjaO)X15j6=w!LnuWvU2&aR< zoIpSTfJJ2z!^lEt@er=T$0it!eAwL$$V?M^7ltr4oQ!9_yLjrZrnc)I8(4F^Xm6uG}bBRN)13fUH~9v z>)#|`Ott3%(r^whecsdX>?i*poVc+Kqj}nDjaA>&u6}>F8G+TwdAB(gt8b({0*9~q zYW9O`4&iw(eNHCQF9Uj z_1~J8Z+KW5)1|+9$wjQpOUE+GcAe8(nONld+E-xhrnPV_7MHPTTKUy%+DdyVZr3b; zbTJ3y^T`y5NbzL?L70lRHSrDn;>SP7CqMW}Xps&xrKbTqI3wteflWI%;RP>!0kk&Y zWj-Q16Fnn46*To@Az)?(I%Wr>o`Q6%{ptqA1ABz%ct=d#$~`?>SY~U3Gex>-^;l+g*L?)H(0_ywCeQ z#RQqu&7laKf-s|q=AS9w!EyzBm|&*~H+wkA$cRG~wpB$`v9UP68EY#(E<9!puDa~m zaJtk1hygQ(D*z=!0Aq3G0KE7kU%^ek*ojWhgJxLh^hwoDY5GhsBl>5T#xt{BWL1n6 zZOP`UGJ;!JHk&r@W^hO9z9vSH_l%K%l#W=HlsO_m?n4uWfF`6#ZZ zdnuqx6lq-oAmsCv83j2wn13c)I8uNpvlA6c-~^�lJOTs*A}-NYFLG+`Zcov~~ex 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 z zesMK)0x>ckj{YMVRL6Ymt*_5yEc4m;(uHeQ8&kvo0oO^+VoTyWMS~p7 zmrf;9B|&Ruu6H#6Q+f{8RVj9(`QL_^gTDzDzSfx4GQgq|ROQVfWr^@#Zfub6gM5QV z$$!By!K+YQeGlls%5rdMVOv;DeJ&F{(LW0q{CWsm;PuPJQb4dSgNRpIRDX3pYR)cVq{x^rPj zXFIUf#_bO#1U6VIe+<8|f{ul)KHGR_b_qI9s;)5Wpe$fqcp!oUA+eXi?$Sf5H?!)f zN=v_PAJIh4j^(!x*ahI(g^si{X|gp}I49IYsX=N*bKv9H^yp036H5SwsUib89_T3_ zUrOKJgZl|`mX0z1i`zcR!MipT>Bn#3|A2FN42_1mbX<>sy4Ju?%{L8s63&p z5MYh|L!}S9jLW8#-zmOs{BA6^?yqEPD6NF<$AEn)P2X?}%-8v$DXHvAHYwpwFE)d8ggh9rj zLII$d1SdjKfZrW2H%7aXYR^V5&r$5kyvutX4BwrSW1{?4b)3N8KW7X#*$cyu+h|Mkc`6rY9}^@BD*!PRoUTbf z;kl#V;3lnUC?=eSp+Wa zDqM5+dKhrAC9<-dwXnK2yl~JBHsZ7Fp2U30^B2DGd=!d5Y^}d>L}NL_EV z1=Y&trN&U^>?TK>l4kbL*Mtf5128O#V*eD!H*Gzb^I%Cgf9?*Si8uk@y;ITxZ}VOi zfMpOU*!@gOgX;B9#mG+Br6@VI6{@5Iybh`Vv!-c}3vtbX5Bmf7qaX4{(tNgz}-$8(PE??6u0iUO@Xbf0`_cTB*jqbrik(bGP6AFfW%fep)?zu=3lO$e<7MKtc*m9YhC@~jV?<)e=_{RGryXcSjC_B{X6A)i1z*g)kHSOmDi4O)C zYUu}lVMtIdBQR8Uc6FyW+ga8tQg-TuqW8Y^*nVnB`6ZOrtnNM+)c4#l_o5d)IVvL~ ztW$;BR4C=bTVGhYTV6yS8ua$tCZ!ZrpaO108DNJ}YSjyoiow)UZ~UTgsL!xOScUTs zi=6P`i}0+UalR006H0NVWe4o>K1nJEc$(ZK#@iCE_fnd7^xN;`ZPz* zA-VAFEK^4_ls|>2QFKskV?m;0J)3_Js02$;w6yj(m}&JEg;viA)VyGRVmvOg=3FN- z2ch97{xF{mx;X49U&pgnDjjCaJ64F3JZ7UtbxOshf>uESdK7YqDG}YKFpU3QA+CWwJvd5 z0DgQO(hzuymNzT@OKHb%W`LSSMT~N3eZs+0GgEuS=+)G5Qo1dzsAWmg?y_)OLu155LC;^cpSfPNwaMF zz#k^{kxEq9;%>67`txI8{ZXLw;tv!zt$D~dBC%V6$QnC>ysTeWSM|XiKh@+HC}r9r z@EUv32NyJ$()T~cf^DA}McNYPaI3cIk&LVe>iUku+P^l2wD-bc2STaVff-SY1O-tS zIcdjIDlp4^tB11MJ8Ts%POu2WfZG6-`IlE-Pz~&Il+j~TP|z0vX@xgDoTBh{LDaMp z4ql2|;qntqZ6ACzL*ekCmtFk&z;h-Uk0t`q*P(-UYg3n@e=p=|kYYfCEUhizsqiD2 z?CMwDINztQkF)Y0xTbGOmqoq1t~@-rly?2=n~lFXCS+UxefvWY%cn{856TrpBdoJPw~PFhH4ed%$CU^7}JmdcF}Mex*lLE_msCf$Y&8lVMeZ)eHYbBy(a(BxS0Z9E%1#z_2|% z(qr&S+g670usBL)6guMk$|JQ$SU^}!jwug7UjlrW=E{V?MTC>X8cHZnw~DrT-gL#74}1(Qvs;cuCENS~LmwvcWy zVxcx}fs!G2K^ajaM~J8?RBF7I4;lyqiPr_FhndA- z;r%{-QIaWc&ab1bX1r99AU6qXtQkZND7b-lHso5oOIfr%TSxM9WKGQv6Hwvc5eaQQ zjT5k+i$s@=Nu-g~AFC&0A`8006nHV0@4qq*_vlh5Yvm;7+;bWHgyq|t#My)N-!%Hh zHGu&pRcNt298#znENQigc;w!v5AJHAorci?#$uR4RpU&>pZTme*?3_%TC8*T?(Bes z97R(fZj_&-Ow&X4-vAj!BNz|WvrSKSgDyJtI4-^B)db70&REUIAbzgPQ9A5dcG!MJ zDDZ(2t&M60OhdLpA&~xzF{z2_BtGo>Wygv9iN%@F#X^c$PDUrmB*N+sLQ!eFmn0{?Y$bZcL|Pg31prA zcHfjt3z9=Kz z-*8ZVf8+R9Z#^!L;?h?5M2I`YADlAuog27c%0Qa^;O7RuNDr8;bC_vYLBV+L{t)`{ zp&C*q1W2?b!u z9V=Zuik4sG?*Y3WN1T-1pGBjo2$O&5R!y)=ln7H!@_*X3`z#G7R_L!c+i_p4ccV_R z$d=9jN|7f)8(}Q4KB5YA#1e@lQ(BB&$e!jSu4hi9>Ftm_psOm)Y`>1oxiU30?WG>Rl56#Fxo$9})-i z6luE#=@OT?B$ak=kNffm9z9d*8Q=cjLt4Y7G5ICqNTePf*VTC=-hC8Ef;86T z59|yto%`Z47Fw_vu>D>46-WGI`XN|7M6J_Zoj~IS0m$ryvKf*r@U8#Pl{}(&*X-G=Nl3omU>&-mM7Y`#9zngKXnOr z5oa4`G+;e~B6X_=&dx|YJ=6!OnIA8#DI?xJj}!FcC5Qjei@+s`o^fc0@(E4ZEsQ;K zXb*KlvELW?5{|b*D>rR-fmnpY@FNiZY|#?`M~YvNYrdFrdOeCw9`kub94UGY*kJg9 z>G{gou?*unR?Q{bUB`^l0S^a;FuNw}N0_=vu`@d#*uG#l&FX&}+?OJeZqk0trSGJH zps!VScW1mLWHiVAQRNp`?&eZSaB3FX1G`Ls`TH_+|gg{?!f=PJk58SPT0JNuS3`T)U`3u{6+iSpOpOlxUW#3c*1T- zeJ6@iWj@6$6tdNA-GSqVws{SLC?3WFk0V8g{_!p*qac&WD}l^1Xt1Z{DuuX$AR|V} zsc;L#;1x8b6o3ASv4I4E0bBh+S^QVg+@O0ca|f~4rFQkHpP>j@wz(QvhOd)a8n%bO zViNQ{c&)(6$aD>8%gdxNcA>)bbk{F6d+nBMFxlqu*BMbdVASlGtiOyuZjZ6ea^NCW zG9(CYyzOaKr6qKsDs{LAB@f&+S@e}sD>By_QSm<}=rJx=6aQ5XUN!Q+Z$Qka!^f44 z$gsjQa*B8r%!P-YPUe8Cv|iO>_{GcA1eW49V z27m^=Gj!kBU|Gt18nEQIHkf{{PhEUL#o`ZE;(k#^NPro5C_np+86}=AA-nGBg({-2 z-T!V4?p^{AAKFI&rJ;aHuIHZLZ(&Z(RuHZ>f z{yI6Bl*pvDn2<+>##C3;7r~k9C9|Idc!M=-3;wfj_aFTE_`FE$+D6=O@D(6BN1(I~ z$gF}^$Y?d?F(1PUmV^rq)(-%kYXZ#!9;d4^TpSr^Zl?2JiG{e;r?}F1WUzRxHpDMd zlEA|3{_tW?f7tg%#5=J}Fp5m7C=_{obCaSEKB*BRPSigi@lS+3XV+v$V}RA5k=o3D zTI~uZi6MRx`$vgDJkQYPUq3EWqxqLMl>Zb0T+%*+lw$g`;~=9sQVp{VQl9@XxU=Ag z=iGiFe?Un)pvhRPvr|gyaHUuy5+%@`NPF43J<7uZh1JmWmC%p_)T(N`)dKBY1fovV z@F6J|0v#r%d8JeomN&%J;AHrj zk^6zyzACo{ETe2*-9PMA=3L6<>zWJW@~q}AB%GGgLRqE0YnOxSw;k$m7*e6B+99+- zJ^M&JipTeO^Ipm`VF}&sIzv(>-JXS=?an0)ExB2{F*!-rWpmxJ%+ZS0 zbtJ>eD4J*tZ=)|D-1{4%gZMwF^J!CleKU=Z84qnzL0qSG43oa@o{k!|ENO`HR2QTj2QbyRvUP8r{cx4cUAFr_N=4atyd;IUOisM|xd49=>F z?PoEFoQ>OmXxZVC%0NnvNuhlNM{P!_b%orp`!kQXaNVM8WPjAIm@S_q8c%NX=nttA zl5o?>$cOE=y~Xl*cBr%wa?^@^H@t-$#1S@${R?HP7HmcoJuNQKe^5 zsBneRi=Jlr{?fORpfol(KC`>P&a<9nJXs>Az?uFo$)gXNwEtH&r>n)n=Ze#dl_Cze z-tlJifygbf*(CG<=W1%c>GpdF34dzKKq6e<|!DobX6wE`Xk2= zaY4cG64rO)CZiFx5xp}Tj%%cgmdXej2AI|BEj(h0-MqscI(ZZ%3p>OAOU?xsorq}1 zxU4?9f${JC(iok4{Vf|NL5(5JL5p`7cKZ$AACRQ20xMw4u6*cQlK(KS zPxHv#0qi0>z9N@)WqU(qVjxLK($DX?(qxK?+1QYFdn8dI{N*CmvR|PH{g!}cCYuKYI_%EzP@ot zyEbTDGDqCt<&Hm;1X@>H?+TIOF3Dw6mvC_7TAd{ChQDj=lc(h0GRG!#cYHLi7=;Q7 zYAW<~rBbNnpTDp_31VJ$9g_;|UQu&$a)#*k+#4Ca;Pc^W7+0wH=CXYCimf()jV6qe=>Yzj8{$;KaZ1~~D@ZufYAi)E zJS)T~7by>uOsmDrW*FTFb3Pm8Y=}_BlzX>46Xh{vF0m>jCm+Wm6L%j#CwVBjCP#hN z7IH8|;9rhSaq9AQZgC)0i>0G5H{@agHgmk2kcaO)vdu}jP!PW*HbLNDz7`3GGG+v( zZJ2T?(L}q^vTTP@il0V}b_q;q-`)$N8d?%ck>w%>uV9>e1==8kxiT|Ymi&q4BJ*aJ zDWO2IRmDOw2?f*sELseCj?2E8MjbgmDd0zM8rK=td)Lesm1Tq)nhx751DhCvdRx1@ zjPDz>Nj!hgi7bNmMuOV>$as{vS5O=G%eUu=OVf)G@xPE+Cr8llHkYUU5BhKUUYB)E zlSBjQecd3Jz{}y4?k?CtrWhAtS-`0bWyS_j!h}pEFX#bqoPM!=>k#e*q$lNfh(I;p zvMvhRdxt=;AvJWEuk|dAmRW|`GE-_v{)yN_l*JluxE~1kmUi83nE?gRU{?*zE&!ES zlqhsp{~!QF{O+C49O*E)^Ag9f1Qlrg4qnLy`_B6x1pCag=`M53M&*h;i&#bsw(@Kj ztG(E|j-B{xW!bK81mCQ1GCoHg!S14eifiBK@>Y<$bW0Kh-NlBPPf2e3DOkeEb$>Gz zsc@Q)Od&8E2spS>a_sS7?)7^PXO<#%-cZEtxNn4jCX($4Rn# z(tgJL&;3U>loTcghc;;-NdYH+m|>tFY3=KhRqBcVW|e7({Lfnk2`!9fVJXZhhd(#x z#}bNQNGNRPVRYL|gv(cc@AFJ4yaOwaknaNvtTdxeqZh;g0SW~k2>8pA8#wK8y>BCe zSVGf zjV4l@L&>(yzA{GejBT^u88q+@D;QQ45Z5s;$63{y%XZk6hTJDvrw8lioM{Bw6Sn*@GaFj*k9C7A@ ziG8CLv|3et2`Aj}jHDeybs%|D6(5zkr#wT9`iL+;7P=QABt0u47d)@R99+PXqbMq5#(?L zOD6*g+eeInNNX5!I?*WBV*@&MA&aNFJH_+lQMmHrMERpESVJg{V+$Pcyx8~f!1f9| z9%@Q`9t>F{&E!v6Yhl~X4#Xy5I`iLdBOWCrA*UD-W`ea(9~~TyKcGq7SWY9;Krk>3 z9#^%R6=qyC+BJhq%2W!<4;g-?IlgNnVqsIYv2u1WiuTmB2+2&*FnX6DpaS4|nTMRd zdzJT2Ix4TG_IKSmn6MEbQyGC!`cZbO9Gd^$NBbKL*2r~>wF?w8YRR?yTKyaEN2Q#jnWTMh;Cr@!3U%V z+4x3sKj<96u|X^0v8-Wcz+?eTP(za8wz;5LTrXlXfaS*>_~yzj`L`O;1Rtx9zoLlxx6EB>c`29YQ?Lzs-x4S`JiZ<<=(WL z<2lXl2y2dZPk8G{yak-GmTbaPD{)}LAetBkJy{TnBNLYm(P~YkqjfpK&&&#%BW(5% zvXgRQYTFqv*TwWcFnZl1h=Vt5btkH@$B*R17WK%)lZ=On`@3gBYveiHcu8Y0fHTWw zgiBi$&O(JuG2~L$NA}(H+W1R?+p!vKS>N^_ft#5b@Gn*39qkD!j!fovZtTqE*GmXM zdc4|Vtw;E}*Czl&sgNp8m7p_X^b;&XG(+|Zxhp=DMHpeJSgI@K^PKhnT7XXG)Me!Am!y%&hc|2B z%+CA|c2(JBfHEf_qu?o)Kh6Kpvy3v@F?Q*4bN6m+97cw1rVisO`5+;Y0wNwA2JMJ! zQ|y)+Q{*FOCZEr3Apn!mJg?-jJ|!5saZWjHJIa^u3~@pEzw1P32pWP=fFZ_8FJyBM zBJ>I(-@lwm{g{afTN|&2-Or&OoSv^cU|ZaJ)h~g7E~-ozb(^P3{;|aw?> ze!jEADAgyf-+`It2Pa#jDRN!2P zkpUQVn77$6e%99YhojKf;oP~2tJ3JxApuN#|BaQ%LZ6mMb`69ee*GW>{9u4)EG!o^ z!Yl!6M5Y>Zmp5gT>7gnR#q;d2xrAID-N8bfy;C=8??XXLqU{)fu*$8L8U zdYSsp($@*!+Y6y94CSGdlkM?n#@wi;e?|$RQPbI&(VGhyB{)7d<7EzVBQTKscqC0@ z#Z4?J5bQy9h*b7H{pvDcDst)>KEI~kqM~<7*4Ag3_0}GHki_-?&a*~m`IId__0sCU z^nv^>zb8Q2`_z~CsP}-%vHR3HZQP3o8cu^Slp@r)VxX+HaH1~wF-}HK)YC>XPKD%y=L4-HS)lE7Tv7bpcYCT22N`L@WMRNv9l@{o? zK2Y#P-wk24X%p0EQ8}X46j>{VSx6Kn+|P`-A$!yn48gZrUBX#TO$NEz=8eQ)Qq?-nG!nj>iq&QL+(ok2GJ=rhk7tgb6Cs zcRJXh7V$ZIi*X0X#_Q9tYM-w^KTa?AK?k*>Hfc+7i6uB{N3~kxn)tmt)Z4!qsI=Oc zr8$*1B+)2^M6Z3pEm}n=q!^gucLujCzakkrZvb7rQ16r8Btie|d{^Ug(aLu7g)|3^ zJk-LA-A(;gX@8G&=iK$=(E`l3T!D8Jr&sCh{( z;6%&y>-S!Qk)Qq~D9jP;RNj;2hu%&oRB-oeu;U&wJ%F`-%X%prd)%0K|D(1ZdO+Ge z6|}tl0Wye-vDtCmRvFmj-+>4-;s`_|5L+P`fsV$`P2Ww~xqaVwmu0_$5be7pjrzc5 z=m^Ijy}8>PkUtJn@XMIwZ!68{?q5=4G6vB;?PT>b)H7VxnlGySakHom*R2Wduco~K z#0$VP9&L;cO{@OW`MB?+@k8cpbdVD7Dr0_n2vocsC3f+1Gj`tO0VCr4WvRW0ofyG&TlA z%w?lK5PY>b!NbLmNGtRiZlHYt_hu@)L?MQ7neCV_HJXjJ6DGphl1P48clQT2d7OMw zJe8e_w95>bC5H=5FC?|agB4|IJnmg_KEhg`>RDbxrzbV6X3sAE#1 z0}V=*qpX%+FaxufO{pGqaBcf;Ds=eY2{LME)YqJv`8MJXADh#7ZEN^ee}nK12^ zkqBt#E+@Nsd=rilx82ZxpM{J-|2}~K4L!~GMNQ~liLXFAd&u7}G6%-CnLe^jekC+p z%Fp7%aM6iGBq`6e;6s^F^#;4F<7(p@`;Cg3rtYxBGbhd%u-k|Jlid3vu-_ zpg9eUYhRn+UyV?KG7krwC>hn4-cmF}vDZNCw99@L?k$1 zGWtCDL*C_pNrg$uhkGg{Z`_(Ip8NI0Mz?T|V1(K9o?nmCNOr%;mm=iUTfJ`CYVfZt zr;05DGar*2qK{KM{CEG8n=2m`iO-J3lgXuG>Mix}=IFyWrcYAktw>GQv4Il1C8Glb z%#o1|0XI6;jq+C&Nv5GceVoFknHRiZ_S(!I+y8UxUz>?ag2U3Zs-jhyQY&&i#5sH7 zG&nO2+_afZv$1DnuqEWbb&MOTN&?BLlGO50XAC?%Bctf^h=B?^F=cLYWK=A2p|MZ4=z z@Wpr}(gf(vCrs8|tGem^+oRF;Aa9`&o&=V<2+LhQ;KumdbNH0}d2Y*h6PXp>L_zc- z=I*XG3=FfC^HR^={My;IJaNhr+l?!O5!dPr4FnI(=<8ZipkFEOT!{B}_ql+WJ6Vo9 z89l!Dw?s30u)Z9z$h5*# z3C-6aPSWonauM3WiU0l1g^Wo+cA+9H@Xsx2V65yX(oHa~0_h2C^YAZK8&U5`cNivr zq3GkB+NJQ8dXLVqipaRWZ`aJ6y?#Uq-W0L7w2P7i!|MM5twfdrnf+CHK4^)iK?o{v z`yNi(j;M>NA&KOG4hsc*3z`;g`3c=X`0jt4$uJ1i__%A~c7h5x-{#5?l#5p86t7-> zdOYr1>E;&^+A6kYg>^CM+b>yawSl?ejqziRGw!%8p6BW=hy_30FU9VCT8bqDXbQ7B z#GoQxV9e&*vt<=}N~23E?W286t{{1(XWSgxzw;f~npb48nuo1q3L?Yps1-kdb$)-( zwR_!zXaBX#_96D;gyV2k=!DwR*2^dpk97y428+oQ8LCJ7h>|wpd9kCdFdUiE?~X+;m6C^+y-SgtrqX=8 z;y%lvCcLMzkR*bnzaXLVA80~izf<8eDc*9?Y&vhR5jpNj;DD`cOg(41|(+X|s7aW)rB=&KPHEM(eCg;tJUEIrFg2U%94o-g55*`$xw(y!(+i71| zjbAUER`8p@na_`LW87qqU7Cf$Yt6ol4y`Zfh`erpe*D#E8k#PR)?5!N!7g$?u9$F} z?Dc;A_wwCu8#D_TUJMwuFDnhjnJv4Za9}AwZ(4i*OHsQII;ei~ltw>N}rflAznW z@3;_&t^Sc!DCuaRDaq8TA32I{rv|UpN?Rld5z5lq0e8LYE4Op|GreBWih;<3;EdFt-^nt) zX>h)|QSN`nPI9=ANFt|Sr2>?xWf^784dzf zITq~ja;*Irw)N6pnCOv1Zd-2#ZGD{ZgH|NUp{4}KmQYCB@#=ymh&5^r)PJH#5{$NM z^TawHh>GP6u{oD86v`KfAkyH-AcbV3lv>0(OFEpAh`q%q*ZbYjc=m+T{HDFS00E3} z+_#W@q8WG{;($O(CKO|9zk<@%)xL=*9iAHs<*Zwq0tMGH;8ul}lNrY@tpsURqMslXA{_Pm+A9(NkLjX1*RS`3uT z5`(5y;D722Ww3@thg{U40su6(=!dqO2!7IV$KuvM&fiwS(Q$GNBlRlMnW`3R_%E)v zIQsj0Q%5~$Ho5OKB4_#O(Hn5dyylH8Y={MfblMwfy)!S%A$hCq?``~h^QNyzy@7vl zT~AIiWjOzjtF!8=tBscQ!rk3nLvVL@cZZD!f@{!?I|O$R?(Xiv0|fYR3GVJR=k(~C z{t4@1y<^TFAAE~+_q%%pb`E{ z+=f{y`0ol|=$UP7lG_Y&mffWNycwi@%x?0!kIWP$bI(;wo+$tipl{+Ek`;1-``syO zi=p%(G9NYqM;1weu2$b{ntCanLe9EaUYj2+N7_0|F!}?ixHoS(9SVeZlGWd;u- z(URzCkN~5Wfn>>PV(^fogpv@k`y-*@^F?02Y$!$dm;T-|TaK+6n-v>z3|Mf`g00-v z&U1q5lM(E9X`J00^t|3%yT|qYGyJ^l6s1QHnjfz;sxHfbb%)+G%q%89@!tFPBymc6 ztpUU*xV9-$dka9LT7+ZH(y<}pHR;S6zsd@#P(lg9rs)Db?5 z;zqE~E&l}9%t|U5clyWF=59hM@z)j=Yy^XD53Z7tQnp4P%+0o25jN+UZt+2tHST<^ zHmKpTiWIN&XpZ?Oa6zeU#vQBH~*BEZAqj`(Xywg$ByD!Q zm>%`4R9w~Ma2&GxO9I|vqCXz5b?V=q4ItPJNXY@wVl8VjsI7maxe+PG0F^wt9fXxU z1MS<|)u5hG)Tq$OH++Lm!hD(%9+)YLpOUPTKiv`TS^ZrWn|VKSE=H$X9fQmk3J7uY zSH?6+^oAb3<^P4}c+~DdI;ljRl!mJf=)bwTO!-pKWUY$GU$$z2#0dw0$!{U15}zE8 z5VO-Q(GehxB=WPC6ZqS?)bTxh_%VyuuMvbJAYin9o7-B=S94LkB=d36(Q(*X9A?}#sR)7vttLj76~@coU+QQ$uL3{1kag_WkkiIvq46tX%^XSs zCrP_{{MMCmpx!CTO`YDX?o4^Zmvcr$dfqOse&fA`wmsove+X_NOt5{iL0Gb(}kH z?OtfpInigP9r1RX2`)cQ&sqz;@H=oBnm4#gyx9axpz^C?*JW@qY1uZ+ozn0tE{dIo z*_>q#_xhbHq<0@VTv^v7D;(C*F30CDRm$3<1l&a7nJ*5a=C)RM_{^`j-8^{i{P$TQ z&AIv$xdgO;5$gzjY*%M?$0+!ZL8X6JSqP((u#=VlxDU;6yzG}gtjKMCg(l5dmYe%_ zq$rNxpW=+j{mmm$QD?CzRP6~a!|QF9oqLp4D7NxmT6Gb>J^)AsbW@NWeU?^D|9x!U%fOqqX3FQ{#k)`O;!69Et{VgID^b`ox0lc%Vxh>wOuJyQ@*yFkPO-PujQS`KgorG<-CM?6}Sr?3- z(pk@4dx+ttm!X-x)qNrW#{7p2P9yb!)bG%&DEJD>%`^raRra5Cd6uA9?bZ0 zSDb_esv~3!U;iQos3RWN^WHJ>x-TS_+kQYs=}E8Zn5Hsf=Brm>*Q17-eUFj<10S{? zIF~(0$e-s>*-vJrGkdfC5Biw?sK$09G#1yew?(SVw{g1__v+eV*0+-v-BEbqDH$-J|)x!&haEr%?oSF9Ni)`gcOX6DTI85{##USdtcHD1UpbQeD5``+Ga-6J?!7Onr|_T zZT9HVl*a>Oj6TlW9tw)T12knPNL8Jqc!d``wJ%WK7MH&Bftk{MTO*YQWK$@q69K1h4E**hu=58`nF196O!b zp|MN!zhqtdWtTXeQQAc`D^;88w?u0mck%h<3>&v`tstAWD8)$hQVO$JZ0|qZ?X^t( z)89`CHzV_WPw3jtMs>tm72q=)F_T$me*!eNpkcMq&lVJKoG_hg$xgOlrL6N2ogNp4 zS<~$k&TJez`iMBE2M2m$S9x%+*AbG_@A6X6!yYdx{~mBkyouOZ_>ZebE=a#RE>-6e zgXK1$aUHjh0{)O}=Gm9g22Ol<%5aNp<^{b=k4)YwC{m4PmIjQiCTTTzmkV#|Pe%X1 zIomN00`YRpx0ADLk-A>~McRbyETM`p0!iF9Oi|oXCmRyS6Y0Ol96%iif>HGBgf`jB zjIz3Nad7RHJLytCm;^EStl+qR-P~{ln6m*UilA{j=Y!f>bkT!Uwl!G=Z?rPdUx)y709EIp*E)9 zZ*W!{&iSs>n;@8uXgF#2FOT8_U~@X@{=Jl`6o^T+jafCY_I4JQJZK^QgkfrrOt4TA zbeYocdQONR*2OQxMc+r-)A*PQrY+U8 z)Yu4t{I;}Z1-!@>TV1h%-)E(r)ufy8FE7NPJwRONWbfUZxPLvTOPNCrM}iU}3A?L@ z;L8T`Bp=7ZH^UoeUODi6aV0fX3mo9}m>H@nLaAfWhkYMs`Pn>`#^+3*iRR5St81V0}Z_vR(f)EQdv8O|%kucfWZ>1yPY~3Ont-)m2TvGq1nzTWndaFgiy< zoUZ2<0dBHlV2O>{5)6$b_A@!xT)k42p+MO7GplsPAS)-9Yf@6%QHP27{WMVlD%x&W zYfiY!aLFUE6Vm)}>T?W1KdbjrtLZ#(;m{xOIx#$!DeP7m#CiKOj6$l_M!t91c0vLZf{#yNAIAIhno)RXrX;e$!2pzovePA$|)hEChqgbVX{7-z2-9 zf7}@(1(8ECnnGw&s!K_z1%fH?ewGBH!&AVj(`8sBGGut{H~D!ObQ|S;{P&XGy)>Tf z90Bp#fY&>|-_*V2y`+EP{W0^wK#|Rf+ZvNvv(+;=>U0u}wtEteo1n=}p=uwJNz+iC zd?ZV+H$;@0=;fKxh25yrZJUIy=&wpGdEU(t9I0vpe` zuY1t}=+l4H7UcPVdq*%S{e3@zrqh~xx4Sil_XQzi0O-NGmB~J_k}+!3MMJmDL z=W9n6H8jSuEr+1LBte^}@QZIIQR8VWp%0g1FLi}4gLK>#0I+WU7`Dt7X!M-K>n~Cs z%F8Lz%}!nqCg=RG2Z=z6fM-AyF)1zs8Wo=}D z99+{+_t&t{Hg~lmn88O&jawSssv#8Q&mSSatAEuo#po)|DEKDFWZAeXu1z?4z4%CfZ;U7d$z>|2D&QFB!X4O7C#=dzNz|BBxrU_@B1T^y9fXHCnQB`xPdR!hm0@G^JD$x>duD@`ZotWCP{&M(xW&J za)FM#X#v%ORWHKg?5A_zO)HSdC%sH1-y(IdnM)*Grq@55iSdi|`H*Ax>rw#$A}5u^ zkX;}Nb*#gaVfc}*;6fjs@EyTv=hMw6V2|FAt%M?xHCc+efwt%5NhIdtqkwE=>AVB4 zb=`$yt=;@HPOS00%njQ*mcXYhZYCdVx`jO2z&5l$Ff(Z_y zJ&#<gkXB;jqS~sXD=inK~Z*d`Ak|4cT)bHv52u zKAVpR*M}FZmM!h+L1cv9x_A9FbcyD7VUjWhMj#;syMf8A&T+gkST7Sk@WvEr2 z#X7fBHAR)W{252V4F-p7QJ$}d(Ar!i^%sMf8N?7NKzU(=AfH>k9G?!5=Ca0Jd}q^=k?a{3r>Zlc58G64+j6Wen$>s^*8!zf6xlyl)}p ze_xGZI;LE3(_O@TdPc>edevrp=kY!WLamZZU$p9bOgA578I>m0Okt#p z_uH?J15n}h>KQ?`7%l|oTY2X$JDSMAY7O}zbI><<+dsqmnNfA_kV_wLo7+ceMnLzO z=%({jSsdSbAlciy4fMMPQDD(FacrLr^wzY<&vyFVhs{ge*SAp-xS1Lx0%x4L5@=I+ zz!c9~393yQvTt#xFMg@{TxA&0^;Fiyzx`{m35fv6jvFsBRmuhh$&tIqcKf|rQ3GHo zg?KhK7~_}YwCx`=ZTMNG$4*3}Y+B~bO+uWIkq)wWD?09M_b-nmo;x{YZ4lvcxuSk9 zSdc8iT7=;aN<}M(`N@ipoHFW|HYJD>3K+1$>a?go{q5=NP>76|MkW1-W3gOi&z}fY zGy0bK46cY+UbKD@FjSwCKVt@3Wzskk&0xZ``n^}S9Z8UxWtq5j>;IxaFk+*%;QaeY zFyFREuo>+=HAMIEgr%C+Syws}vZ)_poPn!zl|K*G%EE@Kso@Fd>3WI>Ehr6XqA3Jv zhCF-e{Q{c*8>(QyF0cM-?G>nR0jNq$hStuzTTrar5htHmHWAr(y|8S?P6wiDCe!?l z4Kfz`S3g&!l5~?>_|gV|t%V;l9?SEdoSWXl+yXfX@e?=xo6lA^c16vht!sH@+f44n!EGn zm8I`v?#uQeH-G9^XSe(#lSHvdjsj1**NwjWM+l&2MH(#|)c5t2w0}jQb$8~o##M0l zctUNF<4`YR!9L%tT-Hb03$YkC+m&jg6P8^H?u|oH-g792CO|&wRt}tsTtBKA$TpM_ zR;&zW+4O)jetL0~9)yA{W-?|ks17cG^zMt$Dam~nx(;B5Bp0K+C5}9>P!C+aEx`X( zn}*REzr(jkAb4;c?fm|i1U~C4$YnVdL4z(f?;Dz0P2L6094PA|Pt=kT$S^f`Z!3>B z4~*uR-iWb&n~cxzI5U)tgfp5!XUlRj6)*z88S&6Q>>{EK?BT_4;i~GKF-P1%+IUJ! z7{?+PSRQYm-4Uq?4_q|5)x|go4-q2aC6O{pBJClg&B>wkM3$EUwuJ*3jssVnHl6mI z|DhyTayLwhV>7aztX>ZLjXDjUJ(!zR4K5wQ)j1>oD^Cq6g#^%HAVX9%HN0|u9O}h% zWNgXge)zTBt9$^zui0bHbYcLo2FmT>K@o;m3Z_$JqC*;RC)fU&q1Zl3p&==#U z(Z?N<2XpUv0^Xi2$_Zqf7K+xD4QD?-@b< zYDdHoT%2BiN*?An7cXiPAaRGkX}E+bx!voR6=zicvL7=5BGiuBtEktuIy@GbLt(WN+&ib@^};Gcyh* zA6)lV+Nk{+dh@T_j*&TZ#Uj)(LH`QEfAXOeROw8o57&0@x6a1!r!R8`-^=3_>%(La z^&gHz2nfF){|orcQ3b_Nwkj6jCRL1!|6%=_{bPEiq0WvwOZ;rlYMHMLGD>-lvhuimoUQH)Ulu~obDaM=K$_@kwL-kT z$T4qn*aT3Wi5Im;FztK%ajFeUg1c*tmy^*fBive{o;%Pj1T^V2@g2${kZ6J5ig4WW z6;tay%Y*GM+KB8cgCa(5LAePV4@}#YEpY{^*?Hl;Ru7s=ILUux@!=0tVzx(fU%nf* zxD##+I4}1^HzW=yn=i-I#f+j;hM=J(3`|$U1v!AMD8Qks%3f{nm7Zn+#QkdxFa=A= zief==YOT>j+^*76zX$Xn9MK@NPl>H>`7p}+`cnV+1ZtOQdfQ#zZl^0lP{PUZP2{Qk z5u+t4(+^7=W~XED$7(cZ*tjnd8HhH%rED@lNhkLzm)()8HV(#i3eGbAX^{{$98dkT z0K$T}+#5NXnggf zKB>kt9Ak!av=PIEPZI2lCCdn@`{j zJ079y!~kcd4jt$^Gx9NAm3o-Q35s*Qg>~StxUQ)S+OxbFvFzH9-!!K9t8{L;`apv2 zaF)oU7;%keT_HzS{!bTj#b3<`Txm=wzC~k?Uups*Z(6v`qa8y)eP=DImB|>;6RM5) ztK5zQr8NO$R`z%)WiTf(YQ$S3GX--hGE{Z`(9%HPVFi9{9~LX*#&5qBx{rSoUc*Em zgw+l)agO=wo76~+%H|kYjjMGoJNU`A#dTZH+EBnCqpAn+(!RAx0Dx1ntUeQ|W!BMk z>|_Dgr2%1N-g8LXG85@#D>f{oDLT?{YH&I9pR5_B^muz>L&Qs+fESF{)eS0L^rT;1 zaKk$46RrsE(=y;xJEoTpSv8yv8YDwp0$Zjg)j_2E7iRqzQs^M&{BTyD*zKjwOBZ}| z+SLEP>M_NpjDC_h<;%A7L_DY*-j=J?>794Y7-oWJdaWST^YCYouE5yl9ukv_%wMCW zVqL;`!Dc7vxj7>&@6Rk|F1CtYQ;AICU`UvhoO~^ST-uO<3VFGHMua0dDC!&Jj~<_H zP70*qM76|Fs+{@YQ;QV-P)hk0-ps&@>4EF zjZ7mm?<2L_0}9MwG!p=rXLJ*!8L2g~KwFr&{wj62C;_8XzjN;6f_xgVjTY8}{qXSm z9p&|5Po^#I+9dz7io^1P%C~#nSmlXjuc!9rc5JkCW{+MTv28Ut1`78$7|9`%9+k`q z0(>e(dS5hxoLO5v!n#kJ5e%bIE{sa^dyCog^6Eq+4$7oM!fYrxq~m)79wJ~ab`x+7 zBma)YQAjK)l^Ps|IW0F%9lwG%yY-Mn$5CIR8XcV;nH0 zF8EZrE>!l1IVd$h+p6alA*BRTMENor9|@0#QO_@*OkL!P^#fPW3e_2kItA$-4s`vH zHE>YKd>_bmkJ1j9A9GSHbuEd86d`9YjzWxNF#vOZIPT1zt2g$qNpE);qH0*_a_Q*xEa$V!}&< zYV(LGe}pgdp3XT0=Ic;Rw1?Tld~M&d1~Y773#*su%9xw6&yPRSBHXD`0;AC(P|E#b zZQJ({;OzBaWxS2K-NQdyi$~%I#MrD?v|zO_{p?B*0`Rcj%o%X}B_$<8m6RS>Zk?pr zEod_+s$XNHErJvUOLtA{n<*nBG*rAw_kUaB9h6?yJX%nM_EtEq`#e)o;dc zu@!R_qMpJDi9g3{EJ@bf>ddpAD|8%v+6ub7^%2YQFvRHs!trqTr^6dQ_u%%33ij`m z^e~oaqDR3HL>tBffbJ@`w6zO{;&D~UPMYx(`*#6Xf$X6`JRjB!0ihvPD@4)v2R{*T z0gpv9UmXt8HRlYf!FrSkUAKqR7u&5PB(^S;Ub6C0qPMQuSrZXWDOhoelze&u#-4t2 z4&bli%U&jt;QPRV6ek5Szu4YU6@(cqM1$Lz-O(?NhFPd7?T(rdhp<@Y^OuozuiFVv z%4h;d0(lJW$v0!P^eTbE0!RF@=KnGiDG;INed2{3MvjxdIY`A_L1HT1Imt%ZH)gePm*u_A%hf6$INu=;ikTA zui%}H+~hgqw4~WvtXnRm=!@4f|K!NUJ2ZN&P4_I>}%gY zRW$^KJ{N;FB!Y--2$WOOY1e`JIGen;tnb^dCPPbPy3#*vk@UqVG4k}+F}FBxp%zAt zBlHJJsfqRaZER>OEb5xDH(ZDW&e)knUK9mCx=KwA*2THpz~rLokB~qex;2QY2So?~ zFV$y;77iZV&0i6_sgr#hAE^1l(qB`W9e=Fh0g(zj^2CM^#w6-MsZyv|;dsix|B(&D z3J>J31kLLPx|*peekl6qdi^RH+kw8zbAU zNC|NK&`jXZA@VPQ+Fd(xRcICF}!4NuBpBmGe+XvL+VeZRTTx@|s`g;^9 z=82+$Ni8RY$Q zd3oqdl#-ES!M6brh$8^k-r`Q*2kQ|*Mj$bjZ z5Rm4XO~bv$9ea_@4QN#fDlyiaHx zKJ2UK?IS2Mq!0p|1};__%Q?MI6ZtVN@1#{v(1K8LN)#KpH(;7vuK4E;Wox=Z@45CD zNPsn=?$FG7p56I;G0oh?1M;j4isjlgISlDq*vIV7Zs+HLP-?vDY1I=&Yz+KU%&la}9eVYQO+ zhMk=ElC8UtM3qa(X(nvmqAbE&C1|mB*-rwdgWTrqV!uPWpuJQ3;sj&hj0Ei;^t|qI zbj)2B!T%YJ=#ERvx2U4>jJa3Bw4@UzCSV7Dr^flB7ZfZ8XR`4@?;z{~jY1gl7lHmD z$CMrQ^VY&u)RVgpZigmsjGDny54OqbLxT9>GCHemYN1+TYI~V_5DNM=WOPN?{{DR8 z+^=T<_JXn6fw%dV)j?e;>6UNJlV$P=3u)NW1DbilDH4lXkP%xbEo9;fu4XAX4z}3bxR5-$!*C!<)sy9EoX=W=gCrx(CBTwJ zb0l0`k&^VhJq&yo%K_He)2JYkNzCgmxY}l@DN02zM4$Uo;I0k{)RLx z=?KD^31u^uaBqmSe%-rK+h~BP(+Vj^8vJD;hasUS-%c5inMvG-X%ovppWXObP&y)k zR?yfL)OM6#^+}~iCS-WsOJZO&q@AFSl>A7zcQl}3yV@08b7)rmGf}2B!s_`QLk5*h zMz1{jJDr=v0;4+U107eX;6Pt}`)MReMxN)AbrKSq!&3*a)T#-CeNGQKoHFHszZ z29?NC2(L>dgpw=GNj6T{R0P7Do43W~oxkKAKSYxP)8Y5kq<=-QkIbkG zS*#EX-{$uAanyET>&*^-Z;u+Xr(B$9D7(tQGTMw0l1aPl==H)QRl;ndLQ%G*0Ilg* z-6CdYfBIAghagMS<_*aWC5RXw*`X&*wUH0kQP-Y!Wz$mgHF)H3LpS^918(`+o(WXK zjcVmf>D>%ZJFQuSpU`;3?L2=nC^C#4jl3Zhn1mqSh-!!x@i@d>?6#XJ;^g#cdBe)W zaG`>I__um> zj1o_-L>z%r7nfrk$7>x`)6NTm9suHvM&eg)N-uvN{!q4XMqX#fu(PC>i&4}twa*w{ z&@=d%@VnyVGMzbp9|OG5dYyvw6x&i*4q^)pQi};Tm}>;*u&#KwhT6En^=xZs3Opi( z=1_>T!OJ3lq%-dh0^(xzKRbJBr_p}!e0QQ+{Aztoe&JUGA$R zv@gNz>2ufSa|0eO49p0SC`!D}Fc+y|JALCNAk{iFNC_(Gv=v1%)Lg{A@gar46;K`I z#%>N_I)P#mk{iB+s8tZ6&pny6=uS3)y2VnR3&o)=>%gdD9pN&V?xX3P8I-a<4Ve&^ zM^UBuXQFeJz~T>+chNLgoW$?=6@L~H%a)8u?_~`HfWti7Lu4m%4x|zrJmf55XM*)o*h zJa-5e5s3-b3c|N@{t*~eHuMSJ_R}E^vxOvCFt%GWa%^AxeBTc%9GzTO+L{%2h?Z2v zg&_FARb0MAP+GxJ4CiFO5=6)wF?re2F=VE6YT&8@#VSpGRbeoBm#izD4yzEr7Wd9j2V7rXdK>{YY@?<4qav6FU1gJ zP6*;GSfZ#Hkr`*1S~I<+OKYAD@DKeiMjFYO~;K3A!&A0%dW#5F_({6f(VjSwl()()GIKwm34tpyc8Zo*c#Jn3WcDl%!U zohL>wQ0%_dhu&CMTd0 zRiyaF6Io$VD_nCu$6(9hS#q502-4=T0&7h}Y>L4mhikysp1A^Q>(!hh=$L zPm^d?BF+;Dgz_O9vFF*0+Il1|6b&!e8H$NtJs!v~u|_B9X@k7gR)YTaYuOZ3y*EAW zk1k%o0(SG(u;9Ei6B`;X9IsJI4467mnE!>p3#qM$GLLO#wujW)a|$N;0ZGBM_v9B{ zg)^e?q^d-OD=!{uSwQwP%+RSF*D(>3{r`CZ-0@cal)Y4Ma*sLfE z9_M{~(_i6rvGr2{fXUNcBK+bhai-=QUD4LV?`_JWuy7tHNOzAl@9#*t-v~u&)_>lj zA8N^VJ084W+GTab?TB0+4AuU1{}~?8gG#D^pec+4Z<0Z~vIUT=0ZVxd(&$#Ob&La9fV&l4AYL@fk#R@&!{=>P9 z#~!riNYNZ9Knrpa4tSPH3h=8~R)-X2BK@~E?(AKch@3(nN>ZARfR)YH38Rzi2{HX& z7tUQI`&ufL2+Gy45ZZPa)Jr%3HWTR@`$_NeAJ3=qI3^~sY!6yf1yMuO2pW29Keg?D zO9PFz#g^X_GZIHHOQ)vGs!I@G%2Dm)VAB~b(2mZbV)4#8u_|7*ci!)mbffiG#!#K$ zI3^?S$x(JgRs;~(SpXK0j1l9u$yu)VLW+gr$X363yC9U43qmh5C(^43K52ozrxF*# zHH{v9V~`1T<%s4EKAu186xa*$PD&`YR48ssObKN2$3A`SeD~#tW4Q6#eYr1le7?xi zV~>*i^Gm9bjd`Qd7K{hu6k69YCpHvWzvcdd^lac@Q8Ld~z>XA0i(qh87c z`@{rWP}Kgbd5QW)rD!hKx>cFv=6to7wp}$bEo1KiiNAXjd`;_|1ak=t+aDvfc`}fKF(uE)Hlyxa5L#e)MLIKb)sQ_>q46I7xZMH8ula-j7jwztBb(E2YK# z&ujww|Ia3ZkU8d?W6EB%?^`OHQA8pENI{b!wpzt~H-CqBP&N}c31R#6xi8V7wgdpc zbZE^sPqY(A z9Aln?&Fp&RMkM_0tOJruQHSdtlGUl5tq;I=VK{LA!*O_G!hY(_2tu5=*&^nH_rlm} z1ufH9Bm^&OULt*L*5+SdIBY!#*TJE!Ig_Q5wc&Z&H`?%Vj6=;txA{}Al0$(fN_uWO zu}kI8G5SKaYnrE(-hQcwlS@o-Jw;Sy0P;_NIWz>HP^4!>NLAL7^z${P_FpJh z)A>Fib+c$^X|M|PL&-)X5}{!;P{e9oQ;;=oqtTiui}TV(O=WU3k78RWsq*20Di1@I zYg*0};*BtYF+UHP4^K%b!MeFLkQ31{tHP+iIRG}D3quA|imF$E|NTcPnwqz2#uz~; zVAdqdjk0WKSw-i5`AUbcNNS)%+bw{yMaGBfc+q1RhBGb>zf+aj$MmRp+eOgAm z%Y*jUoHf5TRo0vALaz@|X*A7nkxS!vZ?DtY(0$h;aX0g~W+W{q~CEX#=3Q=A{E-RWA+(z7RV`r;sEX)YP@tn2eU3~0W zJ8#zG{A$*-z-uVEWJKN) zYza=zr$U=D$80NwAs+L?^+m3{&X>@fQz7=+V)sdAx|IN!72;Kt|D(%z4B83B8-5nK zC5)atA4HLpF(7l%4VTyX!pu2=#Rrh<2|P8r*%*xKGQPH@n>8t`tC|8931bR-KX99z zwyR>}yFPN#bE^9PvZpud#Mx$|<`PW%ph?m;D&QssE6S8p>udhals;uq6VjtfAtqu% zJmecZ)yY;s_wCEowaFXbvMsbh@l6ZL(v9kdi!iCMVEm@Fp$|`~Vkh2ee6{jC_Qxiu~8(yY+3*5=^E)0?7X}jmc z^eckSp)y8>nJxcf*xq%_UN#0r(g>8;u{qmiT+($aFFVs}W#`c-FDw%7)F2gWkNzz+ zMs%Hr&NNJ3I=NiQ+2Lzs0#VGqWg@^3*`H7Y!_Q@^1=+vd^N@D??RT%UmX1eyL718Z z%Td@Xg-7I#hj6VL5CF6eHzdFt7o4RY(VfKXkA|K!Q1hwL+lG-xVcawnt_Az!^Vec& z)#xN#N+Z}Z)lj01U-31a_Yg4(M8!cZGC}4Jj(S!+mC{4xyM?qL*>!~NepTP|;%84Te8r4^)2GH!81Q*s2|jy4|FUwpUZIjjH17O?M` zrg#1F-YH2dY%f7n;urkt;WRXyi|Kw@8mWkq}z6Z z5y*a=3M1im2qDgrjE$Kz?66OPV!oXDRn516w8%Vlu6p@#q@d_=i>@~WZl6Cwp36;7 zv4WG~EDs&Vdk0kkXt1pbCQphy$aGHZ39cY3BK+~Va^cU+yv!eL|Lc}#RTWBS?TXJt z{dv|y|Id#DA$B%C2rLh_bw5X+F#ALR*?Q^bzbFEQ-cCAyi2HAIGq{k&0v~BZN ze}d>@y3dAbPQ_evp#&tMFAbcygZeZ!1*t7pD<>bQSv*Q+CzHGjSEVtwnWLTbL~n0N zR{!(cC+%y-qu#CxL4F=WrX9N9a9^j%jH0pJ_PFHBG)HG`Jyk0f&}qt5!$xg!+>A_; zqbspjv`}37n_sR-u?^}ZaQga|HbdYn3oY5QSsr`ZK37C6 zi)UPkoR!!pcIQXyuV8xy1U#|6zuVUiqX}Jf4po((bd+g+>X~9#-2N|Un<1V15^ib| zNWT~}{}>g}YT_x`AdckLH%`Y-S{Th4HHnZgo*UR zbK*kqgcR6`7bqse*Avzce+^A?8>P}f&by8yZod8znCdx&olYpy>sXj3{^gFEvKy2n z{N?Da`zqzPofDBQej|s#>n<;S_C@wpr}U}`)$P46LWnC`@zOZ0y8wuo0%4Rhp3(pP zn{oVSKw{r!k2Z$Ye&`f&@nqTg$}+;91R@$Kj?5BKyz`%U&Y2^*+L-Q`-uzlj(75jA zr7DnGm&>R3{&bzT}xW%4e^k_f5fb2lJjY!+@$mOd| zL(%z`*=~(n|F40y89uJ}uv$)+qAia@Oc1#s5;G|P9jZ3K;iV(0} zKS^44T^n`h@yBgF7Xbfmab>-D22a&=MNXoCj=<$dPFeN~6F6QLrsd#YI`CTHQT%>* zK^y0gqn6;jkAA$J9VG!)>DuI3F$Sf7e*b~+v_mm#ycWwAhtbTRn-;q;{j z?;BgppR*ClQln&}^V1P|{l;CxX2|9x$F0Ac=xoGGhlQ9w}+;z|&)Qcpti`OjOV z2Mi4unv2PNAu-T3G`B<2)%;;2>TqzRe9&Cfvz7fvVeoJGVa1^D-yy$Lqw>c^=V)*^ zy)pAoBhu22Qn&n^QOrcu?|@+8+|vBr*4P~JuzpJ;SV0J%tp4s>L}7z*No)aqo5umg zGsKwN_MFW)6o^e=ns`_jggDp!A$2szYQ#1WRtMe{^yzk?ee*!w=20Qs7EX#<6f?l-YqWIe|P$6Lv z0Rd$}M!y?0UTAzU%QZOIzFN}=_|vDmZh`enscZzV5P+sNH92gW@Lc3gCUJ8Hxs@T) zowV8X+T1MJAxd)%?(<|DSLwj7R#7=lPQ{i44M1EBk-Oda(6IR-+$`^#HpB=!TEvv7>-J*)nCZ|LB)Ui!p-Hv8If=ZEv`%3-hDVbUXM+>+xXVmUE!+GXE8j@@Oj7#6djmw~>?W>TE zAF@#`IuMPno3H5?_9y7MEzIi;&*CxTaQn)$R+rRUrGNF!n@+m~$3P?M=8Ym$k=?vl z9}*u3o2qwQVrscqM;g+pTh|*-T~WRgMt4b;`{ZXhLI3(fMu^MF*?82V|D!1|bhd@6 zcC?FR)Dv4R6A1Y*?A$#e^;;}8vP5eTIhYn4iB1}xq+iE8yhp_5P8|Q8M$3--(TpjV zkF$g*WE3|)hn|Dfx2G7w!HlS+jDk!CUHrn@44O}Kk>h;d1H@FRc)SsFS%7?b!w(>E zZhetpB>sD^*WUE|Z6cfXU|kt2;UZU=_d!CS5K-{1fH@ac5$1nIgAL3@_v!C5tDa}O z^}r=En`Y)YBS%3prXL?Nh0=`nPY9^WP;t!L2++9OaEUt0f?$Z*fo?>AVw(E`AHh)5 z=|zYI3J9pSeiv&!H}QukXQ}-?q%=u@GCH&ht{BtztTUZNN{*uf*vOza=de2e(O8Nj zXrrXM4$!lx9m6OGIEdb4tZGAvFlFwq+xm>~Kk#XpHb8G0bjsILW?wb`jlF$;dvuju zsV81u}sB1%8j{o zn+grsvz$~oQE_W6H)hpaJ-{L;Ss?&3dg3SmpirwT#gc-;+2>&On@Ex~%209}G7K#)Ct_ z^T9fDdQ2CWkW$}pr>jL8Haeul{)#Wr4rzRmqq7CN0qe-6z~FnXSKsPZ9uYuCg*RaG z5q3I~3h0gVlK9%`6%M-%OAsq;*zSmI?EXiy{)djtoTGTG#R8*y>hzG%Uy+5JGNaNr zN*yS^6!JySUHe3m0V8Eu|8?!`#lKAC_5&en~1y|B-N^7;QDp#1hbef zrj|RHcCi&1^m2w@Uv?ZdD4CNk(VGt91uL^r#F-&2m(+p)l22xyC2D-N42WmFBULq& z{4H@NuhDtEHPJ+I3CN)}H#~=TW`LNR8*ktyvC^m6CK!ErS>B6&R8l3!%-)Qjo`oFR zn8KnqtE;gr-#?d;_j4(kkz)eOTwU_JFHaqTMVXq<&vBH~H$y3P=uk%t#7HSygHU1% z)$)8s0cNls ztyO2JYXeT?YN*!Kq*PP{Fx}X*uz&-mmC=BG6oBue^VkLF)i3+4xEs$&F|V~cFT;_ z(a92T-AFkysMe6}cbe?I4}%6?sz!D|GrSGd@i|79g|@oax59YPrgb8VYDrG~9?|~X z?sk3wd{fdd{idf{8Tiv)B>U|#XPq>|bRX33=tl<~N&TUGGBq-Mov;wC- zM|Z*jA9P}(oEFzVvAjLOs!jjPUil>nt1b@3c0qIWi!`*g4(7Y#hcUTnLF3}?r$9O) z(*^>9Bx&rQVKu&)psv9q)>61ZHAR|ex60oi2b*dkqQ-d#3Hmm7x9g>{@>ua=_Rb*R z(pnFC`aU`wIa~3)cn$tEhgU&d z12WDiKf> zs<#&7o!th}MSQ`-!2cL(<*E{AYK!FG z8eArP=kNdb2|eHVH+#^oI)+q5n%i-5GRMErh#(ghWCfCr*qHsj`VT;*d&@Hxm_U8l z97`@iD8>R^IO@A>f_SvQ+PFF=B0dTyAT)EEARXis$dLOmId%G~SdF7!C|1-&KbrrU zHf4;N53jiGa1?6ufz_mUbC}9sM6pjRhJ)$Ciy$-*-L6pKFi*3U4MZ?h<{DTE!T8qo z|21^@G{LsY+_@jl+PiXxs8xjEa%5JRJTdh5FcN8C;xN8jiP2ebMo~b58wNMPg&?0t zeV~*ytIq0BrzwOG~P}K+zW$ZNu|k|g#%4vI%*%(gN>n`(@**id63TujPVff z@&I6jO4;4Jnc>1M>#=vrq%RkMj6;Sv)NZP@FE}_0yJbSo)IYxP$(91J;Y*(Ks0-{>svF&SlZ_Yk1kA8S%^OH%#S9tMiM?NT(M|5`A6E_y;no(+;-_|ZERcPc} z$t*Dyw&n*P9iWGAGBVW|2@`3;F(nafc4ec$Lx)nN)+G?i1T8WIOy2i$_7*bdwVsWM z5e9`bGnBO*lTJPRMVuHe%JMsZn~VQSg#~^dDW9j`^7+!uEigd#tNpX-@9okbT*N4t zyu{!&C?VKC$*>CN12eL^qmJbJjJMM#Pmw4+-}1f?`OLzwU3IWkYDNuU=#_+33Y7wCCRH4?S!0h`=_n;}aD%Tb!ksKNx z-Ktt-rv4wZ+2*e)jqr-{VIhtSu z5zXM44I9rGF(NI1;igiRyky@!!v(YuF7PVed{tE($I3o2{f;wTX9RvexWzW-Z}1bU(y z#5;sEPdds|2sH`huWjZqSQNfur8gtRadio?WkD+tP-&yultzEJ&Hu-q{0DaO+z*f? z3&b#>3OITh)^oCv1t$cO2IoB&UF9wi63U5zM<}(fU~m^Bwmj6?m2@*HE8y5iO0{G^ zF1kGTb2vSwvF`nnMYEt`|EjN@!l)@{p-o^QPmhO({Rh`SzF5lW<^y}2nx!6%?@bDm zmVWHxG5!nSsq7>}gTz)7hL9OMiiK*?T%YS=ws3>bE(V8k=yn}zmSTG}?q*{Rp}OaJ z;Mp1c(?fh&jvToT4qo*(D!opa7bp0b4Aonhn^sIYU0;`$36(jGHROXdPlvob zc^c{h=61tMBom0SkUbC|&}Xg|lgIr)8Gr+JRhIm@*UO}Bnd+*us)mZIvm%i_bcN$f zDVFZ$s{k#6dc6R1aly3LmG=rohKUHole?jVxucat=5YyQsJw`meTCYZ?X9l@srE{) z;PiJcmEAFT68BJh-Z@@Y72n8pqmk`DK+_3@%a|m{Y}d=0!T@nKakB{i#4^32-u8Ga zoZfdP-Pm8A*Md^e#wSzHb#)DcwQH!y|@%xtiJ24oiejn!r2b9Y!YC(B@VQOrn)QSHICSMpdc#pG^h z-M2OlX_5?h5TNnbP1g7KM5T=jJP4e!I+KhTyK*3o^xmruQ;l29(pcqjgaiunSD#Ek zT~@a$81!b+FgfoukkMoqE{V_Uk_=1?NBha+f`jRBX7Rr)fMR{gG5?e%(Wbe=XgHhE z6(ZVxI59G1*ER@4am2ZrmSH6oXJ%nBRx~RKzeVCYtd|MEQMf1ms~@tT=pNlvgM&fjbDvP^ zY*jc${u8i|lRS;4}>a%AS+DGC3}q^q<^T7fNHC{0N(Uyb865Ucwlx)4s&UaJZR?3eBBLv|Q1 z=iAu&1f=70p+G27J-BMAy*>;%-`TYAXDbUp%<`|Kfs>&?9WDSiJCbeS^Lt){?j0;t zjl_$bp@uiWC7oUmK+ElNga4J4E35DHu~6V$$lG$uem5C#FJ2z1E9BNKF)2z zU&j|IBAl}qrzWJ!MH-6Q$9*K)tj@{9(a)wr6<$!6}ELvaEj(urP(#cKomZ`}I5!_k>RSdEX24MWvQn@61m%xY^5ecJA1vzvGWu?v& z8|XXs=wyQdk$BEFE0R%I{R?4~5k<`9&;Z~=7z&6L9_9kpsgWB~bnKmbve$~D_w#&T zJmIn|;9cwy<+K(Qa0yDG^6CR^sYbqPP;p`u5th|Y%5uF}#EiA4IYt<@l-q*$1hM{< z-{^hb-{#zVhiRkTgF>o|pmZb5gNMTTtc7@>Qj>yG*Qk0)PzqcA>W(Kofd=XMijT>l zD`fyEvxm}kz$}J^1S|FCYVej0%`!aNG z169EttYVHh)onO&oMISP&*n~GFcRmw_%%VAz$+3j-_X5&;FmQ0&ocScdFFO`-sFSe zR7$Rcs&guTOHmFY8|gC*JvBeHzb^qSx_fjOt^V4rV#0wA_{oQDb)|gS_SEKJ_DqN)#)AiM8bAIl6yGt?PiME&DEC;2B6sx z^-&yTvMC*EDIBT+br@#r9f&QOVi;V;Rv@@o{k^0E04#luAVOsZ<66^^KllSQk79%a zkkrI%y9qx(bp96Uh5g^-lpOpApM)+;?}w>!x~0+Iy}=?~fiwF~8*h0y0N+?r?k=JM z3=mTRDt<;2u0tk0f}{w_3l2TG*yuj|lfZp=7gr7Up!JeR z!5gZ`ga)OceD%QiX!}b5@hIO(5fxv^6=4?IPX!qQ5V)m5c2$DykKMdHdVo`=z5!QZ zg}8Q|-KJ2$d3-2N!Y5!2=(syc#}^h;!3GGKeNn)A$-Euv+-wup|P~8*qt?kQgFq^o*v}91lo2N61+GaRSIT)E{ z1sXp9b+@A@C1fzwyxKmMGfCLQjg3$fx?3bVbZ+dJ9c+fIm?!G?HIy-H=FxKd_XeSa zCUigw2ZRCzFf|=>V#q+4@g2(Zy13m6|Dq9lEQ_L%E&|;_yqo^;-PfZbAK=(*RF~E4 zLO)6q05DKheA7*rWyvtK8K#zjUkk7#y@-}APexPM9^1)%F>4x_wCLHWDf;a*b7?~Y z_v?`=KOAEWQt9?XWdf3KOo*X?f#*`4?G3GxQ)Wtk&#dkEYY`EWyCQyXG<)^ya}5G; z)eTAKL^+}=AS4L6LY?cR(FiFuhUFJgP~}4#nZLR-V&3csy@ociD3GVCOn$aNFu7L$ z&78s^a>F5I7GYxt_28a*odW;_m5HW*2)Yj|W3Q7Qx}X=UkQeO>+T{mNI*8xNNYhAtDG1;mU= z?-?Xmj8(qG@NE$ELhhqGGO^NHjZDj|8~Hq|Yb;}1#Q=aB_k5U0!?pBD&vM7?575|w-Kr(WWE&pnU^RD013Z?0 zu=vi)&{9!(dsb3nsH{1^a8-Exd=+=d2?^iUP)&o=-XT|}*;uw+V#yA7q|0gAUG(y4 z{-apga4MG$9<%#8K9XOJUfMDjKg~ZMvqO7li-V`_;fo(q9)y{eu8lt$JCa<<(BHe% z>AXW^@Q;{c&4^=ZjDr^HEA1j09Q)4eNxN74=t_v2Z7KhflrPd3ry^NZMR3-(;V>ql zmqIy4SGK+_PsO;NF}U7*7z(fV%U5pXY%0m<3ZhUHfUZu#iZ2jmf`z)oR`091xjEL@4!?J6 zkd-8mPY2rmhz@blc#9ES*C7WLR_9RjU!=Uj!gOfyZJ^!ud)LAPzc0Ae$@5BKz)jDT zzZ5|;%2Xi)9qwnG7rGO{q@csJ@nzdA@399%2s^@^ebxgv!k#kP+%f{Siz~q(^0nI0M0v0bo^-b@MxNKk47P)M*Ri=i=lu|n{ z8~w^-4@i|4`=)cSRCa3}%~raP^B}@VVd>7`x2J%D2^s`dmT+KsZVVvEv{TV~l2HrU z@9D;9z?2ZK*T2Km{Jb`CHl+^};em~m;2_I7N2C!+1Ip=q>`?$RMXd|M>PmK8V>v-1 zo8ZXC;m_Noq7k10-jVCwDnl}Dsa-qdX0iD3gL#P_*d4)ci1DxEf3SAt()M;q2HQ&r z$@^|gZ5=l1aXmENnNb6eV$Dp`rOLlECrPMbUF%{29Eeg-6e#sL->|b__KPDYUtXYe zRNQBsJk1QaA(?%rP%JFeM$N_<98|`0>>nuH901BVbh>d7?Ug@%^!I#gZMBGKCJLLR z8YmzII9sZMf`-7i?zOOhza|mGa?F8dj zh1Roy`AXx*7Q7ZwtMSPRF+RJ)^|$@2w13`7?y0X|gyoQKwFhjhb5Ue0&c6m(L^9ng zyynRv&^R&qK;nz02i?k;xl65vK^{#qqe?X+@~u*VNK;CdVz>wwxAX=|65q(Rrs%!U z%eta*$kbZv-YfYt*V}Of@$8*M6%6ii%cqet7jrX2&t@oP-h!+azE6xL#$`pkH}z~E zrA0LSwA?)NWAT)W3#^0tnz*JxG7eFN}Vk0`8PKqizw* z7NiCA!aaWZ&`mjoIHk;*Rp|v;Wa7giD=APQoS_(Z!^V3iVb(jnM+61sOo&=RcTu(H z?Xx{{<8OxMnP_#zpjd_~TT;G;=o_ovBpJq?Ggh@d=PbOjWGmd5tZU<~g&MqjKlu?3 z4v^u%B7?$qD`wKN=%VJOF6>t|((OXFkAy-5og($CV>ml^4{z{-iO0qJL&mI(!|5S8 zAJR1?VxDX4*q44>0e4ZHKuB&vknJ6J+N6c$uz{C#+;9Z#3tIWDY_!|r_P_n(Bwh7r zP)W9pw^00`?p@pHIpkfIB0$!RdnW9*Kjks>nGJ6zi|0&EKKH_9UIN~sa z2w@w|zDcO?LSfvRUq)P@Vubw1s z0Zof8bQx$Ii3EvdnG2^6Ac-9~`Bss4AuH}{%e%J%(O|xrZ_>%8rHg&<&;9tj^xq8B zJxR2L$vX(Co6VW{9aBaoum;7p!)u8^pEmz?5zMzKwV1@YcEv?kAUdqDQxE&A;}tF1 zQZ~M5QzXxYWQ@7Ab$x~l4)b+{lFz1$=PpOmL}WN>B4J|YnspMC6X^Qa=(X}{a=!_) z-tdD7JMf`%+B>pRK|6Vz1WKl#A|3-#onNQqj)Ak)|mAzU}6r#N4Tj;r28#A zaqofou1fL(GKBkgzYd~~wwGGbC`OSt z+(B#XSFrU6Eec$4Q_936kpH7`*a5ITt1=R@1@b%0m_NU-gnt|d)Hn-2A_K)*9y|`k zNvsrd1Ap^ubh#sm#23p#}xeW6q)Md?%vD_i$P4%ou>u*ebb zbn17gtD@5E!jBQj$n1z19W6g7SQw>E2JIYdad08N4FzScOwvj5k(j=2W7T!S2{&7+ zGGwU#UiNaJw6PMGVwQ!}<;_Hcq=BcJs?5z!V(dggMs=Yr+3#zv07(nl7 z%3m0=kbpDj>P+ORhglPY(AG{Wcvk;GY`ojl)iEf*u{zj{Pt3G*#(-1KPK4)uup~;K z8Ko13^RALP{qSM-bwYIhBtASa6A%BVA;My+g3m5GZ@VJuO< zHSs6^o72bNzo6Ux&6v3#ch2#l+A0Cj3D9s7j%*C*t3cO{2Y5ttIOs!RJMq@YXUKl7 zD0}s@=PI@>f3Y`?4bsr4CMZ3nh|=@RQ|y}whp>-;)NE+eW>l0H(5O(+(1RE63 zmo=2u9rI={f|e-Crv@c9@L`9f4!Zp-f1Z1M2Od!&+faX!Hp;UIRALn(vKi}q51kYg zqH&x;wnlHQ=KeI8l#dOODnDWwMk2R#tbr2UxE6YeCdycHi5H`VcTaQ4Ff^zq;3L}9 zlomuZ@SLA6yXf3~B9^tW@(mgYD@cwgA0hSM*&`n;VojG^HR?Nq)r12-ZScMZtU}wN zD}lhr>FM{qq51Hg)U72CWGB0*L3bklkKatf1cPXWSi{a2FyXo#(&>q$?kud|8rhOE zxRG3}vP$DXbp>b485z$*0G}uP%M1uS1HSlOe6c78-KLMu?S7rS=TgAGSUq3v`vEPZW(YTf?Z9l_aF%Bj~Fgg3+U4aH)ulg?>O%B(s>vJF{ps^}En`MCWPf|CK3I$xgB~NRa9{ z7{+DHpZ5(GAX;Ve4du>1?pL_GFkza00Yo&*N`@<_?pFB$48oY|4Mb=lCZ_A;DL?LEGSal@m>*Dk z2^S#$lNxnoQ^QHwKFbhljRNGe1n2ffV{`T6G@F6vR~x?$(s2x|^?afoJYX9niOy@{K?8RhP#mBNbA|w@O@xD~ zg2CWI@;}#*1FgpQZ}H?j+@`k!+-*|=O3QM-^k=I~*{dWMMmNrTn*rXJJpn}U@Ity@LwJw*#O`}a_S8k$*^Qp4f?NPM;cOaGgG3AfbhNr)`pk%g zZn%#gYKnGsn#5Ac5FU$f2C`~nG{tYs-#lj8URQ~qjKU4jnD?9qNG$*oFLCu;5_q7J zm4LOr@>eYvs`RH%r{u$tn-6G^!DoDGeG`f2^>@B_N0ogUB@;h|`(F3t?Bvz}-&yqE zWNm*Z@{~>9PL}q%2ZRYw-mrig5NS0`{!1%-Aq>SG z&@~$}%n`gCiC1IlM%{PWv%lnWUBuFtwfVrl-f~)}_ClXMs?=pIaMh1fs`;%$|3a4D zeQ;aKD<*T@;h58bw{fiKZ-cFvn^J`t9vnEeAE{wdG~g?H_~1T z1&!FpG#V!lo$bUuW55qZjZk-Za>~BvAl|x$jdcC;B9z*7H%@P5d`rx|R-ler-qVT& zj+az22jpbh-1{m|dkKKAF#XGO!2DR?WW7A$+xB3~4uaPEs zNvlXZNE=a!oxbTaW+ME;b&EJ6nLc!8FNu`YC_|i@-gVXhfTf@oGdd{f9hUtSO>Sif_)t!X@ z{S)cqU~6}{wB~@Fe&lC9xih$?8HwG0Gv9DL*2Gy${Sm(Je8? zRXG68o%7-0cnMkSaJ#>djNjZuW04O&&)f@3ZHTPm^(b;!6OvcVN*n~2sCUd@coGi1 zAq++^CC)(qLz7oF=NI@Tc!6LhPCqV%- ziahY29mDH7SCg{B5d86N zsiG&OvGoh&3w?wokfGfC8?4%I_XQVO7>cTC0jUVasVn-eH&pw_VT-e#&d1-C#Ln?4 zlNyQ!E0@Fp2eANlaQKHP3{t~oj3-WI0^@YO3HC=bW&7c^$n%cmg4xemQP?q4>^Jhn z4Vi|Y#9S&V!sZ$1E_@;uCSmH3vP40&3d%+B-WA8uMj&gBwF(W3j%xpaN*l6MWxdu+ zI~xIRsSRBBPXc1C@Dp-LzDP8mVm>K;Z&QFVSbvLu)1-SE2P-tuGNp0tiX@xi;|sk; zFE{yc>U{b%^e%uf`gNJ0_qECWU88pqcb1nSBJF~l_v+8qVzLuWE??#7<~78OdrX|O z)n;SOv&qYn&+*otg#Z1nGk&J&Z=AM((!b%~C4vo0iVgw?SYNw>a9SCrWmWFp%KhPD zKBlU&&9^Sw1eAQR#1T6r-(RRqxosFNz<|=hpJ_O~N?oe|a_A)0x)tt-FO0=#Pg<;_ zdGhs!@#_yuui4kB4+q0USL|@1{RG>wjGxp3jQfJ5KIN|;p_&Br2;^1XD0@hoejUF4 zM!)iL23HySBNqb;KDCUyND>b`<gfQ zyl+<5617qqi~oLbbG2o1@3-7-#d&|W67ANqPj+EgRERL(9F>tl2x9O%JtbsQv)j#8 zOZLSy&{v_FkGPC z_(N2{a>|%E4Hno3O>C4+vVLdh;^}d)zJu4u)$vktFuK^4?zuv4TXxxTU)q6V=b{$3 zJV6)@tn=o9N#Q1_fMabfVCs&fR<5U{ygH)ZC!Wg$`#h3mMe%HTy{(Y>H#gp*U!8z6Y zIf|22Kr@peA0rut1IrZ623ezd5m!>#?7!LlRc&HSKnB<(`++z@T&G>vO8j^OpQNjpf!;+r??QWo)I21=IpBxFsf)1!;1{^k4yo%*q?U!(15S;KM?`3sv;fEooCXf zHSW%nsh~KWAJM-~DwmhI*X;l-hLPA#yWy`{TdvgFE$g}hYECtJsA`MXlMiVifWw?a zaI|p$;E_{C`|eYZtKX<2VTE+k`9H>Z_bu18{Or$J$6WaGOcc;}uVl;&ipagX@~Rt^ zByLR5H7R^Jr}8c;r_#ebJyVlPsl=CN?YKlxLtD4swX?geh~a(+lWck+@CN>oS?{_L z;aX#+-;*DkN$PZfE3O;vL;+BS9q7mn0kM`h7m?2rV1cAqjV#CwqD$b}c-5=IITeC=Rh<%7UdsCoV%hS)7L1iO)tktuL4LiW zGQQ@V9vdwOD;drf)M$s@k*KQ*jwEc;3l#V=s+l?AI(2_DmQAzylt$W&RK9Y6es zEAjK)BLLW2iCaMe*@q$zD~~s=oeDN1^_HdP>7GS@uOo=+5*HnQ5N@4Z-uAGxsuhqV z-V*%9&!Fnb;1v20HOfV`95?4SqAxt$W4hQ=&V#mixQGRo8ngM5{)GEEKd^iCeeqB) z&}E&t++l1{wU&Nb7KvI+OJQUcAt5Y6AoVAs9JaNk<5{PbXyA=>Pwbx(*lf??72RVi z5Q)_iL9Z_gy(!K5Lrf_P#S7NWn)84%>FpjENc5a#7X2Q^NpBKMDeOVDBDqK312gXnu zcIZ%wp&~~O4KQRVm=p~i3uQf3H_z_A)IWR_ z_>jtfZ2~)$7ewER4G);tyX^DoU`5n5k^QPCDu=L>@I{~b`GV$A&ITisEUo-NLdV$u zC@JcP?^@I>ADEFL>D3Ps)l~pPWpPo4K>u-1SBK+ z@QHVjHXRV9NrEL|m8^CDH1^-;D2 z9&O#r;wdEUg>~H{KeogJy<-h;Uo`)tA6C9`BfDv~CkY&&2vic2g!c>djrePr#Ipl9 z>OZlWL8hq?TK41qv#SNsm^Iy5?e++n2m9MZtuXxMds)!1xlJMd8pJbj(fKe*ZkttmBR_#L{&;p=1P5;?^YYI=vP009!|w|JcZCDWv*yjPVs23`*p zTB<)jb)yQSQ%Qr(eZ?3c+qcP1P67lUXt$YOj00#8%s@+c3;jyBMr^SPx4lM2EzG0k zq02CMcbKVgExQz~=kz6K;3=yJPZ(r}QPh^RKD$!q_LgEvBm z$eerB`#;fl0K$Da>?e1ZZklW!-Bcq$TYRM73r@={QR{CfWZ}&b*Q}`Eev-8q)so`D zHR!F6OPPDEtbOrDRN@8``vp$^R@a`=`SN%Tg5e%XO;|xlwe8RX)h6(2OM(_U>6*=% ziC*O)Q`BD>_nArl zA%7U7g535fp}|^;-o6 zoCu9roSfM4cahNit^N0G2yjq?Fu|;ubOQzGBc3zlx8YCZV10mR@I18gtxIVj__Dwg zAQaO>MA9QeB$1|`iz5B1En|%n6mbkBlor(x|BYIRbXwL$M-8*aUb>U?-yW~s`|4=U z451z+&7bi0UgwIgU;(Sj4ZTHpwL7rW-6&~22Ug|OVgb*ZrS} zY{*uF@zAU6Wt8`JfBDYt)D=Lk;RY9RN^xJomQ8^McXjTHO2)cBSn6)|Z1~IiFo5HD zo;JEB-WH{tdz?R@n-BeWvks4CyPODfUplCsn_SfMdf;j=F@USv_pY@*eOY6Rz7OR1 zoB}r1x|;ySd}mZRRG9YQM6bKaF(!G+K-GabTo<3T^W5h2Y5Y?B{OlEgWSl}p>90iq zTgQ`700QI3bfci15+)FlbRz=N5gdeCMAp7TJwd@!Nqi)?*?paXIaX_(s=epo(len% z_57`t;}v7a6bN#}Jwh)>q%)P@G5Gbyq`9prJfT$YG9WO%jYESpZb-CovFqbRbdz1v zCjD`u+!nOzft>d=`W_j}x(o*o+BK8CUmRkWVL^+bOT!6IW7SjMA)N@SJ)+{4tRDXM zXGb@OX~kt?M03k8<(N(%`tuiW#wSUAj}y5#0jxsDK%_>V$jsxypPg{a zO>J&8MU#KgQ)evqc<~t8YdBzrwID@a#KPGe%C7JB0&zb@A(o#V&){CJMVjPC>u{MP z&<;lP3IwkNEqcW+8btJa={HRsODo%Ff_RA!B?ELbI!^9_bm*6146%Q(Q*N1QE{?G* z#1#X)PbZewx!BKYwl^+2NmPAy_gvmGvUVN><@!N}ksfzuPA;;3S`9@FajwOH9sLSS zDNQDTwR7N>cPcrE!Vdd>*#Yb4c_#IxB9ZWexuzUA;)33ga{N@P&iIod|B#^tbG!wS$Cn1XbEo~ zw$24%EhO~@=cdbzYG5dSx-ZF#J+CK`jFj={*7#vvJa(axSDUaUHHhl!h!itrCk!e- zaf#B$+IFAnzX?3y5UOO4OvMMepv#jKsCx~uxY#h(U?9>z4@OX{KY6V@l<;<1_XG zHm77cR80%O(%diw=saO)VZo`G_th-+o?aDV1dD`GK5$12&4oyfsBsodO_@*oMkSuL zq4JU&4u+f;PJ0+0=M`0{jl6!7pd<7{9%inv5o^Kz(~ zkL;dPaZn&<++3kJChOr;!2@P)dY|8*0GwvuNei-Q=Fd(8tzSA3NjD(%*n$oVucTHM zFOQs3idq0wBn;|!cUqfc9~%U4i)nMHRWdiU zsaPdBx2TsQn6yW^^*bv|sfOE% zcfrGXw8NHJYK1a@%dD5_b9J12(PnTyE0dzbm4-)|zzwrg@HEbO2}*Q!$36XayNZf5 z3CpVa35t;M@IqzB-NH2pgB(q?WbhRw2p;Fj5?+_9j*qIz`l^uqZ^=c$ih*bTO~fn} znpcs+%Q8rxz#+O&+aDO1t`g7yf!osv zw#nT=02c>R3fSGoImb#(;UGBt%@kjaKv#8I`) ze2kLYJ2AVHYOaWH5zd_mWv6g&SBH6_gwwLj7oT-v`!F{1(JgqE_{QL~ziaG&wRfW0 z`=v8dHm=rt4FdF;Qwc*Nk`U<|T#R@np(7kAdAJ^#H0z-LNyTpKWt$#J*YP^`?$Cw<_8?AK+|0DiZo*hV- zqyB~$1^HVBb$%>B_Tx6&fOSFXyQqMhMPEYuvyO0D3aWXMrw)MiHbuJo zo~iEEFc;hzM$s}Ul$95)NSMH!<+x%RK^am4TdW>3nAk8S+?ds_>Q33=X$Y7q(R2w! z4ewfngYpTX2VbrHk1pTwRQ+}>W|}05jp5N2s|lU!HruNZ3x1!u4j9>45K?~!@oc%QIRZMKv*6@`BjhCA2TH3NxcRfnIC8 z)XCqyO2YT&K7cMSQ0Y`?KAB${PwcC&T*y~0qPBRv&R=GeUwQ!5_S@^W9YNl`ncM)m zJ)DG{Uh>EO7W!7XSpxOaY2D$B@v}eH^aurcx9YX?{j3r=k9F)g20dOq=cwMlIrHEQ zkhlE;P@Ye*;#${GK|HqSv}lPH-$8{w%I)I-1<=bR3=z)%LR|om96bf1elNKkw$=vs zw4n45=bNqA2;gzWOtaIaCdV^alxNN1B`=G&$e>0@f}jR}@yN5q2_e3b#-9)a+*mJc z;?*lM7$rwZe`f!WrnBIx;(fdD9J;$(y1S*jTRNp1Bqa|Gl2TIA-JJr5Rsrd5kOt|l z|M|UZz29KgteNMzWAAG_ZAzM|lin+Zw>+dP8Q=oi*sU3v!QP>x3kvFY`e)gLK#~RN6K52v?;oDLxVxU4 zx$?5tS@=yE*TL(XGAm3KsAD+!J9qbiJ*LQ_pZR_*`C)IsBTxJDWv}d5r(IP-#?7JM z2fF8Q5P0l8YuD;!?-fQyv&3`$e{?T8ad%{0zvF|)cL@23FLKC0TH0t$#eDVCYErUk zru7YE%%_8k#^`rF4^S?08b!)HUrf$I{Rx*C_@bIu0N30AgO!1y*qo_BX!P^r);8g* zDoF6J(7!LN%KRq{<)1z7T>3vbF}-8r0iLB16)b;dVCZa?s?PpwGfrV$g+&I!e+mB) zu8CI!61=d;u%cK^=(Eb!;gz z1|&+;FPEubILk@z9VBd(A)jDWAAHrX8Ti$b)b-T#UhH;XY74oNYG6rI%*HpX@a3|$ zrMSG?714lf7%(XvAO{#MR1cFyiZ#vhwBP&_y`K^2MKyaJZiyZz@hplow8TZIVxdul zaeu(7uc@yXb;4$wx6`9{Yk@6X?Qil$4qN!-VT>3iX~ufs)P~S)-AAfLn=%%P$77^6 zVlDpSsoxrw*!|WGR-00SZ9SyI7$ydw(i%Y&b6isCh6TrYa?Ply-cZb!m#6dU(cI|# z4NzfiDswS1{1(|F-dAgY?4Hy9f?y8~40Xq-xTfrlZyGDHZVQu{jBNHixoy<6QJLz8 zv#SC0#-cPQv=+1=F4)iWuiq9DDPSI|$FdSKA65EtcT|*Mul4m6SV87|q5lpuGB@JX zHspOa2gF{;$c&G-arI1M>H_Y(Pwe?OGo2ysSlRqi?%kWB3_ibP*6Lt@Ts~x=T8_jC|DRawT;t~+Dge) zp~j~o{wbQ$G3Fgnakx|`0hiOH)=oiv5n0|duRVnr}T+OZ}|U; zy*SeAfhp~cBCqoN43 z68f=6N&KUn`4y!|79D&=9`H4KFGGjk{MPX+MC|B#rQPD_MU+HL^Yw=!e=xHjXADRJ z-cx98JD@7eVvM#BONZ4o-H(OZp%e-viy??fVmK~Bl*YW-<0msXrRFxeU%`CFUBCFp zf>lf}(hfEV%bw?6X&1T5oZO2eA-2)w#+@23oYm3%rNd^yc=U9IZTsE}?rS2|CFC;8 zuARNFlt(;q=Fe(^t%-N021NX_AThtKnKAY8cFe}}_Dnv|CKf5oChJ`$z2vr)Ky!Od z+HQDAF?TSGZ1Zefobf9=>6^_cc*Bfv813-o?DG@p#b1V3#OCiR7r9`G8q;rl%aj-v zi{oF17$dX|P&8a(3PBPQS~E@=oe{I75<8C_?a{Dp8!@6j>k;E>ABuG_W5H&)^WlnE zjn%{-ll*%67|RbaLIq`H=<(s-R1BXr?IQA}!ZX|BT{803L32wp4dV0X4|PHKc^!`= zXuPn>Jhq|MtcBS9L;PO`(Ia6#=L&?^2+iGmSr$sWsShGpu;A~Ua;|AphP~A$aEP^(7A|`^_vr_F&Sc}pzrlt?+2e|Sid`Ks? z`}==P2vaY=q#IW2IU9Tc44ga-{0a^Wad9K(vVwzB0T}9itN|5t?4Z9Q(aHMPs7@db zG`N_A{2&r>uaff-!<$k2VJ4($xOzRj{GGO0p;?JfQY#MXdXPa){#KU&p(Wx(k-q>rVkk8$IY{ zOL9&v%y{;ObZG(^n5=*XG%h_C0QT&W04FQo4*;apS^JkwBCkdhU)bWTIiH~=Db}MZ zPi$Gl5>1$brX&UpGP8uHcqRKj-;Ji>nz`sC0Itw@9^tN*6)^;09wf-Ez0$y%;Zxjy zdah^mgADN-rn#+smY@tEMhC%I0Wqk6qcj|vA^q-0pG&~sFul!8@fYpM?hk^V6bjm$ zO#`aWSed5_3UEok_cr%mp90?7k0Xj{*UwDU<^z#DpJDH#~s#@U7+X21hR{i(mlSI+o}H6o3egS3b!e#`T424=Nq-1dndq`-xl~s)v-& zDf~zP+o`?}`SYrhj153q5*?{28K8wd8IVN-Vh+XH`nT4;pT06F{V!MbmL zk{!m0<}eCslUw%B2&p$lHJ0+TLbl`L)eiF{T%hW#u#;eO$yY{Q-9saQMWy_4bbNp0 zls5Vf8)1aZS^3a-$Sh{EgR;n{csv$LjGac1ZMS5V_)hQTB&Adw7pn&yspskwEY zkfCjai5U+VsG(+G6ZKwrh$m*!O7I*N^)+IB!H>ko;hywW((zC|ATWYwr0K4-|BA2V z1QmZ#A!)fIYk>fwez<_?A)=abb!(p)YFB$PpNfzai!frx5~yTx z=)gw!1)@_Q4xXS50_>(w(5aB`+S}8LHUO=;`k^(K(|^)F?b~^Ej%WD>M87CkMc0k4 zIKr1c#|DoxYOT>4tH}|khhdncqYrt4uo6oWiSS4%Fl9$i(08fkvcr%cIZUz|hof#$ znEGWSy)oP!R;f!&T!v(5azN#;I(N-8@<(KLMm+jLKf{*Dw{Q?ZN0m1S!QUf<6`<5dIV1! zIR0|!!CJpo3ixg#>uuE45<1+eegtSMtD-JJC>Hd%8>}sp4pwTIip-V!ia#(`X5Wnk zUX(9P8JG@?rSkx5eOhJ%Uw7HXo+~)EY2GgFPehv&lK37P?hE=Gvo8;662f)SH9Wh2 z6KN{@%Avf$L#*thsVTjy5>9QQ9TdeAk+IuUgx&ZA#FKUP^UnACep~Uxv>`7dNkYYK zs?!?FnZtd{he=5yZW`~iNq;RU6pQ*&wD_?S4ZxPQ67~;_-hw7Dbv&t&t@{Ip;*bCj z1bT9;-*-IYIxi7;>7|@@ZDpJQilbW3A0)m(`|w!>I$Y-?P%*1C_hM@*J-YeUIn`kA zSvw*0J15njIgZ|ID^T~=U&dm?y+!tdA5{BF?6xK`c+zJ50fZzwtMl^uUK0T*-xh}f zcr!@Y%#rIq0mqyq2mv5XDa}ovF3}v5rmfnV9qWlVIwy=kUT88#^iwe$Yyvs}_hk<` z{@rN&iv+()A3y{Pw6~`70%U9HLFzx_6rft3i~~G4-bqZa0PC~v``cQRMQk{tKh3+H zNRkaQRNFq)M=XN&FXT@>La(II-uU2?1R(~4ai4PV@q_Q2aoG~ z+7WAD_B!ryLQcv40F8|FV&R;9YT%(p9k%nTD@Chce4TxC2`leSsl zQI@=T)YbV-X$MF&rU(J%)PuoKEy@?YZwDpi2GtMo`B%BbXww-bDg{DA_dh?q$|3{u z0p2DIx-gss^(@#Km5n_Xqu=81s?!|)jVMv2W|_2DN&nJN$D1ONhpU`XtuPQ5#Osz0gA~lYb6IqM|9ZOO11;eH4Uqe+h76U+-oM0cQKL}~NEDATAlRmX70Jbv zkik$DTOTydEqTN=^om^sGk!^^z#CB!n4sLQoCtzl2)@dOxFL;KD-ta79!L9$X4s%> zVk!JWKjZ1km0%K@UJNN&;i-TF=PR@>+tLC1e;j-#y4DjI#Qx#Hy?8CQM$(c|U(9nY z6ncHknOod1c^|W9MNn%0MOYa&^~Mej8sO~975e0D`8F=FwfRrfP6)kuOt?P?(5r(f z4;kLU=KbY#l650T2VmkL^e>Ufm{XHam-8ZIyf7T$`e4IxX$^$$%umI#vZ7N5yh4X> zLyoQ^Su2ow{wV8D;Aurc+Lf`s}D>|-3>JZtZ!nW@~T0dPu% zwFPT=`aT4AqVe#X zXL}KOVsidu6!b<0*InPW!)V|GL@Xfa%xl@c^ugDrBG#g<KA8dME!02m8&shLdx8nwm>1CO#97A(`XFcWkxa~U?XEaf>)GFfaVoU0f;va-~4mOr^ zBzu-aR|Zs^YFy&4I9hPDMOi)wtXcH8`eRynO9fM!SBWfQX_@8~-*-#BXen9>KAj1< zm!fM}fu1^|$xk!?UZou?SH2HY{Z-D{W$e-@#K!jlPs#YfSF{5u(>F}pIy+R?9Y&f6 z1uC2*rA#BY9h+C<#jg|&-rY%=H*%4*2-ACR*d*HTB}Ew#)s$IDZcptiI!H%BisO%i z_u@ybUCgMMk)1Q14qT$o21!>8UE9}o5!JF5wAfR;)W^~i70%^KE*cHa<-L*`d)^Yv zI(W(SX*lR+pYmI;?QVROD0QT^W4zNwumFj04JgEeldnrEr!*FM3HXk_*jZ*+q5L(N z*O~wqXpS!8&y|jWQyviT+lCqn!AD#40YM#?a^%*R{)$DMPn(yBx_<29E;oLXp6b$Ge-?*8-!FaEGaCo0B zMN!3V*2zAsG&!~?8nKgnhc#iqy`~8RM>U|2IcE(-B(`JO=K1hbyq3%7ue*Fv7xqF4 z5S2{2PtG(IZ%nxBy$GBPAXiMHQj)f)wL1P_y|1PFJ1$0Kr1D z)&$x7f>so&_VHZMHP7VQHV1ev&ZAK$hU8!WakwuBpq|Q+|7h0V;~DYbFGcS*+_p;K zm1}%IhuWfv90?tmwO1_F4px4pgqR`{c&~zoR{qHyk=i*Bm*gFj?tjTP#M;t=@l6j= zsAv%Q)bN2FbJn_ECEdv>8pV%CboO&~%R8r{1l1e*PD!XY4kCJLyt%L`@4WGbgh$jqMMM)WDX%scW;NfJihDeoESrZ%J#}Y@N+%$i@l>(mnX;w#-_Xsd_)W=CkFo z(L)injg`AaRqs^%BbO6an0p-784i*-5dIJ(X~L1vZ&z8X@SAk@bvK7m#_v~ojMjlY zuwpj6vT;7*`)7tSUNs)+&wN?#*zf9U!x=N^dSvn>3pM@H^ezdQN(kYYjMpf&MNE@<-j59Wno<<=8=Va|n?2N}a0tpX=dIPEbI29WG z6a3r;=;FbVL<$m&hKOir-72Y^?-9h!kU8aA{gsU=Nr8@*; zE>(kq0%Wa}BQ)Bfix%h1U*FPQ1BPLl;qx_M=r3%T`AjY{^?34Qww3a~u*QUd;huK@ z02WjO&?X=Q_jbIbM7LcyC!9sT6rFf_I9~L43ft#Bb zzGz}d0Io&O$8PaKKUZ+tu2|>svA%(cq)&J3`t&8^`*EsyXgpYg^>#B`cKu77^J1qp z>S||L$IKpAn>_*IJO)r20;UcdqmRm(oO9Gg_X&bRNw@37K?4gB9pvwlv)tkOAf~7z>R+jSYHGU6|mtd8z2E6M16LAiVE_Sr7b*jprDz zlVExX6*bb=K#Hfdz)}{V3#ELv^8 z@@6eA#Jdd%)D8D;G?8|7!P@L`E&k4(Bk^j>IfK}iR328FE(vR>2H)LdVy#l)YFdRgWpJz?#)J^pSB{U{=O zQFTH{2Ex)S;wbo9C!B}?%F53(=w%nBmxu`beaZd?tMtn#2ZC)g^e$ji)OA2z{1v9T z`kjNVh%?Db4vhciAziL9rV7KaUpE`qYcmx01-Fp(HG#6=w=MPUnzH~*w6d65`R?M+ z)jkN5q|Vq^75u%0g<_c?x9hk~MvY93q5vHu-X&7o?ClG**yZmAC>}cUMxV0?z+?By zjCt^1I*5W=xBhSs?#j8BDuKE7EbH7z@bkRWK#(u7@#tb7@+!C>)n27s5H#NwCQ>pw zfY!NVbv8at>0-h6C9SmlcNLz~&AAeU4+AJCZbU}_Lz#R5N~+cXr&w7Uwd328fSgH` z_{nJZ=cjDb*p0yT61QZK2!g9U+_)#LKhw?cQuz3;-D%$~$3dw$7@KNx4qMN|hx}AX zd8=2k%r{bEq9OgiA+z9{D0skWtZs*cBZc>I*y(9jTt&Z|9t5Ni_8tW&<3~nzie-*` zlKqJ}_+IDvTs?4f=xZO!EtybgSS%`10D1ZfI4_In9vcA}#M=1&GOitN=<%RT{SK6% z%riB3LZEiAPzqv#CDEP1Q6T|h-a02anHiFVG zK+=PsOdGmE?`1DGRrQ!FJfPskLzq0HNGG+1fWQ*F*yx3eu!fOb9?KxU<$m8Ad_yR> ze_eC_{%MoDTn2_Czq|~is{Db_sQc(bW$1=#ddxWNUDJbyF>17y3?e~&`yFs==0SQ5 ziNcR8CvFXRwG*R;cF8JIw0IXhyfQXW*Hu6xH5!x9M{zhIL29YM$b?z5$dWR<`IQ3< zsp}^Khcf4ZbxBI-(TWhT_kLq2)S|EGGza=@^ePASUZ@58G(S!7zlgmA0l*MRz&08f zZIeJC%o57>j1pPW3Cn)8E0qra{vk)#aYu7MH%8vn?cJZO>;V;`foqXwT$}ca0j&NO zm4niEt*Jp+`I@c}Jt9J8Km%MFK@XEO@W;TqT6N2fJIjrP5kx?asy>e9T`puEw*mA#7F7o+ zqG*LzYy2{No0mJxpF+5Q$f9=fe*&@qK!xK?z8CTKv_mNHlyoC9G2kfH&UrZ@BF(F~ z6Sasd%s2Idz4i(b6-cd`euyxc%^e-i0KmRJuDpDk+mV%v^{6FUtSy(S^a64ij_gh5 zW-vkj)U|B{8z%&A;wud)@<<H8#efJ_go^R~2Rx36zvZ&O zek{+TCukMsUyt}sMNFGjKv&&y2ra`}dfrymwCoF1iQfPnsBjnwCC`Bgq+td30RWpW z#t6uC&q?6jN5^Y4>Xth1h`PRch&+5Xa^K3#4rf+1hcu&MzkOV*bs|vQnt1w_1^*E< zW2F*#$nYA5-;j40sr**2dYPF73P$ENSeIvlCxXvz{3hr?a$S9~QK?@1 zC}cUZ0cNzKjW1hI1&IZH+#N`?-K01ENwm#*h+8?qAgd@Bl!2zPB1j{u%TUh!CdW*; z6miE2roM=p*KKgz5IZq24I9Z=aOra%|Hwq-0{5K7&g+HUJH`XW5X1!4#R^_qWC~|h zyV2u7eo7%Cbc_994A=A+{JTyfC9_60&}U@&MnK!;N%WPDcuHsDM2-jQCENKX$rn~v zEu$a}y}C);%-BE*sxU^GHzfdINK2KS3#YIHd^7PwUcb3*E3_G0ilG0lp`&@|iVq;&Q^ZOb(W5Yv(1~IqdauU#`>Z1PU_lyKXT8WpsuoxDIRtUm zW@RLx-T+!QX()mV4VZs4nsrswMoXq5JRCJ9Juycb;SX=XKIV5Y`ZKbI8hd}R(EG)8 zwHtBt-J~-wcAFuRH0&@0+lTNQSPIWFe{)6L7swYbfbSKCs>#zbN;ZEV+8JYd2|jE4 zAT5}U$U>nG6^TJ;>`s63Ddg;M1GesLu<}bu?etmWQFcdaIu4j$RP|7Ad3qD??dBam z%Ki%4TBaDHOG8HKYqhE8B{Ekp3-iY$?;pQA>05Wfv>27saP3zwQjrgv4emVBhlAk< zz%p~2ngqouP%GWb=;yR02b;nkz7-h~jH&s@`+kO35Ml08_&<((uUnqdhVhpdwXB|h zsi)A=OUT;w<+`7eS#(m!H)Rp__1Cd!l={oDK*7RZev7)iYet3Lx7O^>kcM}pKjZpd z94tfRUZWJ5cZ(U6(0y9p5p)*94a>qMiR$tX4Iz`$wG&rt?@weZsdnAVeG1{# zx$(V(ttmrM8l`osM9uG8=|H+TGyQd#Ra{XPuH^eJ*+anr*Gui!$l@`|dFQ@4J8*sY!S^ZH#JNLwYWy&S2rWJ(PTNJQEn);sFxMY#w z@Ia)n9S*^9Ji0n^eJ@$cyM*x|0NyQi0`-Q}@Vo2Kt1k|gTQ%PO_73gia z`{!#KESfRoE+q&F7WkBL3w@# zvMgf=Cy^DP{YQh_*kcgO-3hTXuYa1=6T{~`UBDQFwT)X3+k(h0T3U9NXCi>`TOU=N zM<_tIFM_OuHc#u}YKR1R5SGHWpa&hbf?NZU)qPhj&tH9dVY6+s%SoFRPNWtPGJodl zZ#M7<%JGP#=4s`gos4sRRUVZPM6ySSnw_>F(;ukK?g^9jISo%EDYtJhuA|U4<{b7T zg7C2|!YvmPp&Q97jN`7$1?grX0|XM$1IeLZV;DNlhF->94vdfTH;4;IEx!i|V?;2$ z9#*<;dI%WD)=v#4Per%7y=Gr_*bE=P+QB7GT`B<-8duuqUm|H{GLYlE#kDd4q1(@mjcVKHI@Wii zp;m|0r|9C0AdW1bp`YW%mVY-bn0j|b@iQ>}twDk%gm46j5$+cbXk21&ftZQ9E#mtN z#=phhXrr?pR78SroOZ(Xj;aJr=9^JSHHH!RC9;~OpHqT^;fo&X0t)uO31b{o)v}D} zVgJ2z!aldPW}aKX1_AWJ-AXuX(st4y$);k~h?u7bOMn^wKA>Kz!_ zrsq%tPMj#Gh^L9)*+)W-mw|=t^zTpg1d9^Rx}Gx~5dmPFe z8KCe6?JcWjP~$05fq55+1}rEe3Hm>#r{*!i7@?FH8n4D))V}-h$f6~)Pv*(8z|p6V zu};RD_oY%`-S4Q!-Ig7lkbIAM??~{M1V$sN<12$@99dfBk)4l;0%@!)@1Wqz7pS!f)fOJQ>AcL2T$v zXWR|RM1Ta4K^BJ_U~HgfOoI(1DBZA{~N~P zQ35lg!3Wn8XAtzmQ$*eKM)7;IcFQwn>ZXuzQbuN_{X&DKo=Z3YY-#-}kNt4*4Ga5- zI50%Lwjnn_FaoC<04mY9X`v+oA26RrV#Np9yNc)mEP2-`>Ml576vVjg@bUez_o(sP ztBEB_-RbKE8jpFD_zv*^JrH0mZNo_H^TA4Gh-cPUIkA!si5XU6lo`ydgSBGYW4HD zm7JI{Apl7Yv_NAc)&ZgY+IP|UQnkaR8L)}26F9~b8mLQ?4bL_5Q-d(%m6ozfaWobM zD6h+d15~m&Qxbxw0SGM$B%Cc$m}>5dd=Onga1-V!LMBY~3`r7kC7T7{R*wj4(*$;~Ta56Ec&=LM`5UF5BBy;&%y0fswt`KK8-@aL5Kc2D-*4a|Lg((sJi^Tl?*QoZ zqu@QFV-T*c-EL)zEGli>eWvhHYXl?4mWL`ApF6CeskSoAY}~6NEWn!02;yY;4b-1J zRP!=lOQPz=_6HzrmVyc;2@^Jk42;^gPuQ~EmsiRDthBc6f7;dA@f8S1+2b8Qua#&#{JVSlMP&IT7T#vJ&%G=`fe%$L_Tz>n3h29-If6Jh{ zlcjk4di`LJ$(SoKgOPa)fodNI{mq>(Zm&npdNG18_j>zZODGiFIh>m8Uhl#X06B`; zv3V`7V5#w5C)NZ-#Gj2AyQS#>Bbssa-%k|{^xf0V4v_;oPT5X1B<3X5_zV$xI`syl zOLYOp-I}-|!t>o)X`h2;a#F=J(le1QG?iG};CJ@nU@04XZD#&3N%?FhI-%3Jw0c^H zSYxDTv+agz5!Eb?3!wN-s;L_1SL_f^Ae?rA{TGGB5t zG9)UiAW92|iomAiLhg(G!>iTxW;|i}&KZaa#JHB%aQ|#a2QW0*d_U3sto1B@xT=N) z5FICmhX{xD-I7r=|0z z?bZDO#O6qF_=%9kXFbfsXC3890h5g_VkZI#5hNlYr@(|~lVl>F0ppG(m55`Z(k|Bt z0-}wi=)*@o%K+eeJATR%Dnv#nq$@SUR`rp7L&Cftw}t@^t>pSXM+f$*zMc~G$N9fv zR~&wm5I>3?C4>=SKmUZ)Z%kq&cCh{-x(EWd*9}y3CBN9xZ=B6wa7ENKvC@P(J0KA! z`S*Y+SN4oDoO$a@<_IJG-8jFPg9GS_veq#i(#sH^5+mf*Ijs#+DF zDq&oD#WrSc^P84kG#c(&zjqKh+s^~B{rmWO!US;IFMSsi|Icy!E9gbt-yP~mq3}(* zfl4kJUar&hwh}pl2@}W;2P*ldFu_Fh3YldPk}z$p>H$rOYP=L9K*I~sn)LcT9m@01lT8&Od|MY^{O@nQ3?h;rg`vThMKxI z{AzYxJyzugjBi9CNq zv?YZz(hrj zUF`NJBzrT<-4p+VMn2cw&B*B;;LK!c{`OJxD z!B}QPgA}kh_Ugct%!#$qzwHS>*sxy(%Kp@WhBq`~!b_5{XXyS3+bz>w6Kt0DPscWg zJ6NYaPSS3Q`*$^Ur~-Pz4xhDreowH|NWSqakwhW4gA#VYtuCFpuDJRF|6^WZVw|TE zn_?wkY_)&|4NaPr$T=b+qSqC6T=c)EN}t!|qAmLVkE}S!?LvW3?b|q}SXQ-#d?+~G zk?QAuwD~RsRW@guTMO+yX#KUUPJT7k;yyFF7>>U%xpwUCM`&kzHCobg`vCHXM?Umc zYwGv_yR0bPwc+}vaI@gPtMg&;&-i;rUI*@!Yjbq7Cb$G?uTo~6>=u)H4wWoOeEXJ~ zrTbnm2%r=aNonnQ%z?Oz3e3P9<}ZT5HW*X6(u=G0nFx`8g5HUR;)Rq4Tj_f2+SnWX z!mZ1@qUCq8(8?I((W1Hy82j_bT#$$g31&=~9fth;^BPaKn z3RQQQtD%a{4Gx|8N|}uq7dOtYoC=7zVr{ihaxz)OEYRrKEkfu}W|w(UdBb=SkzOaWUB(uGC~j|7oCKBfb4vTr5&*$7eiijJW^3 z3*FXP95=D&??;i7epL6{jpaXubTHmE;0(l#p)xtkxRK@*sSlBhGr z0>xtkDaBgCS`R;pE4L&_6>KiF$A3GFpc9&sn0Is~w74KXVX%MP^gl@XRO5IsoUo|q z&`EdnL~T%3g2aoUgeSgWVTgfHEmZb?NMj(-XEj#+$6cVp)4MpUy>>uii>Uzw#J#4~ zV73VNzcQ1{vrw3#m+OC*XeB1ry0%P-C^MMBXMt(_eP zfN>Z-4Dsl#SqBAaVRoTZD8!r!P?V9>dDnh`7GM$C{q_zm( zV$1XZVy_2oZI>NB0-{pxEBrRI)yU80z(&#?#}~o0BiX90>&n`t@E(> z>zdilwnrOe?fIv@iU+ru!pvQ+7WDUFEt@^fH9e`Ag+;vG`1*P^A{Ob>bB97^fN3GH zhlN1ES{8Zp4~q}si^F*N7v$V)6wS3M^+tHDvk9UI|C49#Id6UtsvS)!ab~pT{%N646ivwi}=+x>y#rJ-I9g;p{WH;+* zCvZ${Ls(XP5>=0e&i`_dx%-=K2#Sk?d99w2Ni|}6r^mD-GINx$OlDot{+Tp9(-J(H zE;}Nbk8y8>Hpmni$#%5=ghAHdR@Z^C@wkV+=zhhfZ+iMuPF+AinM#^9q%}~X3A|9R z0+RT#^+hCEw6E!34dS-Y_=#Q36j&asU^QVF(2z>{T?geeXfxR>l+XRC8iS&1xRfnt z$&3aYrSdjasZ;facWTn(_h0vIcL?9En%w>gt#EC4YkH_G8dA5~pQoD}w9a~5S@MZW z{Y9s4wzD`BYR zLpA2pBH@t&llSn{usp(C&OQP>Eg#%Ray!cdTQtDZZj0sZ(R%fMh$X}#Zqe~Cog`VA zye*JA65g3hCmH^DB9il#*L??k+hwkk(c7&|U|28DsfCfGRaB6|$vAL|93hJ0x?d7` zvUCy`zls~N%(it(j1n9M_aUT&#rJ__hySu_z$qf7mQ<1c5pB@k&1!o+5;kdhTAvZ_ zq&Db&sLc{vGcjp#JX?D*dwX1%#;qp*fr_pyJurX%N8pt$_qv#Y>P8Ry5w;y8^F4UO zDAcHK<`^@7Jde6k00GFLkoq!YfU%uQ@QGh*Jlv+kcZ>2>;K!L+?ckqGcsrJ3-&ZIP zZsb%9-Y*#4&lUZuh9EgVN+UZujac zW}BnF{0=O&T5OqROw%bw8h3~xiNW8su{%uSNa6vm-);Whz{;(tAq(aP@`2(N8=#!! z?>uQ-4HPU7^wYHM=ml-r`~C`k3wE3Sff)Gub)?&J=^u$$;2X4|GWItYHV864TfP2K zCh8{cXMVHU*4P_7+I4Xx(2W>1|7h28Teu&^xmL>fQX1K4>6-)sucp@EZL8&@Rehr^ zG$@%!G@gta8T*KAxaz=udii=&i^ORK^3EgNgUd!tItJe13T5Y=9 z`ZCHs^m+e#n6}yq3*TOR7R#-dMQcJTfwE!lXGQFv#{p_n^zqb&j|#kT*W285{Nt4` zRm*z@{4Z@Y1sp;jMm(@33TCJC#4Ptd+r9tE>e(Mx%yz8`12j~`DiGE!%sR=GZ$Y*O z?w3DoK;{;Vqk>;1ZK1TU&8P+|K~WF21Z80tEVy$UV>G|d8qOW;XwBGiUm5dx>})iy z1L1+{N+gzCq{rA3<`3&v%{ix^1Z@WwF z2caY81#93ETe~?G3-3Wmbyifik*{I|v2s&b<)*nx$TsKSo_L|*-^1L|N6#IUDUH_z z>(bU>*FbOsT1PUr9?Ufv6;X>Koxss`aJ@2cdB!nufJ_HSV{09`$dN%m#x(3tO6kq%~0FclPOTV6~|7otY6&V=51|<}f1V`Gwt@4=jqgk+K*0LF# z^BNJ3!?yEbi6kj>1q9=hP7?96uHX_#5DibB3N@%a$8Wrn2fW6bN%mG1*(_yPa1vdg z%lAHJFU~Y;#y+_jJf`CPeS78_tBj)>*DfB$oX+}2O&=V7{G;4} zAGedbve_Dl*dUd-s-#vb0*siDeZeLzCg@^e7<-4H*;W0F_sz}yDT^P6dzk8KyLii$o zo*h&Ld9+K=8p3O5G4lz%vp$xZj=QmhJ0N?rkou-^bN+axo-qVqA1qPED|Rm`;L0V& z&?%bogn1qj=HElcHy+q~uG8waJ@VeDYrD2=JfTrVTZ9#^#-*Ubvy9 z++gwJu_%#R+GRE~sT{&lwzJdO=(tZ>VNM0Ly$v6D6mJ1`fxCo*c=9X!S2?a^Hz1`G zvBtounmkQ_~QzZeUp^fiZ4 z4b~d#a17^r-x@07@0l)po$?bjO3Q@K)pEi> zWM`J9xUNJ57%^3Tk+mB`!;c8*&mX3+i0i}Ym9R3|SqG$CyAhS!>U)~we4sudxATM- z80ReH{SBmg?Fln$3B24Z*>5GCVa*$|s-u&aB1B=4K)GQc{B>bS<&5$wbcDf(Z434tAoq`o3q% z3L+KUbuGT1fy8~?YRkJ)$yqI@F%gY6SP4mqtT#C77sHDTkJO(CHwalwp3Whw-ywtf zpf58JHX*`084-yNQ^eEdc6IbF;KakM|65%zvb`fm`i*{ueNbw&oU#-TBJ&a=lgN<} zBi|{BEP}yp34>N;0SgI`5>#3V5RH7e!A$|U(=Mn>B!A~rQ*gb7s_Lol!r?aYzWt5= zpeagI1H)bl!{`zB&4`-su}JQFc)$>25KYywyBFWDB$xE+-4Tri|4y{OgsI|;I5hHy zF(}W_#5Ezv{JUtsV7QYBoXiz9elRinj%Zyl6MMj{(0Mb~O$98Zn29qG$_>u`&qY%j zX0Z(W*5^+p_Q}M1WE!aRds#VGKYQckmm>FeM1*na9F{3gr99bbMrMHpN&*MC_<&Ya zz$!UrLO^mXHM{d2ULdoLhvkuhaAAEc?>dCI>^pQ6``g-g8ebhk^G6gF)x+6t%lyiC z@HU>*pyKWiY!E%9!iod5nY7JutJ{piquOAOGum!J5a)HX{Z#~SM7fX}Lhb^W)*vU@ z|1@_`UdSW?>fI7ehN3KNqJ)v4aRpfl8C!(9k_c;dN{Vm-JEUvuwLN<)_s@hq(dxms zSM9Hz$05TQ5Kjw#FDqn8T=cBN{^Ivi=?k&x0s%%aKpLsYDpe&r914)c%nl{Qun0J? z&|c@xdkNN}YBO|6nO^z5eu|AOv>d7|`Z7iwY0!}?g1A!S7{(lZaq z@)`jIWm`zZns-sCtl+Dn`P}7_aTd>+5Pf7jLbeXVl7oik`1sYUkjCUe_}Rjv{O`w} z>SWF)p&7ctq1s0OZ7DW0pjo%R>F_D7-a(jZ!NlTMxZk$oA`ooI(bfHr@C8pbpTHdi zSl+;boCDFegSjAkj<3$vcUWNVDYJlmyRJKfAPghu4f7T6%y$BDs(Jziz$!N8z~yzu zHJrrG?$cgo4?5EeCT=neH|)h^C}``%RAsIv3V;OaK5SNsx ztM%pW{Li~ddYWd3{Ch9y0(R!54;<;WMnfjjV&dp)^XyTY_M-g)S)~9Hl`0g8J!Q17 zm1xFK2F&%}`4!>Zx&SbjacpAu7guy3j_Vu`kkG5GW4vITM_y0)qzwX(OLY5c>zdT* z0EVY(_>)i2m6TP9`*$dw&*N_XJW}sf=#Q8^q7$6ktqM8N%0pRt7HD?|!2^8lHr@{B z#AW>QgU|O){*|CVKWzNK&U@|`m{j&Su9j(u(R$hugfcDuW)_1uF#xB)FxEA^J`w)W z#_&>-2hnypX0fZNyI@Q`<5ypiokMj<386T~ex3&ktNYGX-=jjzdBn%88T|?}sHY6= zT*_u6vahE+l1GA$h!YOZX<{@izCcT42#$_MsQKZ`2>0F`dZ*zGnKKN+gBPx6(i!0j zw_&QJQ~NVj64=_NmVICPJ8URkt^5aDIpYotDJ(}MMc4(l_7)P}*(p@Sb{{Cb$mbl( z^kNuud-!ZUkRK0YW$IeV31AJn8L@RRgqdPS zOX-grWVA9!_MZ93lzd;3=N97?#SuU>Qn$!N@MB#{(uzbIx50gXn*&$3j#mP;yo zp;(4gr(Ql(5j%b;PWGsJ4gz&4f$ALm%l`NvoKL%63aPa@low~IlxHD*W)=TkUq!61 zl!0??PK-fGnmt&OQ!wSgH+gs~GpIhJD#&J(`iKnY{<4$Ppn{4(E-^?>r8iC}r;_|` zu3W&PUjhBI69(Gq#W*Q=4js}kSn`ntFi%E>%@pkZ0~q+gz9-@LmJ?$SUVT_JT4*>M zT#J4`>f1jdhTQchI72_nd#gx{CoI(*j(bt_>*onssfb`R2ltOOQ>@q2=)Jecy)kvB zH_n1L@T)!(?@ojsoLFn<8xxvsjt^=%B#$|^xXSXtatAwic~8oVQ;C};Vn2KV!+bd_ zJw^Z+ov(=LH`m{PO8TAi#UgY%TXbNU{JWT#_gsAYWu)snr;`wYUT|6q{_F|=v>=`+ zCJc8$x?_i14y|J9xA!_ELlPbQQ=h(u#;4e*+}t3N0)nlm60hBsB*d1zb-&S|Mh5p{ zCO&-5dOU=16u^pEDjx>;eSCCTRY6`2gY#v0TyRjx`)QW6aTi^LL^=!rKnmPI?w!ZC zZH~8%jqu!|jQ5|F-Zs{qGJ2k%*69^vG0LMgzV26h%;)=CiN;U1w`&8(_y42mEW@H| z->$!hp}V9TNs%tep<5d1?nWAkp*xiBF6ol)R8r}ZlJ0JJ=l;LP^L;+e?Ah0OoolV% z>L+}~^1XRCk4iV!Tp=xPhSui-2Q1G53#`C82`1yrkfk~!D~#?k$Dz*U(pQb}^cyWV z{&M02IYT!4JJh9WG1OKpVKkE|+mD4&0blFpfzfvsS6pd`TpOm2?{&oe7&+-zOjF<` zudXRlkevxL|JXW8Iw<0=*^z7h;+6U39BC(x6lf+ zQssEua6c<4oVem!(ZKEKlvsmQ-efJ#C7Uxiw(PgJT+mYXGl;&ZuJ|nb6SZLs7mNl* z$CkB$D8T@7j)p8PU0eCgm7@WiF*A*piS{aXyP>N#97LcwE`s!L%v?Z$IX^mp{O{jf zY-|bL5_;!^wBx1qzs1uPIckvhg{gPhBun1!94UT2uEw9H3k728_M~!3tQjgVy)nCt zg%p;WLp=*GhGnPy#t;B=&@!6);m_fg=*Talux6bgv_4f-NTSug42Y|{rVo()J~^z9 zr4b9>zEp$J18GW#?W^QfDGFst8*|5se8sgn4}4X8VO}NkQHsT*?dz4<2wGlg<|~>D z;>!m&oQZ$5Aacv6azi#BG>;fY`YD-|c+mJHh!S+HZ7NU=y)D;2`D61k6z*wY{RYO7v^&2E|gOo%p;{tWKVF-?}xyn2@m&=KVogIbv-EgZcWf#?OOhxJv<{`5TNLo5}(lvHv64C zAU1rPzbKFu7R^{pAm`rY%v2(rKk)}kmzNnjTx7KSbZf!AMG=Dwt?F{c1t2@Btu|K% zm03njlJf6~YZGlFzoG(f5krl_*Ls>@j6nbu$cv-2ozPgqA_fv%zcz`4W?!E#i9U9p zWp-aRXp1=W5lnb(a_6LTgGy?YzvRt;r%|}bF=3(<@-<|xovr=3y?K)-;FX|bTlh4* zYs*9%O`V9qCKaqQ6RG9Xv`D~3xD#tOJ!~!v7i!r(5Mjt$Fvlx||OZZVqI}P5r}zAK{#Py}69p zV2_Ba7lIPRTMs3*wGyMVe@T72(!(V?+H5-(AqyDlRH?h};V-(DAsN596>A76!3u(uECfhIn?kUaRz5jdCM>#2ZR#O&i>1@Re z;_Yr;VPxU8CFbSncWtdT-j126Ryw2aH9394Qc|^Q>nOdHws#K@eS~^6hNgMd z4;ID29~&Zv`e?GG!I8zZit6a~=wd0oKZHRlxTuz#{)(TMhn3`?=}nWZT9nT9iFgc+ zx@^WNwM7ja7wgp0`VSecu26t&Xti1>pS^XX^} z+kA&{N%zXP`Y^HLB7U|O6&|n#Af{ld`lxIZ&)dO=Yh2Q1o@U1M;iuoQ&axqzAB(}I zlDQ18WO|N$(648Wp|zc7qAt0r#gZ_%no&&gv3~LP?x;22okR-D1UQXePoGngn9z31|^9rJ^Rprm$n#iIc4*$K` zIei=DfBvxZoy$ac{c-eAsScCy@ORO*OjY2gHKS~nEt(J+9O{Jlf1{ldpEYvhDVb7UK3C&;mXAhCEwJxf` z)6%}oIOUlcAJ2=5RKVxvV^g%xja&8@Kv6J77K!F2wfjap32V+Z+GNjV&TaI<2cz)q zsp$B04#aZezRLIro-Ti?VsJR`olwwegBHIetcI7G<;9{7arDQAK-|Lic;oK+06EVFryRUt9XwKb-akb|q+nu8ET)n-r7$Bg3TgfWm$oAA zoj+xh^tV7lf4GmvMCu0oo>vgV=~Eu?Ngkp{Z$&|#@@TsL|>F1%as z_9M!@|1<7v`@Y8pFqcScz_sV(a%^{p@7?fyC(F^6kf%a^Ep1TW*%^zGR;|}su3^Ro z!v>atUa?@*wEgzug(*^ZqBwBlE`-h1Cpj}Ge0x^2PNmSDJ`}0nF&S4B_4d-``@0{8 znas=mCRt3xqgx2T-fxkDg?PBI@uf8s06No+B}Vv{3#XCn;1lu!)r}Xebng$CO&ymP zeE0j@m5P?KOAiEu4(O_eaS#B^(0RtLpTp_4?ae$u0$Pa!0^lV^sW7jPQR$Y?6)jxi zl5g&ojQb9jQV<`JSn!WnoPkty$L+q1an;V(|BmuqkbCP$AdHSZ=bm++CbMahYJ_>2 zga`7|l((!uF(EpZg1Am$nVLsNF7SPoM)W_GV(YW&zThD{dMKMbg~V@*o1n`yZ+|t* z>>(h=VJg3d_xgtJLDr+>$3h~&y5fnN^N=R95pz$la{Xl;-FHkEl5~e`hq4)Bfrn2! zlfQT=n@vo%AI@J(v3Z>POyk&k3Y^e!!Jxde&Kn9J!lzZZDE!l^tRu6f=q-FVY| zOKMGwlEsxP5NknU`na&aO%VXw+l`;!Zd@V5vO9H$px2y=hUJ@@bu7ahUHyPjVSeXR zkXJ16*pUDZl4BRb1j?5{6`8UqdE$Qw^)G5fwE)w(UZWfx0(xqEX)&%Zmo9yiXh;gC z^GQ0xdOd?EPU%s0sH0^>J-iqR%||@414^8s9%(122&Lv5rxK0uUlVG0C!eHd%2dXW zM9b%W^~+>x-{ZM9aA~LI4#T|iCX;dG3+-k&81td27H5j&tl)0OknEiToz?`^psC_F z(+Qviy4r8dRC$N2W$z%i4jCb5BeyTSjfEZt+Q7AN5{wvrXr+c`6ado$#0R5QSSNWMIA3?{fuj} zyLi9(dRaH6%qaQ#0|REZ0&ETOdwp4Vc#HlLq62Vxa}gk_ya8+$Y+0pPewf6`Q&@Qu zZkh@EAbEK?%5wlNV2stK-Fib}Z6}P~q1N`LW9JJT(4*_w|7YV4Cxc_^$nz|#R0PGU z(Q*p?u@(u;ZI8wQS4roWcWin-Hhmt!AVR?PI3ZQjKzR+D<{n7*!ihWXvy4D22YpSz+-!A4xPLl>j{ww||vuo~ZwV_Yblc z`A)kC1Dz469skIFI91YR%iPDS?_U2=jqy%%p8Rk@S zJOph`NsZC=t>)+ZU$d=bFMCqX^>Nr;8fF}CLg18heGuE@9DBIXxs}k~QVQLF8+G|J zkEQ_qM(O;$O#}a%dm+aKem=Zko(E$ViJO5*p1EyHP zW{wlgbO->w`70hAub*OKH*+XE;f*zJoL}W(vo_W1iWWLhG2eUOJ`XXXjJW9BLrt0= zc1QuR0UmO+ILZQwBI^fXq01kwh?LZSg9V$=Wg0_b!jEzcQG&N(YL1{F^r ze)PUFJ1ephLTGaRD7mB;{4p$U8Dcc;-0#k}#Z%)$`*IAXlyY#Skgf_0+&6V7L?ZHD zgll49%p(aQ>u#$ICEKm@dbVNTgx4bJlLL?0;AE&mqbG&QCp)fC7LI+8Q?LqtU*utE z40Ju|KEhL=l!wnhj0QeRQ~JKTlwy@m9RsDy0T=)z8~G9c4}X5)98R7 zb7PBME;NA4@^xLC8zF1!x=_bMjZuiqX_>^D>rd`D;!NaeQN=IDpJC^~#6OXYE;HED z*yM|G;pOpBbw9P?6YLLPDiVMZ)P+(Zg4E6tJ=?_7W9FPPzkP0GztDP(I~c;Lo39B9 za`2pyB<whoQ$Z64DtbIX{KLS=4x5_(s1o zE-)9#b!>09>8H06Hy^ogV<@)DUDsi45{YBK{wTO8A7W(C2ro-ZpDz+Tjj{gQF{bI! zB}U!jbZGH$qR)HxsQ>8*-IoDsv*+x9S5jSA?oAVs9KMCq$e5r83PQ6X+s<^ANW4T2 z2I*Vr(+P@#cNlo`cT)5;)u@Pe94?!29OEai3rn z_IFoS-B7WlFXhWO<$Kpit)>D9TSO2%V-rgu6rhS8^y4luI*J4Y?c)=YIB3Vc8#VkE z_SEEuU;Op?s?p+l{Pbb5__@CrNEcx$@4s-J40~#JU>fjx-S`Y<>C{JnT9( z4Xj~FG5zI6A^7(^b{id81fX0^Pf*G*-+PDa;kcd*`kGqi29Up5ja!8Kh`lk!LyZav z0)2jCynqgk=ZGD#WlVmGLm!RS`BBc4KnbycFQai_NnK>7D%tcUhEQKh+P;Jf00?OL z06eM(c<_o}R1<*kHJ2pBbJ2WO9V!q+Uim#yjoq}>F-uI5xX(4ypj-}yOufgPibK?H z+pr}f_0wh{{Zlco&>19N6Z}k7neylM&EX$Q%6%sLQS1~fBSDVH^BEaoT*7QG#HIZB zRzuXp8LKEI0phtT`21pOdx0qu)|YA50OE%}*0(cj)?=IGl+Va7-Gtrn6{7~)CBbhm z6Y2-TE9CI|%XzTxLG@HY2{%$j=J3v;aI!Af#>DEV4i?}qyLz%(m<1L#UOU^$17;H< zZ!Fm%>R2wH5Ax;>WTq;q7dhe&Pjv?$yPmv#ud4+^Ic8jKYZVFoa}Ucgn^jZQD!8M5 zTQ^UAvgd;#lv=Uh4u4_o5WtPqpqV#)`goD9ToF@Y8bj&#OiY-sL2SP~z8?SlRL=8? z#|`U}Su0xGk|O~w?tj6l3Y%^}hQgGS9`w=3DA9_V__u18TP3CqrpODwWR@MN_Nq~x zXRZ{9ehT;~cy6%0JffXlU6IuRI%IOk04m7*!al23qAA-eiHic{Jece6Q zzJgx%ungk@ULTq_hUr&YS2uIBmwGLQ1q1%Jra!5|0oq2BKv4Ri$ot=9YPMBMr(O-H`KNwGAQ7-t#AL;l@%tI6L>8gQGh8)YQHZ4gf5pJqSmUkw9aKXx|TRUQvo`C3*_CknMIbwN+N{A z)|v5YO0SkZyh1%CPLF3aGf>BN07CtP(H5TMG`c{pKvE@IWSi zW{L&v9tM;>Z1ls&OtpHEke^Z;P5LK|cUBm1EwWQ1;^x7uH{8p2KS$Yy%qE19ET?*N znvloa!uxe1oM5FKh4X76HEZ6anDk7R1Rezv;5YRpG(9s8+B{f+lvHuzAaWi5+Be2U zFWj%rAZTI45fhz#;?Cj1orX)aTF<0iE=`)+$~Q{Aqp3DqJedBKgbXXC1Da zASo{Y+#wGU?v(W=IyQXe?CiQv4l)8~Fp(7rS!v+uiVMTvn>f#{&eta?GHu;3%UQZJ zN;lfQCf*Kdv|i{KfM=XwlCUGEz!OG93s8MrmG3c{$-bPSLW14l{g>Xe7iLzR>puTO0yy=jVmLV90u zW=tx7Xfg2_3#lsZjv_S<2>*AS+8tI4%P|VL5xw61fgRD{NYI6l)#s|vW#?};e`{i& z`}-B&e^er{pZD%S*wa|$lD$?q#3XFSfhrnTJA)~XF612=0n(%RCwnReF5q+KFb+6R z7p^$&R1U;B-^sZ?GU$bG14lyud z3P$wh7D7_pMK1VD{dD3agcR@;WE2((UzNy~4F@DT{<*rg`ClV1QQCj`nI8v%#?N0C z-T77{MFZ~b&gEwU6 zyqpY1G*usZco707xmMEeG`U7M89+}9G66QkohOLj7?Vh}J`(!kIP}Df* zxQ0!IWwD_=&n!;7?g|fVXMT%r4KZ*$(!>6_w zU6XmWdluB0fy4@vkyIplsLW$8QvgVg$@rmn-!&!HfdL5ti116= zarS303^fPqQr>1VCz~orh=;#rp1%d;fnT|6ya0fqz-NSIW&u9GKy^#$NLnHS7uH^p zby<-C>vyAz!Z~KN=spYi7I&d&xlq@gUG^_6@u3?1int)AMM)6n_mH_e|C{G2Ubj@Y3O?{%pTAjoeT$|=L-er{S)=`>{D#tJ50w#?f#5(Q;B)(MRym^ zL}N^n123Vy}M5z+9@B(>eO~&M)B!L&WrMaW9Q+EtAW!2#j`;#{%OA?lMSGLlI_f~-*-;%!W8E=5qP|51KFT1U2QQ~N9ZSExAEAn9 zU$5tl^;r8x!X51vT>Bie0$#lNuV)hLtadTwPL16DwrcBV4g0pg*YgmJ&Ku;g?%HK< zvMw@>Pm=;$+o1QCED*MCHaf@PyyewT%~@$%A=Rb9ePTSQ=X02e8er#IDMt4$nXT>k zJDBiZ-i)I%yug=Jq-ph!BfqJm?@{o_lo^%$wZPa^htZ9D_Nm6`k4^v9Zqy5$ zEytO>jZ`@nEH+$v;rS^u0yV`Bn#@Ne52;4%AyY1~i5#Kdf9#mMa9^r!|3E3(Z)V-A zb0tG0HKlO*$wF3R(o4J0K)`4gQAIOy_k*eo)hynC4U3zvI$u7FB44&)OtX$_T4%`! z#bZdrXlvQ!G74e+maPd97`)=u6&*f$2 zR;a5+CnX*?#?0jwNq?PIi&qSn^>^~hE|KbdRRaL_f|gcPn`QmpZ}Y9e(VMA!)IW|!w!ls#+682 zH%;*tHNMY+XT9-$Z&WM$zrXKkB#GGUS}3uQMx5rw37rKjo{(~xraSyU*?JA~I$UMV z+;s@E!z?WJMQQEK%_?Tj(uFDtKtuRYJf zhc|vid8$Pbq;@Fl=-@yd?b>Y%`0*LsZ;}LvBj1Q*9NS4duidXcX+d9r3`==nU@LOU zn5gee=&g%?PD(y4hM;Ucpw$p-z0I2Cd(zsnj*@D;=DpkJvZsN)s~Gq;?5{z;Vnt-< z7Y~!$3D`=OA8npprBv87qhMM`dbLRvkEb8s##8gDiz(Ywry$}j1b0s-kc z5G&sHPReaW1_8*kUf1=6>e^APwe0vjouC=Tocf=#j!oo7Tw_pvH7PW7qQ-QGKh&V+ zQ?cXyCy3;RR{Q-{LDM0Zn}=ahHqL3J0^jou;Eb!Qe=wzP0ELd3pNZh!N86Z<)PrKH z0Rgq)kiWLQhG~RaaupoGA#r#ELdf+h=|=e1D1ge|SNUh}bQJKiDH%S9jq)8UvV4?A zCq;RB_SD7JHUNlKDglt56kBN7jp(q?_RBj##AKH|L@Ib}y_?6sBo7d+}{d@E4B8;Y0g#Gr7$Oe4Sb;X zp9hU#*c|rASmS8J@V%B=WhLsNWaWT-Sv%ArpQjnGNej=g0yNH9M=?x?2#uWK_J`5O zlTFxR5AyDsHp<{aoQd5yH0a0Vxzu`~S%sQe)q==(K(@D$iAabUY>W=53?0G}zCbXK zDd5cg2>_x22;#VDzCU1V-&lYg-sR7uD4NphF9ZPTd@23kGBo4^i{QiCNL7LU(+$;V zhV?$ziC$WdBk1qrKBBv{OZ2+4LzxT!FlxKz^}X7$b4jp|M}SKy2Yipd6{gFQd;@zl%WglLhCPZ| z0{9?LP?{)XOc?dugq=G0fsz}@Go1VlKvnN@sZq4*%={6lv%L(P*xLc!pDRWDh6CiN*T5}+; zI4PK_Sn;85nGu{af2KyK&lo57%}OkpP>C$So4pgd1mT%ldkE`}OGY`K)++zZz@sh4AI0OJ2y zZ^pU;9R-0ZK`jDiwh6~z0?E4Lj7^iFQNu(}>py@}e+VJ0ugbU+EWD5b;)w+H)MNMa z!UK8QjKNl;jV27#*ls3OZs%!SlLOnZE%9Gza08QZs4(@!o+bG9fK|kE^!?o!%%8IO z1E-PPL>FqB1H+-Af8Bxyka0h+(vMJOFj%5z+p+UV>|uWWEw34lWA&k3jtPf)-by)kOe#6ez3EYfxedOS^qc+{Md!D~x;tF^c_L67*zLC}cy@BzL1}20 zT9(Up{b0??k~d$mv{{a%CRH=w)HO52v|=_S)9*>?SN;nhu~&P@JP3<}F=|-AxF71U z7@?$KyFrXBy9glE+LKmUst+J^-o(H=!lT@^`20iGtxEB=^!6qWcWedk=h6Od-Qf9p zg~aYK2iYA%`qLj>NzXTJlGfwQ7-f+^Zy@H-6>BBByV$b6iB|X8(@8`{^w7aIH6J$d zuMaPI>pqwVL?tvIe7r#Ob_5p`6^esx{Q=WsY+^H}^GltT3(osdyaPwvf!aa}POjis z&JKw|+>B(q`BdO9vUJd9R8t|K`Sq-A=`m-@Nhqu&SzA=hN)%XMv-P zSgq#M3yn}De4o4&fVd7%t0XK=Pzoyyp`xlMud51{$zusA6|@c)X=`2yJUMO_XG`fG zXZxFhqsmV7FD4WpmPh(|ccEsAO8$*l$`gvOG|}x$UfQhZ^~;QH$Og@|C$QhS9XvM1 zx?Vlim%P?NGG9s2Q-4flZ>Nz7ktY#BU$`n}X-Xg=NY;A8LB>&baX3;RM4cdNm5a}F zP#-tTQ+vQ%=qrd+li%N@PgEXfjZO3U`*ZJ0$q7m+2Bic76~C^M|GgW7#Xp8lR;XsV z1Dw|OG|QXvimh@V-=NXizbhyAr0_V=B+p6EDBGCk>+@zbWDRamnG`2|fzFlkZ$e|mA@XC465H0%~!oJsQeKHmp)rd+3 zv%s7Q4gE+QCKJ8asrbHnAWOcufB`lh1TvFgr$xA);x2p?{Wm2OB4nHF5e_~LQ5jc? z6MgXCYMliKES;)Q#d&NhDeUkvnK!JST4q=DJ6fmZ2y}j2FEJyjd!vtU} zC@{ZFs_EX#+sLIo@Ju3x&ypB?q6VNEM6j1R5qi2nZ-WLAfbO9_+Cvp(;zRH!X)}+f z5svb=L3r&=c4j^yum=VJuO+7PJBhCvH+aYQLC#RgXyYYBAp^XGcwXr4(L(U(1V7sD z*q9j(eOo68Q;J^X>bhoYd6llRmG!|LQ3J6)p*|6DtVO^dwi`u;sMavMDxUdDg7x`|4_yvK%YAu!ejNt|ND5u zCyq5&dl?d4$}yTEEZ_%&liYSAl_c)J+|FKQ!z`gAH;~sZyLzzjx~7vao5n7U)xon7XC`0KREYn*iKR#Q~hf5KAG zt~eHHDNYqVUFLbmu(A4tj5ofdeQ@G6S*nJA{y8LI>l&@bN!1rvWM=1GCCv9+bU^=G ztVQr<{SSjd;3!*wP=Q7;HXX6;wbY8MGjYI0YE4i3+9PP}eHJ?Z zdlt$({{iudM#eR^*>FQ*2o^8UqiE4OxOw@BL^MjVHHuHWaeyqR;jP<;BsVz86rr$2 zVY?Y9dBc>R62G8F4;^U_FFUeCnhpcBP~-o+06gH2wEX0#{&2JdTI%b=`W1L2MxDEoe+dT~=f ziEW}HP}S%TgYC5U)FDZSF%IGia6NOd)!QEFVT;=?h0rn1uG!iO=G6met^x678!flr z^Fcw|mF4aSNU2$}M@60(9|QUmc2T&HSpD|vi4h1jG58>*kI|-QP2kq%_ZAeCc0@p) zVFC8I_9A|~2_FH7V6CCZ3*Bn~L>eN0@;nlyMXaJ%6Tgg)@UQpR+Av&c)tu3_{*Pf` zWHkGSZHD=i%s7o~1U9126ppdS+~B*H;b@ohe&tXHAo>fdKo>@TiWS#?4>DF241rq` zKKGnscnp&{ow3lhx{}{xkagT#JM|y_y)#^B@~(`xFs=yy#1!Ox{8UC>g9u5{%YS1(b?!JIKLg|B40#Q|10ClW93i8(>f{}^C_UP-uOYaAdfnHU zv8NY&T1Kv95@ZWeyL)dxf%0Q`_cZG5PxLx3W!_CsIb3yvUz)%0SrQT&^V#mHUn!w6 zgV^khX3@RwGt%d)KIVOv_J7*-)xWCIHO)huDnx%d(;LDpe-Z-$GvClSKMf!zoDeh? zo2}kWEmhXSS0+J_r!5Ac$icO^J&7)=n0q>ost&*Ii=)DNrh0?te?AU+Ygz)bh?#Ui zA5e?E$lIqFY|wyxN6zeL$~#?4d+EH^>?FC{e#^8bnhy_Qp$ad22WCP2N^eum^o^dK z2h8*sg_l4_S{Gj_jqw#28sh9|0xZshG8dTGzJU zc*5>JvoGn|aK(O=ErhV%&jL zf@dqhTp|pk);L`E9AxvEpLSnt-Bo<*!bEy-FZzfU=g^O3Cb@F$GHvxrW1B zREjQ(#(sfZM(2e9pQKC$#!e;?qhf(#oWX)~zsA7z!HbO=MyZbw9B7F>4<{oaC6_IJ z(!SWs0E?*$UB7^34D6F{cF2EUWWc}OnCNn#Q!4iXV!XdenVVkuCO&jCx9($7irbW` z0{NAXaAjp>-u&UF9!#F{4}kXO<=%$d?5@2_vLz22YX8R!$=&{-h_p^`-JEMooy)lQLw#c&To{EI!U^k@I8s4)u~Dh#Zn$lBl&@pb&q);g1c0r zir-*0y0bLB-Im+7N4MD~6=>vJv2`wu8tL_ljMClFd-+*(SMd4j*WIs4CN|2SQm+f2 z@X@oDzaZWmFNZfwSkn->C49XhN7x&*gA@8G49_0n*IJ7I&0GfrN@m~m`?1~vlbM=7 z!?k@DSFN;mW28wRgJ|jb`+3J(o^@0y_&(S$^$5zho?6QFo>8FsH+i>n#&CHg0UC&_ zWj0ZVGGv-yef7dJYAgAZ)jTF1yvR2g-~d1CZJlS4(H~#yCRUw+)jl5*T176m#3zm- zk1&VA#jnv`Dij5XsuLzMp?AF3MZELZJ!$kn2CQ zgrcCxsAM|go#oeS=CXVYEAs&(6`6g|8WF*Dk-$09IH_*yVm?(-Fx6NdsB6_=#$ohQ zO@1t9sU2KWM~B;X8?xUUVmdt_CbL7!G-|E=0be_wjW%;HTrO$-=d`zod$JAlL zS9#kqb|Rp{Or^Hb`T_+GzIgjg8{~DKGM>Ap#?+^Et1O(VSQ7=1lGTt++n~Q!kU9)y z0fO{tWUW6d68!j1qUcHCQiG;4%COR-$R+J`4|wo!X1N|vbZm_~K~oW}vwa4+wbHrM z{>!l#!$QZ|8p}tu`^%-Ue+6iLr1@3&aZHnMv%CAEO8c6JrKYlAE=z2)O-=oDj#QkX zG}f?H=Nl-sD*cI1mGQw$WEECAv2K0q@+VCBc}xoc_}W`XOjsM~Z+uo>v6ymO`5u7r zMP84i#lRbV(kgXjh@A&wm{lPqJi8Losv~8^A-S86pIfQ`t~OT!zNUM_2lTtkG24{i z7*wUnoPsj*p`Uvwi)Kme1fxTBiLd6Q_-k$8ta5y4{2$6c!7jNJv7I@gDOUZmNVSdl zO4%$ZYJRlkk2QQt#sslz@A8nOHo}SNbCj`%U^L#VK6L-2~ zL{{|2JQEKO#ES4#pfs*Eoj?ooNTzT##Ha*Yw7~3nx-F0 zR}WyE>3VX)^hI2kW^qh*GYk)lVM7E(jSz7%TKQNH2fZeFARvivC=>LrjHTp>_q>3u zslzmoAn%?M!{WsXwTDY!*#z>o`c2_-HsM?hNk6Qmr%De@k1dHF)Fwn#m zO2cvLu;PD@;lcjD9XfwET?GsQ2E!vWn{Zk$q&vg@khrZAuOUn1y~S zhEiey&W$cbmW^oa&DyVnB~3H*$%-)y9nFZqXVLqW&swHt$vp)$*uSI{AB4SBVC1hB zR{9Yls`4x&m~by@nEeWi0`#M3rz&C2PH3VlZ{N(F1zsh=<ixC;e8Ne4Wp-VDYUfi{B^0=gd-HC zG#~sENNY67Y}`;6lt%#4uFJC6Y&KTBcfPH$SDB>v-U$`g2#Mj$f6H6Tvgxb#p0Zb; z*<1{5oR1>X;aV88&qv&)1Bw=Tw-=s1j{cB*iq90We3g5hz}IVHw_I1lO{Yaj*SK0h zN=&3s3kk-&@`7emZCwm!$B(kLViSrSn3M@3X7LXwHh>=ZreSRv>MfOm6Q&`-T>vH` zc$R@pj~Xa(~If>*@qK? z?{RiyC5qO48??KX{I?tVa7*L)~F025JP@>EwcjH`^5Eq#UaD!KN&d(Q@-)s3SIVDNDlpVt|l}PAO=)U zwnNVIqu}Fe;x(az?B8!sdtrQgfKWkAngmThx?=Z|b`UV$EyoYQi4pc71p`Tw@gT@S zep0^^A%HD7qlVXK6bXXNsi{^EMM8RDi2xON_GqxqbVnu9=w5#JXMpXwVQtto;G7Wq zdal;%0k72MSJ7JC{vqrp2*6&i+E{thC6xbzw_Z@}u>|n79}Eeer@me$-~E}d5o_?{ z^znz+rr;bGFRNs4Yc)J>O9L66mrz1bfOq@hMxKo^Q|B<0_frC_Wn#P;ep{!SxM#e~ ztPR%-e@?kg3Wt9#qJb){Hz+P<(vFp+foo3x7I{aa8GS_Scd9YUKH-Lij?r1 zk5=^V^YDN;>gQy*7sjfnO4UYo)Q5sMJybbNzLpO-9>wrf2dCk2nyedvS+b(_(XupCoT-mVkt>be{>-p7~6%=_xD zl-n@Az(v!S;q*%@qLJK=29%l z(+jTtaF7(~!&~uxNT1Oi7&)z(h&?n5#b7-#{MC&pBq)V3$G<-bb*gNL4h3nCg>G)k z9!~p3JT%E<`}%}skR+K3Uttg)ag0~V*LqGG5k-w|E%+6Ev@ z2Sndpog6FVN#B;+a32Zvv(3mLo;N}ZQT5?ND%U*9vXha?FXZF3KGx(ckZk8H0vD|Djm3h zhHG3al$L^#2xl!OQyr?|l1O7t!u4oSBt}`1Qc!Kjzdz9F^`M>;^RBuD{c(xFRv@?- zS$8EIN9K$S&4(m^Z52|{OY+(0_wE?{@^?8uYiv4al7^OzIl8CoQKQg!zlZ1u&I9DJ zUS%cpAmp%lak6sKE7e$7p8Qu>t4}vC(!%o;pdtk?WnR6h*mpv0^_{=~1uK^H9weVeY*;`&Z*GgW-oIYI74p!QEwO3RR?d ze#>D=Ah)K}*>~3@W3g6IwfCN}3vbcX%j$(*2S}W*Qh(3x<`RQ7G!PNHEAdUpFjQ#E z>OaK(B9_dzekXFRx9Dq0Oe19`*{MhWg?TjC`y)BmR?CinIn8eeH(8~*pI@k*u?tTd zRxH5^qZNC%ek=<2p@?dy+h2R;RT9+U;jc-2vMCI2KCum6^wL>>MWAg^X;|8;zWItX zL)wPPjvU+Q{{~>RW0xIHt)XH|ra}t}Y)?uE5t9muzkYcV>@IZvLUq?ne8h~C)7BA- zOeT+_gyOb%;2B?#dVlso9%Z8TDA24EJ9{gG)-<92TJ1VNR|8HZ%gJ3o1-yHJ%*etR zeOja^->L-X+YFB#Pw!JZt`r0S_wlMPc}wtG+)DDU6?YW(+WWT+pSx{-^225EDmORk zTcQTZtCX+MN8%nFJgM8mj$7tzJDT}KLUxy8Io!_x?on2i*=u(5W@Kts_-ff{?uzGL*5MuMW40TzQXR{R6{` zm9t;6Ky}162~={OS5|)5%BXE=TY!}?kD|`^|7iNksHocS?K3b8LwBb%NQ0EbkkXAH z(nz-;(hS{=f^}sXTb~X+6V*+zhLqB}E+%tz~d>!By?0ezCCBHyNA0_M69A-E^q#=HO|HBbo!PcY`8?G*k%CkWKvS<*1h@qHwAEl(db)+-t$CB?g~5 z+>SY!-VIYX46`JqB=dsZwW5q2B`MeTWNBI_uM<P*-)mwy2UF8JjVML ztBulcdq5c#TdcxsiHK(?@ZryT@4$p8MZ-Ad!E2UEz_xqOiU7dxLjMZ+c;r`pBVD9C z-o=LvV3v0)-yqM0^w53P5>t{os9sr3-%(;j@jmlp;zXld8iL>B?I!RAD9;1fYXbb4Rpt`V(l@DP@eO0*RUb*Z@NX& zh}{kO?~_Bv6N?)6D*m0=(oh8q{04i&0odd^(wBaaRG`Ix%Mmx`FQA$~+BW*uMQ`dzL`cmHpv>+|* zX(M=>z>l{CS^mGvylzY%gtPn)qC~8Q!$hAVmBy|@T+xdokj&QqWO77D`MxJ5`beb$ zdKLP8M^Ccb^vYO<*Vx7(9}GqmuajEEK|tV(mSgTLoZ# zpRgmboiUW@5Eh)f{M%vaR_X3Y`imS8b&`5F_yXSK5ZV zX&b?pmob+CV6TSA6b^Y`YCsbbn<1Sk*gxXPS+OO3325RWmmF+srT`DvW+6JZ)VO8R zR04{4Und$Uy^33G)-gIcT0dID0jgEdIfc)yX)E)-2H!y%56fCkuu1`;&qt0 z9-Ud!g_CzCbKA4%OXR9leV}@pAi@9M_ib0Xc3Go}B9x0BOh^wN=3L_?#}@jB?$oi` z?6&Mw`^9eBW7#=j(^dpzGE^YCAhO#C5eW9Rm`?WUe`}jBasU;uN!l!8KW9lj8&fiQh;ApQ{^!E$L-F~+= zF8cUTR+AvgCj&PLwWfE`|Hv8r9Pd^1UHe_h8@$(l1$^OO)}bLT?@a>;7Q+HixNdWQ{TSEHimMB|c1z+fJF4n$$38%9~|Eb%`n|Y4yP6(5y))=gO1( zS;EuBoBOMlZW?5eX{qh0E)D3iBs1NFX{k59 z^Xwnr;04Gx(y=#qMlbsA{5tfznsFBQxh7w`{=ME8@pS2_I)3ts6_J>C zL}UyB&Pf6GqL&DOyax7N$NVn$zwc$XA~4pWbofGIwpKXa3^zwdI6-H(%d6CWxd&l! zdFDss5A;#y1dfeg@6UHZ)~|k%<^8_cyyND{bNGy=;R#Bdwo)pJ3H=jI?VqFwtQdSV zMLF7bRpbIJc{KL&KZ5{0dyt|rme3oSviZT^#VQ!;oXqt3>h)gi-3kT%4(4!k7u1;N z4hAP}B8rwqHD5R05uTAJyrGt}8INOfYqNQS=!yZF93Ox8=iK(nk2g-%W?oN`^@K{@ zZC%m3S<>^GeZh&0ToTkC2AX==tlNg9t(Iet{FJGker8OQ0`~jghH>jm_d32A2OZ0Q zw&L4jeKw5uGH~yna`O01pbUyFtvdi~@0sEwp2$6BZNuTXTT^LHO(6$T1Tn07 zeJ5wX|3v-LqSy%qnC?UAe1rsSak=#4_1u3=U!R4q8^5~ijJ}m}JJoe5WpK?PK(5lJ zvCS^eI8gM!!;G%yRJp`gv?9U6Pjo$Tuk^#o2T|sJ(8{4wfvST3K=2{8oOIoX?+cWo zsuh~0(Pnw-my}yPz6wNXi=P>$`f!YV|N7=dRV~^6!^)Kg#z*Tz{Y-m-xTzR%Ii_MV zDp*6%?V7iUdxI-+_R!PqgQSpf5Ti zp|q%_dvU>KNF;mjh{9YTrbUuZz^iDf15s-%F(qB)ad}9+PSG65`$4q)b#f-0TW=%E zGxzd>zw7nBYf=O`DwtXjKLkhBVtX4s`4}#QAxhmJ7>a5p}fUlHgsPDmXMCQS5Lo2Ba*MvUFJ`N z-{!_Wozj`cIMlRbyHWSe5e{-NpSp2SEA1Glz1s72b>tr;(kuc?6hwP$-U*&>E@}TY zt00B8?Z1ir!mFAQcpfb43F2$@>u&o-BcQa8htjT*>Qm4~#V(&~B%oV=qLc0|*A1Y8 zMVjk_!#?R%`uK;=WDpHIeuMgcRIb?tXD*0i_(@4Xl&h6S1!}H|vKR4I)RH8o;a`~z z-WU#acaim9F?8NdJmG#^^L#moUrMnZU_MaK1zrOfpn0=2rF992tNeuz^q9(jqfR^+ z6|{VezIx$*EpdH#m-I@MFx5Bj<0>NgaMXaz!+^L=&mKz?(nS|Ff(7|MJp}ar3((E+Y7LY0YO;tHs%8^ zalHN{bQ9_zXLjk(HkDkh9p)70qS-?|@J|@HJjxRiZ1ATb8b%Uy2IZu=j&0pV40w&X zbkST%bOPg!`Ma}u^CbjHxx!Izqqy~}w9UoB^eWmib@SKDSp%i7 z_dc)VECHC_Gfu3c?HWXZf*wAkNU#`IMWN}0>fW&1hfu23*i^1& z|9$d_ zr`7ApJ3TW)VH{y*kD;m-)Z&4nJ}#rG@NwI`T79cemi4qf*B`a}u~+(N&7E&-gcQ|JCY6nrY9q~{iaEUH2qZyy*){VK;KhGxdZ`idR2zwzS*3LWWIo4JpR>NU*k zk(a}c{^3V(=Uk+!oEAS__;g+m@hA{pY#M<4CgjJbCQm-_O&2#~(1ol)49C@))dJ#_ zz_qPOr>pyp-^jx{J6k6A69}Q~z2I~bp{XP`XTg%_<>KPNTm2Y0e7$-i4StYn{jlz) zRudWJvD7ub4|miJ4j;DcwW_qn+mDe+`A{q{|I`QOKe)2OC^q;n%L_dp2^iXIW5S@Yz8e zCFHq^t$I@u(=*8Gi$+RZ$k?mX@Tb!OuS+~X;p+AS4$oTKKi-C)QnG&HwB0(A1kuRl zWjMZszP_CMuX1~m2Bo}mCVG{?wb}FdFLxs)w*$TSMaZBdiZvFkUVq9n-RGA-i{GVi zXp4@2Ce&=}6|n0QJlkvD)8b;LJ}3JwQHG>YeiKzi_36Sbv>r%c7geNJ)XF8566i3W zw=bQwUy-FtVhJYeJ-noVl50K*Tb+RPlr5V53OSgD;ADXpiX3;GuSa%BIn-oaTFjIG z7<7yuY#RxX!Xs)^HRW{6xE)D#dWBokXC5%A^efrgo@ELuwkMvZ}Z&i->;t-lXg&99D#<%lT9U=UeFIieGSVq$p+EtLZ8KZO%C0pKfx~jBxSzJzG0NeZ6U+P%g|x26iB zbG-vVxRiYS{BvqYzSs=$sK5+A?C%pXHe6d(0}miSndCODRZD8WKj9~IrNoB2fB^Cy z|J?F-U6-%`RdQhY=NES>K$9sgPKoB_d#rvrpqGkYL~@n4uGtG^`VVEWIbM+Ppo7Uj zi~IA#K8Y^{wpv@zdY5 z>(M)@gayZftqKG-R-N5xaAw)#3?$E?R`t9c{T^Q0y7P9%4i#wJ%C-d*Ptx83p@j6s z?-t$i=Bb$=murSu?+^@Y5peSiSLz7%q4v$*yg`Q8B=EC2vIK^6f3QCp-(+yqdG<}J z4vC}(Dk&{daeO-4+M61lCIAi(yD`>O$~O{Tr0b?bzM7$IWF|ES{kI5eUa6g#3INjE zhykS4>{1PZ$oOg|>7{|3(Ao9R+^+{3P%tLl)y4FI$N#La2|{h!&ISwZV~&DnJDdVl(3vHN@5 zNTBCtICl`wZ~bcX-rqNm$Xym2VQjiZ8jRG-NgG}C3srxAP}9t6`LY296gQK7;#*8D zY|luA5{4ucxp7b_1H;CM*k0g|HqHI9XqxW(_6UKP!<#noL+Iht&h8bed1Z>aahp8V z{V~M6nL&MAY}gmPs{zvoyt#*M1(FVer7Wmu9z7OVlB}VTB&EC*&l{>B_=l~B0xer1 zCwII)6-E9aTMrld)7}_O2UY3m7anQqGJM);@T@x75{3DH$Q{CUPW||2F+AdyX(R#G z^jSynih9$7-xnqnuQ{a9HsHR9v@#0rSvU6E%^t)yzOK}QYA2?Fv@l>5#>K~(uXAX} z;5xSau+L<2Bg->T^W@S4r*MZiGn*azntX~;rB<1xLJnMuP~DW{AHxkxZ%KkUP~OU! zZEa*TASk`A(4Rnor>{y!!#&+w z2mWn>Z@&aQy{DvN|FLyRVj$LDGbPrg{&}0ovKqL3tpVL#7~_|X5VR8#MhoXQV}LV; zLvJ09aQ|A?JeqfIk_AB27=!jBY>ob9(ct7~(^_6W{&%rA$HQKnl_zP*=SBB!5rR&x zB???PuCG}o!&)Bua<@*SNF1-#@+48G`4{V>!9NHIceP2XvqrZ2^b84HG;VUd7|u~V zP}ULWp7H=H2S{e1j}$dWbqc+&!-NS7OBCooaVIgZ4Gf* zMOS<|3Be{DGM_2&*bof4L>|iadv2}W>fb{v6MsJSANjI-|7v>g^zQ5vGDx6_5iYGH zf3fFBj>yB4Gx>gz7k7a!i1w$9y|sGm7Vocm$cRkC;$VZbH*Iy-Z#%Eu+((M|Yy-1V zi@i34Dtgd~dj%*8^>g#|ERfC%e+P>!s%lQ`kDh&#-aH*LA{BIsIX;rf_a; zyzAh>BsVB!cEEI?`(d~5PxaRP+PQV-?W`1(`CnayafnPcu6aUGjxMJ<{aWyFBwyEd8GpSAyw1wxdkp6;d-D{8jarvZ^=8AeRpKh`ss9rv^YEP#8 zfP=$+Mn|LTZ0L_51wbTUT*R1r5g@OU5=DUA&4I7(ile$Goq1;sW}`r6&#Ec1()UQq zLSA;#7t7OY1>Ye$8g+YsFovKnv@G-B0IDbL$1ymFy*S}^ll^Q; zCk>3^9?7BsLjj>*cz84(^a5iR4@6mS93Eb6e3avP%irU?D~fI}QvJmj9ucv8*Pvj0Ix=ijDmAuv-FW8#L0U z#F^#2}uUtcwCE`zSUF0L(Rz244>Bp0YKrP`i!|J|f0Y2&1QTUu}Z z;;`PlQ+*$m2#O;%VpBsn<$f0q*Fgamsb0GEbG-lqp$woVLM@l4$sMc8OP@VE(yJK( z0hWe=BDZ7|2&QTb`SY?`TDp!jt$Tsq=dV%76~3QCWi6Ps$OwaQ*&~aUFsex87UbbesMa<%b@%Z;Y`u=Z%($#2I^C3to{!qFa z7$^cXz`(mS(3c?kChTl(fY(!Mb>AzM<$^t^gcEn`?^7l+i1ilTD87e!-L132FLeD8 z0HSUP?@8UMl36U6Oz?2SuU(#QaJ(KIOoPH?@lIX(9vw`@_Wv1G6_rDE3B0fwHPw1Y zcw1+yE0B*1VkjY&EirfTHNOI%s(N}KBJ^t;>mhrj*d0Hy+z3&bV%n21VBML>)39dUfJ+D*l!6H^_ zcSMapQsk3o-R#d%&}%aSG-ELBqrT^2YXq1a%SzujeG;aQbdnPyy(QaXIF}$@090lykZcT_W@cCusZ;zNDaHCT)LyA)d z!@xq(Fq58$g^!yvAI~B`M99u$QLYr>853Y5 zZiJG4Iw8w&j$1)3t)hp9mlY}r9aA?24TV|xo##pgy*$3>PyT>1>lmrotBE7$s?5CGA2|e=(w!+M>43PK?J>0D@mI|L(6ifE4qEY0Nnw zRCujRPwN3YyJt&u-lB79>J}$EdOd+n+?5Qj+;Vv+z|XJ>O&VE}pp#xGHm<*tBNWT! zF6Jrfs_QAl!050~pK~Td=adNNn0I6FOZooHl}fW))cPx8NERYJ99O^j#aPOnmiU;& z%1VRLLaDu>N$-wIw+FLu6zEjVSjbS$=4-C5Z6(aDSi7n`C^^tjmC(O+A*7tyD!L;p zPfNqA{i|_twSF=2$RkXdcN^1N)GUPvevYZHg>|+p(`a95<$Z*=tYg5+mH2Eia%Uv4 zJZHI{UD%ycXiHW#PD@_!se`zNPe1Y;nx1-^(AE90uR(3{?u(Kt)|5fs$bzXQ+@^hX zaBcOk*Su*b&fUKseZ-QEvO#6HNA0kub*c+9QK1GwsH-Ic{Y7!3P8L*z(xr}#nlMvW zks)~jOg*)SY3+ZSn1CV9(PF@wk2T8+ni_;hsc^j_DPG$&jP-q z(&HK2vN3_*|Ckf})*+<5k!8hCajH)7qCdxCm{PH31d$ImCO_qKQrN5E+GuArju!4Y z+dW&!%o!HruZqbr-k~NsIrwjm5tl?>v|C$5y9aOotA+~#!vnuddSAl zshVyAAD|b-jRgU^XJ5-%SzW`144Y3sGdB;jEoeu8b&g}?=j(E=NAQ7B(ukokKP2zn zWaB&Y7zRcNrw_|mWE?a7`)($kJyz`_jgw}xOj2=VFqE$|W8F7go()_tl+j`uy1>(y zE<;$(-YhaL=*;_tS_0+=;9{J7N~YE?yuKkRuFAHhpF*-lE!Z-^c}iy~ARt3Sepp&n z&Ob*2GGZZmuvS+v1|&X z089X951kSK#efgaFwtTd|sS)YWa1T1r>A;u6VC^rulL;(#A z6-G~4kPppf(S-!aVDu)P@Nm#*w zDds!>7}v$rS=AJ|KEdH&uhMk@p%-_tYHpQN)cI319erj`ZeI$|Di(p4=9PHicVYDt zPgDQ)=w6CA*o$WqwjveTdIY5mwbRf_ao5aJ;mp>sLC^Bh2DHvCjkgV%u^7G{D%M4XFb$V0)sY?=?*ofr?FhNP|nNfL|l9OUsGe0eW zub5W`3eQ1+<%o(Qc<3aJH1xgvX(KtXi^M-$JdUV=#6ZFcCkPxH5cxo4zQKs8s45U@ z6<+%RZT)VwWkafGAE))cfAw^Q_O0NY>XhZF$Rev*G~p**2#=Kv;+NFVZ0d79K_s(m zqN54~Oa;UyDQ;=ij^K{b1+!k8_-c~g2@&ox`#&@)Lj$mp!9!J zf_UvjuqK$kDKLL79{Kp$0j!Cw-~)2h5e~|iDG2_;h|1byJ?uN0ETkgbis@)4WOzyX zS-o=O=Y&5f_hH6(LF622{hPb@yq`f|(?LfEp=Bwp8ch-e)W_|Tqrwi77#>3Y{O-U8 z1_|jI>oKqP-Y4(@->-;7b9N~K-bJQjKYtPoO$)pcZP?gEwgQpt{44RJlxQ$<#SoW) z8+Dxmdfj_%I9Immwzae>dHZ_Gs1vy|7d^ijc@nKUgx{*2`0Li>7`N#1dKV^X>!SK- zX>%*a{#8;^TcuUFbnFlA6hQW|ih%D|FEXV)-FdFA*#mobqUG$f@FQpU;i9m`x<`mH z5bH0j-!`mHrZ9U~0Tmxc7LgrG>J9s=|CZ5y?gXc#J>0n5o~L(#7bVwYJr7V18_GE2 zT}w+#R01ogClNh}i!hZ@OCanN&c9>K?KOH0$A%`-dD(#)2QZI?KU&Mfqzzr|%aj9i z7Th(Q1F(OzrIO}No$WJzI`@;;H%T7D2sJu;>4X$LhTrRNQ`>>qsDI88;tg$$J0Y$q z5r$V$Xuv2JpveFMP>8v|IP%$a=I(P>TfM zJ9Ir0x+l;0LodVz;-eSD3hoUtusV=>t8Oot`IVHSOk3j?v;(g8^Z)iW__jTYA~C1o zvvw9nh#R`eQ@-lZOyTI~2k5gq8Jd{l_;^`bC9~p4 z;ZqN=l}mw?9LZ$G9?xHt1N z|0RXLWLUABU}|`Yhr}-D9t)jQfQbc1 zAj+06>KQL2eUuAKVI}#rq$Kg<+*|eivvdzJr43nORrTNzOx~;3uq()hb{Hu`Tp59 zHd+WrsnTzTXQT4kA<0?gBcf8>=lA*)#EUCzWy(a4OeC~4AYk7;^M$C8aN92@%v-Ir4V|~@xZI6D*!QAwYj(~3KhT`qrt}O?^%MF$M-C}0f+g|hfujsT zafYbQinx>VxCm|P|0E{7GV4xeY%T8gZJ0l*OENLascxD9*9>7RSu%KeMiD4uRtXlp%_abuknFh;R^K4N}lxVk3WKo$|NYw zv;DQ}5!GMY@fgU9e{x5oxQNI(*oQB22|C{pH3r7nGAP~NeN6*7F6zmW+A)3nDTBJh^Qb-*zOxlzssio4x}RusO~IL zq`NkFB5+gXI!@|dpz;#MgAvul0#?ctEw6;mWmJ00S(cITB0EjLv&h5xs}?-)|`zLQO~34dTd3tST501do=?zB9e_uA5HbW zza|?7H>zXwi~v_#Yf`zPU2oD$G**gKk>mm??Js=VXLRA1w1&rK-cBTAtGM@Up^Nqc zc?82KsPmQ%qipVIn`?pN$`yI*OaT5EkXf|!6D6=sp8k)H1_dBqqhqfw+LK(IQiZr{ zAVu-1ktUhC@W#|up#KbNc%BS~thx}^vcR1d4BqT*pq|_T&OLuTnM|NIo@A(ieL;U6 zwU-t)v(~j}-G@jbP4RNgpL-f;qgEx)1PVs0idtnq#o-ILxB<0UuFA=nmRv;j_K)fm zKnWHw`caC)RZn0(&V>X}^pP-f?F*=^;jFdYYSNebQgfHY_=-Z09HM*k5x;_zL0SxN z8)_*#iIZca)oK#o;?aBlhy7voxg#OKj@q4h)#0JBMOnFXi0r1mZ+^RH;|H?A_DXrl za;O86Z<3Nb^CI3fgzSnx*x#S?Jc@JoTv)UoQZ4k@;07(fax!JU7T%w4@#%{0U5tgH z0a@dF`s>F3fuf%Y47@j{Ej*vBu5W`PPCHxzS=a7@0;Qo?<2P87RA3`{yXMS29N(P@ z6$X9&!na#FcjH_ZklwNuC6dz-DuDd18-ojLR}2;QjT|U^&Kp!O(Hy+`40WRt6*#{X zQzL`(0rVxuDa?#-gcxK9Lv7WXHtuMHH-Wf}A(F>%%T1b|tIXE(Xcf0b`CWGl$y1H@ z78YzX7P2Vsv=dwzE~&>P&(1G5WD35k(_WAd1k<${7@k;I4Ght|ikD&ihJAi(_i|wlLLzlkRF}FbAaKt2Qvb3Xsow-M{;^}B3_NSYuKkAb<=J~suLoVe z%-MI}W)KY~>r4`h`jsg38sBDkZ7&e6-Ss7Gtp;96 zQyZ`J&fMSN``~0>zQ}u>1_0lFYZQo-N4-6WQhN)~$^vlDt~j0t^|!z_+K2Mm?ly@@ zLx2O=xUgx*S$?W84kMHPA?|H5W$!gs1N7S^`49$3yrFjiM4 zl-Fsm&&o8`f+kk~iMFaZAP&{!2Gj8=P~j-dO=h9(a(%2Y%S^`4@;XiYeIeWAV;Fns z(X`KSGo7^aD=B&{ibX3nj#_|GAikIg|5G+YQ@sj<*Gn|Olbt(!Rj>2>D^K6V(+`c! zeu;+49hhh3t9a_Z<@J9;D^H^Y)TT83u1lK5zY%}IqA+V`KzpL6$nM#)0Y6M{0s9+7 zlj!_1MIl5`TzrK2wQg=?aG{nUg@b)61j*NRzEAl9Wu?oSp03sz&E zFWTaebVYQ5D3DA5@v>8iSdNys=crj?PetW{dd0{6+tCax?IBOY50fXCu0uO%8-e#% zPr)Td{(PPj8inho3WZ7KsSUMs%xx=lF4BeXXfc9$;mntbBiduV?PLXNo;jLzYs%w_#32qQ^fye!z1=|GPkbc`v5Rf3p1~o=g!5X{1 zw>cRkJ8s2#oFdd+4KH5Gn{&`pGf+iLaXMpY=DQ^b(Zy5`AjWlj6MsgqGSrE%5ooMp zF!WpUV~I7pjrnc;oo1qowCG*LOm@4Z;g}KVDmE3)+Nxfo)$<#{@s>+?)QM&B?GAwy$ZTXG5K5C)c4s z7v$3i5JNN3y92DIAA5tuiR@21cVEkkW1A;yfDTVb-1lP$ZMA3-nsw!5F(QP)*YzBb zoywkvIzgh`iiIVDoT~D#ck^hOS!j?QxuDL9$riKuPMGPH~KpOL2CoyP7} z#ea0128tl1im?W7$Jvki$Uz**f*=mM9Jq=`M}k|~fsbj|9f9{Ep#{>}YZ3Xp()N3u zUh{`#WCCBW9Vyaz^3LDVL~%=m1F-ztp~I7-8-sj7l^;Jp05EInQH;q2A5&%5^P#<# z=Kw7A;!m^AC9jka(u1RghZ>O{Y4Dk2d%cEHZdrSy4Rt1)GT`pXPr%v#xB-z5D(L+C=_TJE7X!9bxm~zjl)!^-FFZ$XQK_z#V+($c% zfEp}YAwhiRWebedLDs5p!GW(1G_8`X_r+HEi;e}$Cwx>vPwbcsTzbi@pCcoQNR2q1 z-o4ZpH_@Xgn{z*}-v7Zl`68RhW`ZttO$vi|nH`BCRPkwPz4@!&JUNCwH%G|xLw!>& zw@yGgdOWocb_8b%x6CB3tcD5b3R2;&^g%qB`;I)YbgW}r9+v_Kr)w5M%D%h_9pEmg z&8*k+;7OGkMDZw)<_n|due6EV=nqhqguP{B%Y+Ib<@-+@3V_)DGj!pq@V>AVHML147 z-#QOJH+lcO+V}gX6-lQ4XT>}LCZAsFIy7*gXq+Nh4c8a}po)PShSO}~pv`Z-gTIy_ zlIN2NTN7)S&VGLuAdSP^2A(*&64)uJ2R{z}iLrB!snsJf3VPF647K!KF=XrwOw-ic zjcTl>2+uJi@oR4K)s>tLa6}A{Z#iUXL3-Z-a4(*rapxDCG!K+ZWF}#o2U>g0;h8y? zs-2Hy0i?~A`E*s!=$9o3iFIFG%Y^NEXKnwtRJc^P3zX5$zp^M8b#M)VJ_N2@D0qExZ?f!q+MSSzHML*)G)^!V=Jq4nhOs?hxi zim%yU&WAMp;(DEFqa`!|o1P(1AV67vwS8BrrY69f*iOh4xi}m2lP^2YGNX_X=oN|@ z^xW`=lBe<#4!~zFqCoV_MM$cDB8AxdvFY^DLTo@4U+?QW1_WTE96a85%B+Xk#*RGT zG%n$kM&ZeUtHJnM@U+f7wCk{3ADo<<)K$cF{rjmy-|UN=K7!p94{J2IhN34 zpg4|^HO+OI=Hu4Sj-J?#W6{{3y(6~@xlW>0hF!hT{P+ZZ7?JGrrds(7@P?xBLWWZv z7TQZKIlJWCL*rWOp$+gjifR+(sRS7)yT)>JE;BcLcg2IT`%rXZ*l~JTbqv%G${>3k z_4IMdr$TJrXmUdP&&P--6{<&I6 zei+;iE*}B25-z}o7GUe=NI|wu(F}5Q ze~dc_E?#eX&iC#}$(z|C7@vT?sZ*(xBamoSb>*30E5=gs+>qUqOn!AE;RLVHUgpCGE(7+kw0C6jEIYo zvz>79;(4v_HvRF_Fi8A54!yRs6{T*;%BNes{5%|YLanFfbw%Ba<@XUBy;phjtMw|o39i9dQ65q&?5MEfdm?(RmF>e0E3Dq;9=M$Y zmXjS8%ttcZu9!5j55GM~v)pZ(8= z``1V_;~*62WDZWI=sqqW%6=o}igrL2Jz?`_$MLW%-bw=Fh<11^U2=8_=B#~c!Wx^q zvk&iB&Dho|9qb#YbX1+R|2;O?%85beAHEy0#Z~p2mD9$OUttp+B=e^3B>#R2n%iR7 z5B`mkZT#8O_MN1gOJ93d$)ELAUdGJ&H~k*5D`Y7_K(Xn%)Ml)A-E7@x0`IGPhVIs` zKprblb${X49o$Cd`EwazRGQI=#l|atYrL4(ZJ61wR5c7?#}^+&J1wy`v|{?_gw#?v z?DtUu+_xUaBx;t8EXzecOG^F5;=aoI9LP`TDaPZt5@NUzO(J?A`*Z%vVCq{wD6v#3rnZ6Yg6w{4pJ!Ab@Rg zcn+qXt(`NZ{v-YngBlf)X?kIC)N_H-c=f}qsRfru5s_mj+aTzG#4I>V#Rjgpl%)X$HK@dpi%@p!1UYF(T{!Q~8w(3>(QLn=GmQ-EWh!PB< zN=@HQI8W!qVU?#~P9&(KNq#T$1?634E_t?dvrboa@$BM{IbN+F#4yd&#u8Jx$bNEV zIpP2uo$w*g;1D)=+ot(hknEng7x|UMJ-tsh&R#Lva`Ts_$-_&8vzoN67(@9sQLB0K z*A7KvQbN!NgK$k%iyl^8E?gvL)UVs724AWwV3R&lP-SN=L9sAy3plpS&Dl~vN%*q3 z!hWJ@d8N|U&m#~&%yRmBk3)v$LVvC;VN^&!t$_WQLq?Di_Y7sDO%^i7XE!Go3+4@46vLAc?lt3ux$JH2^;wE| zT(EVY&$?`!CKZZ!?cs|{4I;1>XhMKalhS-^HvICSd_#yF;bt99sS_KvAin!EY}vV} zNR+8n1h15Ej0$&mB3-WA9}=%~lC+I;WE-#AfvfW(&jIsd@RidQ&P%ZxrvU5nwEtFG zBIw8k|Ec?Ffo_EEf8+Z$6d#%>^1-Wg^RHKO1s3Uv`hHMKW6D-&#Xs{e~LwN-Z&LZdi9ckiwrac#1Y< zn>T6Zub8*!76Rrsb3nQh7f=sfUBEj=4!La7dc^6)eHiB3xQjkKuNAksfsE+fT3 zLpnA6$Ns+f85peoGAbK_G)%^2=tkv;7?FlSOZ%>1Ha@cq7S@(5vXs3y`%9mmf6vG< z{JT@&t4qe^Twd8nkE7X49;;&mCLMmB97NpvaoQ6iM(gvVFWQJ0QNZEqiB92S2AbZC zA$cc{465nr2w<)_WxPo3}QjN`>d~hc==*f#H|Ah_ zf+z{iC)l>)6=2f!ASbP}By|4rtk=q|DFkWrM}(zfU}QL4H>AhO#chEww&^u9pXJIX z9Zzd#&(n5*7nsc-VgLU9yVhl0^2eX0u$Z6gF=d`u9UsT@R_#G+Vuo>)7JHo&wtMFP z%Es*R!!ME4#->f154?n6l+Yg+qWJNIjJe-C3kT0ZB}xS$nM zVv^^Q##iIxufKiP49aJ(l;WLr6kuL$q#{~fn!uEL_B{niM_74xN*k;(#@3N z8P4z}akhVhkBZ4~yYqNZ< zS|S(o8^2@$7}PjH8Z-1e8jrZ;Zhc5@?m^Z6M3_U!?)i}u(sO(8Khl~M%5yw_nM8hX z%YWio2IRlCav!N5Z%-sceY<@CANTN-C#cVDX)1dSGLps_#H$&faC-L@i|Oj#!MgZY zPM1n)40~i- zlt|t`&wO)fSCVX^edkRrmC)zcpXn6^@@gr%ix_I~zXnWOYv|#SxBtYzU^WI%kO;pi zN?dAaaRBjJQ4J&O)td;_aJocHv&};tO5d|PMf_SO6ndSXWlr>m&sc?E-+_fA_B3Pq zcS(Z*h!DFkop`rv-N~*yIV%u0Nt`j*k*}sdUAtoKm=Hn}XGWLC9Tj%hKd(tANBt_r zfW+ihR19Llqxlv_4~Dp5=hCJ?k-N*1`4Plrp73`r@z49c&Des}RNIO8ed?kSwQ7ghrt)IA zx;VttRg?bJF|(pl9?2UiLmgh4w3=!z9fc-1MHq>u8kSngxS#}Jx8SY;LU0HW+#x{|T$g)(dhPCO`2p=ud z=Dv&cQcZaW;Y>i7w1U6mbqAo7+$tJZ$Y;Z+;ll{^^rmgFX_ibJPnMzVSKbUQ1-koC zsZT%zIIK>-VuyhxtZ^bOw^QxwN9OZ`kyzV3#1c_|6cB)GWDs`IWj}g(9^!a*)&=DL z=~EwQEJ$TTF22fnNRO*eD~6BzlTzh(Bkusv)+CU3`Ox;!g^tocm{uRpDn9IqSc>Oh z8goA%{9ME{2Uft@^i^W8lzmr|COc>)sd~A6_{q7u^ar2zAIK4XGxvDeC zq8nj>yJ@57!pyBmxP|`C0=kB;aRNFW47f4Q%c8Il;Y25dL2BqMD7xe~rJ=5e6ZBnrTTbRk&66mE9b*^~ zt}_y6X{bjoX}7;|x83?4pAtiInRmet$xf};=xW^(WB8PTKUP>Q-A(dyeEP)%R?y#F z2A|@0+4Kw3Dvfld@SXYog-Bpl6aET5JN%matWQ-^kSKhwr2;!CL+|x;pXcAbWNeQw z=aTJPrVmZ;0M2uP6>s~9xAQ?dBk&uY9OFG%MfKnPizHZ3?hJS0ci>v!?YhsQzi6~j zpBMUIwxl?vMJHsj;eCQ|jK~?A6#zP1yRg6+gVz}P;4*Pd_0B2IfR>{5zWl9_%2QTE zs-O%j9oIsz?1+JRqvBA!2qhM)*v2YT1|1s_`y8nYUyPZ0JW7Rw+baaW`+ZJB$Dps= zY;|gkUUo5uiM1@1G>rWkIOQtKMEBrzlr3?QaZGI^=1Y{*uH;*SWpF-kGtjV3Q> zqITK)v{diC@%C_RssWFB%CIY=v}}g!i;Dq(e0GA@uTm~BJKViUxfbT*j1|OP>G|p7 zMXEe$%<61AYgId)f7b6${G64z%|o>LC57BPXr*#L zJ4r+xpZv%Za@rvV5LxNG++PWMNM0$6hwxQUzF<}n&d{;71)Nc6qH>gl+5deeO5}>1 zR0J}DV$z9WCmBJQ_KxzHXJ+pm`8J-5yi|zr>AdEF?{Lxzd+q8KhlU@#G&4ICGUCyI zL4DLo(d%}_pIBG7yS>j-gDT>nv`CH&LV(VA7ZTas`-z+Zpd}EB56+#3`?pB39Zt3` z%p^uxXLKR0)UpW=a%uQgE~Y8+A6e0s-WxpE*kp5<%XNl7u z>4Ac$<*`{Lytj5{E@gH)= zzN7&M`i)u<-A(b49?A+nFc}C5l}+tWgq`AI8LlS5pZLuVM_Pu31V{4{6g_sGVsqSG zEHHMyCpH`9~hw#ZfJ^32ub2e zTIR)_aR6i|&?}*%!q4bi0-|o7eK-Nw1efyP{_0W?2@x^{^v!OMQdHC`a4CInApsLH z9o4YzMB3+t^Yv`wUC!aH$4gkFyt%nP*3}JPc-_4vT>Y<*sr@Tt5Rb+xsoH-O_8|tp z-&a4!`?z2ySl-B-gG~sF-BeBTl+e)p}Tt{w8ue%+5e_6^?)n zFYPDdE?($-S9)9R>MQ%nG`nK{E3d2TTZI$;5w|5>kpZkUowNQdQ}r?*6fG`I8Eoi@ z2WoMG_xvz&=7;jd6W%{OhL&3|mm)MB^#_?Rc4AQ>1`J%~(CwRLKtqL0OzO=He28Ho zxehY=FH3d)CJ}ONe<@g|Nqg>1`73t&FUPU?Aw_25<9$Ut7~UvoEX+G+VNov4Uw}(9 zg$AA#I#_Bf(xmHT0X8vk^A*L_<;Y1-e*3Q9@>PlR%U3`J?^s|Yn>=ZBz>Qo-{9}k{ zouQEmjzHkk3!zoBXwXhe{eZpkvzuXqGavd@ayzLN+hd)bV}KxE*{?7E4eWn0DxBO7 zpJ=L38~(M``Go$9qaMm14#ELX)Em!dw_DF-FZj6r96Jxf25=fQtmJ);)ZOBT-KmUn zN(38gR}~Ij*r|cfoY`CaQwY0wI%FS9v_&HaD5o9xR8ipv^hJv{Y!`mHe;U0R19m3nQH`l z5*#`AI4mV&9Db^x*S(+9DObMn(gIImL-28ErhZo3+V5F>{w}cr^IcWJ*R`)4s)RlK zCX>h@(~7zC>r9jHliR;OJN38l8O9xV1ipzM5M=uEb~$K{-wUD8^joaz{<_55M+{;$ z$)rmy`L6OYnDBd!xT45*NZ#X2xvnyb#(;#N(Huf_80V0bG6K2UdwA%>g};8){eZa^ zdOls-v*InDoGb*;uw62m0t5|*QsjaO#{^$mx!E2VPn zhRg%8aj07*LNhf(U})6a7sHm>f0uc`-93pvZsv%Y-wIXkGp1k$d~oxm5KcTOk-tY> zD-w64Pju{G&;}2ABJ&B_s7!CAim*KWneId8Gx8*0?JyCGEKEASH<&&P&+!kJ+UT1n@9iQ2oAR)QhI5?K+Yt((ZHa%kOMQnmIjf zGHn7$yfC4ML#}FNgcQxl5kDw(w4D;RhgUE<6t-@AqWe%#AhBCJ7~Z5>Z#7ldh4S*G z(-LH($Y4YV;yrJ`nSF_rEuLaVDw6Ove}}BBGAJ)(;yE-lYfk5KD{{MoE$-|I$} zNcuq>+hzs@KK94kkr^&w!Dm3sQ{@6to%`=df=n!N0(62e1DX?n5X?+0(TMCqBvH7% zEF-_XXEP@dz*5|L?W=ko*5MD;Gim@7dmH{)lrE*kFL#UME^ibd-eWYH%ve^4RJz`8 zxnvkuzHx=9jm@y;0#g>~x;&`7=DNWpoAcZ&ILTE6-K)EM}1m`t5 zx%0H{o@Fc0UZ*3Kz<8@zttSwG{Y(YVVfb5uM0$n_ma8n$C>UWWp`ZdT^_~Du!iLp1 zFX}_+oLf56pC|cKPH?geHasRrVMh^pp?g7)&j$&FITeIuf_)P-{<-6={!6*bu|J53 zqcH7Nq0pa^Ct4=x!?6Xn7%(l%d&BuasC0zAVm6A915n=EFvoN*;1d8~Ljd57qO3qk=m)C5Hg&l{pmw!EEh?L z^Ew~p!8;kzbii?Bi7Z`V$A%SUNRIm0FwD-4^ZWv*pFqnk6S7J+$WF<9!u!NO7;`Oi zb=&H4eZ92Hz_$i{I6+_iDmOEvofF~SiP_G}2 zirAQRD+DHKiz~ka>vrugkMP}v#GNTJn~olT!%DU_nuD3WwECd-#!iOL3vZiEDh59g zsgz`F>C1OOLEgLv! zou>8H|3+DLi0x@jb+^m87uCY|R~J%N*Oq^k=fza8Y_+P^n|k2_oFwkK>mO>||Lfu( z19SI-az-d1s-etlxBhk8{I1&j6$Mo|fZy^h9`c~Lfb><__^~mzau|Vs6#!s)h+~p~A6-P&K54=hepS#l+4LasKfEc}=Z9W=0*dFNsZ2(e%3Syb+N6jN zmFKqvYv0B756imc67K!ht6328BL zFv^#1I*y~Bm2HsMqmbkjCkG1WTx0Woz$M5517@rh@hm390_eoi1IKz}_%VE-8Jdl`t9j-%S8@`_Q!)5iIOLtl1lzCEu0Z4Wl^~y7C zY9*B40E?dR__UMD#0N(eKL&?kU&R5$Ymouyo8<_A_E=*-Cs_*S4~5x+(tA{hApXus z-}1;$ongQ0eK>!%M)XX=iDg&8(i}-BgUYJ>W8gLc~4t>i)%o@L&mNmYT6kRRhk4bxhF>H63Olnd_%+`)i`(qFbVfD#tK zssk1PV8)!zn|D4uR&Ye?qJ`Fzp2i>C8?p2<#&nUb+k<^LtxDzcnANxSe(~WJc}=S7 z!+&@hju-cY2{1Ei`&IEDqbG%dK2&VBy$ss8e|?5|lk%aN+73lnEg!`RMoZM#$i9;} z+%OHp>!-X>kR+8%_e&mSoJwPSD)&XoU@VB$nx##@2;fi}&%-G}GOr3!U(_%lD$uUp zj(vV~J%)XB9eE1k_9Wqo1yRLrMTjg^{8H@PRu3mX{vu9k*x4)-EAb6MOQd^` zU}>9S+J}*Sw*U`S-9GySE#C(6II{ZjL&GvVKSr!)hj7@76tjpb) zdccFb@i2*Z5XP(5xo$&yGf1~l-Tl{hesUcEO?6n|1Sq^FmK0qIiaisrB|Jkz2eczA z=)=M>&GIWsQKRgTIMNdNUNPj6Xxd-D-+NkYC|XUP)^4T%1GQNi$v99z4xc9;GB1v+ z{;7qM$9*=(mchN;?%$ciu853q?5WX=7Lac)*BT7d7AFm9c&uV_*-zlxjkT`}|BX_Z zjYwRSK8h%qiwYyj#l>}W%{TYWUv`DAkoT7-9Uk!AmcV9x0f3DxECB`X@k2`9axy>m)fY|MOLPvlc0SdzFMA9z|55R{wjIjIx2A!@LnS3SXDH&PK>y z7dN+Bh0aIFwF=t2Ot;|FPH8>nB^rK26@699o>yzZpKZ`p$(nl8$}Kd5G%_KHMy#r!}*t&x!kW2d+`}mP)M%N%+4qbh=iI&GSCQloYI|?o^0nF*CHxXuSxCkcQc3E(;3W)q z5cOpvd+fFxgt&Zw9GT`09*tyVc9-rV4=oTgDv@|35S|pucU7?J*j!$=9R+z9qCj7o z7qzK^`%U}HDoh3(7M%s5X2mNR?HAepi14-2#PlU8wJ{|{(3UvsN%Wl=z3slcyyo#$ z@ZVv7>32R3!DgzT4#ZU6yd94`kUMZT*ikX!&|`~-DunJf$bx7Nyw1&-#?!C0!LH$z z446LL%cmiMdmT}tII~o-0yF_13)1juZX2oEfYbtrq4bE;3OTHAR*wDFwB06embCCe z-}5PEV#Ovh(?nGjdc}8x8*hyUQge>emlcd}9#%IBiGl#!PbS20=KUm7tk#)MM;Rr< z$&!r}3|A$1BF7SbDS4%8;ga#`Iz=nwnZ#2r$WWj>GP?^(l5N&a@1vJ-@I;Aj&iQoJ zw*H_heH zq@Hb)g7c83TP{0JKhHSvG&s&9^G>T2IyTCQhNKV67b9qDtsdUK#jsV1*1;|Nni}v4 zD-zREyD6Bl1JUU`cIc=0CL}Svw)laDT|j0oVwp^Pwd;nhMIk_h@wV;&bmx#S%V^YX%y|i7U$Ko>z>q=4B`*ur& zV(Hrt3zz^0ftcMA^i-|!Y)+~nB*HUT2@HvQ;?|@WG>~FukL$D~%o18zOo-#J<4}RL7 ze@nZ#lCBtZfaMFM&hlk$F1h9bN4@KD~@7 z-t`QIK3Ach;*txap^TBY>)*rkK@zmJgiotpRHxn%W+zJ(@+v73+7g!9s^G8RhDfy* z;tr3oR3|$4rPPWE{jg%RFho53L@chAk7NUh1q0p|lXsMPekTOP0goiibv2c@5u${X z8AtHcC6(Tq7BPr>VOCfi3=1^*zF&RAw({H+Mm#Dd<5K-t2et|bCvOFYi@4iWECYy< zp-gu=#w;*Ui3cbgspfAA7n+DdIy(g1+yys$v`<&>H)3!9BY%RBs<3;-cC`ry%TxDlbvHjcHzA6iw`L66z=81 zyEjbizIbz}xAwC-yeFn4w^!EI>$`(*HTG{n3uwh?Z>qkvCPH%efCGj>B>>|)tM~eB z!?T))7l*-@`4`KY(_1r@idoCeB#QQV6>CG~+q#ijza?JwXaH~l9yoVNer0ir!N_1E z6jqjjRN@{B6cub!;pth-^k@9Gvy~Wsr?s%jG{L!jD(L?`+K5bAyzA&j@K) z2HWj3+l2j(dj+3Hh&W|^*f{Fn2YI3!O6HzQq zBe)6sch&Kq%a2Cw5z5LgIv&qPYrWTDgL#v`i5c+F($USH)+&I+VQ*?4y5#jl=uSTm zm`M;EZYmM0#WyJ3%ME%iV-}>sK~{y|Ap>+av_p)qAC?RkG07!LElpirl||hP+_xe* z0+?jhRkl_LXlrS$=vLcpZ*fnSIm2>mZPo75SP7nu@HAPg|0XtPBJ00vVwNx~{gc~J zA{JEAY_g&a2hDA5W@2@O(MEzH0I0~$4mQbC%#)&?tYy;pG0Y`Xe&+35$DZ!JfqU6; z?u`oe+vZ`u#qt37TywNvyDl4%Wejlv-1FB($1kvhG)Da5XuVyRN8FXUm5H?8JV~x9}buLg2Y}HdMO1%3svG-*ZfDpFC~#@9Z!Zv9OP01h~M zQ^nxTDrU@(lEDX{P1sl42u=>Rd>$PK)crI^p1X<*Od;?8L*ql`4-GMisydYM)Twp-RW(VYIoy+)$vw(1yL(XC7OfZv`q$%tYB_F15M8b6}_E+NegTKub8f0PKu zf%n(QMUyknDRE`gLjwsh1Y|PaSb&GC-5U&0)?WHEUCuMf`j_{>MZ!{IsM}zeOlT<3 zWOdGrQy!r~+Z???QIdS$x9lg|T>yTm+eRg6Fp{q_Yb`|o7NU{K1O2KA8pr999L95yBXc!e*6vFBH_@= zjEzi$QC@5qtZkJt7rZZFF!0AE)(hd-pAFxkWY<-f^qIyJ(uM-1zD$r|8VLD{to*`Q!L^Dfcy`qNFk+CNryj{ zy$X|pUq{8>{FVD&{fPx!;f&0YO+S)XPvhR!FuNG+{ zf=zCB0QF0h!8&+AObUhyBDcjgz?wBhO_)Lm^6-j6G3sq^fxJVf9vpdB93nryDhqj; zoF|^*|Ak)ayzS^&?&`ui)BC+0+TX`gBhDVPnxE&knVtu>757?`%`p_@OGlp|JFQ2t z0lVMHyL_@`d~AMazQu(=>Cnkt^36Zz^_PS)EO%J{SGVPTKr812W-LWNKosGZ#!9(Y ze~7Np8&NrW*+V84)1rcxiJLL(oJ)F`s*!9I91Gt{!y%ptVkPL~!02ZMsn3{`dD$jE ziDt>YcbbWNwN(V@W&i5@-5@8VqisnWzb2ll4zn$K$Dje>bqAX(uU*Xqwjchz9U^l! zHMJzzy*9~>4d|c|Pi3e1)~A6)uMb#8n|jxgLl(gb&7?3_4AF|k@~IgvXAtH@oWtgs zmRv`gF`1)mTSiSE9Px zh9CV@>&Uak)3@xpofu7K;6k5d{t>(h2Ha{WiYG@%^k4)r1?D>n0R^iS-D(m4u>P9m zd^zcQMSNW$3&itxUPcMG4>dF?ckvQ-ICEwb5;~B1>%3cjvi*kS^@l5bqfT#~*&(|B zPnYTeGOsDp=F81_U()Mc583Y3h~?GVajZ=)7`3$Uajw3;p(-6-(#nlX^hm)v z)TEg7PXZ=DUve-V48;WPDSPIpt9BI-j6xjX5Rmcpe9O z8Vyg8p9sCaf8Qx{Ty3;;L-a>4AOYKO+8jqUtctRFEwQ)Q6Z5Uz`YMU|Ae~RmHcIJ* zz$h{%7lWa->WQ^l48w}XqK(mtIMNs#Y6FMl6kD*!yb9yAFrnK%%!?{G{r=I#q?B;! zt#NB!$^7%}KDMOJ-PgPu4R2w zx$C?e#ry3JKWn1{=Y5-|0)Ty16BpJ3-3a$Nt%JFjOoJlxNv&|U|8O3m6}DYt7G7T` zG<&yTKXrZEjr0@KzpOioqV8#D=kS;{sJBKgvq{`)DK3GMTD%oGYO$}C>JXWd}7`kI}! zPrj}L|B4*`>($ltOtmuxT%@sm0hz1|7g_5rRSU80r%G5{N;@v<5zp~6dO76G-;Jl` zem#^1MV#CTsAsyqM-Yl?!sNrsE*Un7PKxhDJ$hxLZKesUA^c4W|m`}rPQ?}hNo z!=vh!b3Q+NQ>}T+`!Cz8^gy*US61B#e6V_q(fhLxRt__u0*=H1a6kRl)Xb!$-jz=q zQV5TN?6M1DY|p%9A8Yz77sO36Dd>g2aHX%;j~eXIYddfuxpW zfwMs8Z$nOL^e+K@O_USA3#T==bCUk>xO5ks%1kXxfCZ`+h_bR7Z}9`*+EDu`E>)~4 zNUrkoFPQ;pQ(X~XTB@Tuc^%U@fD?d^e(%JZ{*)C18vl*H%^4#5w-0gi=!R)WzG7`#bzb|pz{^#z52GD%R*tX@f(E!_jUZoT>rw-6y z!&CKDcmC@D6***Wcpi8=i>Wpqypyg@1>kGF!nZ<^lgCv|HDU!}EHY zAQ>calf#N7Q1fTc_Z#iLSYL@y96sTX`V`0DwwZQRD3E_1FnX^B6uwRP1Tz~{a7{e& z%s9Cj~eKT;IHBg6;|eE%LMl~ys++MY-btTnii-(lUIzp_5j zC~>e(Vqa=sz!m$;6Wc3llE@9d;CpD}4Y!UTM6eRR?T2j6#a9DgU#3yG%qSQ3ky9%=_MSkb-lxY+ z>8?e|wk4^y`H?Fh#fnFTwJ25MRK0K}7EWN6JUWdJk*CfP3DIYRG#lPuS;Wp0Zhwby z7XtB4JEQ!z`pZvC(+8PA!zG&s^a=mlit zbMNV~w}Uu zx2aXA-|wOubglgv@Cuj5^dhLYPgSz3`ccAc1!K77W!z_2z5fRuVhYziAFGWSSO)M3*WMZ%QD@^9Qtjp_zTfGbXVa5V28qgsinrG%P`M=*Qe9i zteH@vjCNCKOBD7)>0t6ke#9$fq(&H~haThG_5C9FZ=53kKWSVpe@xgp@!+^-x$Dyc z0W(`Y6he#<;~vOO$TD1{ho*q6!2X+;nx9au^cbmLpA)-~J)F@mcT%i`^ zc*}Zu!7s%((yoBt!7e(}Ln&wp|9Gg&)UNPTO8;t-YrwQT#%J{{ z!wvrA`7-yid}_xF$7=;>AWotzMd; zPH=H2KQhTvkBJmZU7F-ux*B4?m-f|__dGn$`~TFeg^famNrjhRu^VK}+#GQD5o9(+5%coK?)13dS1$a7yzqsA>M^+WSNc zXNQ-P?Bf5}J4*0(nJX&L&w%*1etNMYW3s*Q+c$Opu8T}8L@$M7o3=R zNimg=St7&`Gc!oE!(9uZU*j3kG|@vUecp^2&7d}K&=43psNoHnp@F#~da1SsXa#d> z(6@L(n)HB{uYuVEg_-`?#ZGb;ECt8y7c59$CUIMprgjcQgc>cqkUzI^yN>2Qx(?O0 z4chUVbX?K40XOctkUw@Y-m0UM|IwPAAb8>uM-}cv&!Msu3Y&m7quXK?L5(hdkRI;& zWWo^u7#E8!&LV4!IA6EuSwi?zpd*(c$cg)?L@4q)duo4PJggmZjpxIW9LL7fh!B6^ z0dvshO&7n%9{p5qUo^YQ%&A<{RZm7!y&@(KAdN$uve{8_Us&Nel@`o!vG{z7E&4Z1 z%0;-R$XKMWzFZMux*1n6{tk2T?8)z32WyR9Jx5w|a+^K+1d-8f?2wz7e&xH7R|3MGI z#v>Ci8`l8^9XP0^em9SeqJM?s&fs4exbBt!5^EaQ8s$c~gO{tgJ;C}OOK(|RiwR=1 zD?2Z(xX^4*dH~fO#+{NcUf!Qybp8!~MdZMlD3BP+lNJUrt#xKmX&Op4E&#!;PM$ca!sQ!E~67j}d zy?Kr7aDF~{`It?`^^Z1w175`c2Rym0o#Umq*`WHay6+Y8^5Famq?^%eqR1wSeD7-%nOTS@_Py-HU)-$K>Cn zWVR0}{NkwrmRxcik~D}}@2nz#v9V`%*y6?VL@=?;QXi1Aw7a=tq2|>;VRH2eH;^kz z?WS%*l|s>j%4S*};agX=dp0|obp9H`*;ybAN2ei66em!RqjORWI#Od~=q|Zz?+9n; z{BhAJGzx>Ek|fHYW9x zj5hVyf2zd2NTFUlz3$ZB^ihqwvqPa}|3+s)sgxNLqY$5zWL~r``DJ!EL-}*bFR@l% zjnLOqp;oF37;gJKtvmPxwlyJMi;Knpwyb26^GW|rEbl|0F6j>t28`j4;2>s_-*SRR8~%XBIRwjT0D zVVg{bZzr^H5J1pz#X$c#CYDp{huqC;2mr;{fJTsd9A|Ve#{?7}cE=r9(AA2#1V>Bv zQ_oN|%El7|ZSAkX(>PfW-sb((5GD38ErHj@6N-DEzKGBy)qj)R1rwDCYegTY$3en> zoz+AHWM6JPsXIjM9%t`!QW^a9Ht32v=;fOP_fISvWk*aTHaNG_aoZYCNr;7vKyL3r z^C2TC*G#&dZ48E5nw1Z#_P3Mwi-3dY!(wzktk{4S@Gplw&V~UnyowaaTt^}zp&uG} zZO!aIBO&CvT3u>Xgk47cn$ZJ?yKc{=es`}`n9zD zw6@HnBYkgDDpnp={O@JI0&IM$il(;I^kA~T0u8|5s9NYjm6(!MJ4PX7RK|~?o%J*` zl;wotUFm>)NZZa6!8^vQ>iqnfsV0hAcn8RMX9x8|=mEcBU*d~CefncWI_cE(!kJ5US+G>x|9mk>+8qmTcsW7OSXs!-fEHd+(jAf^fJ&okR( z#S}XJ6(@2Xb=%na{)f}-jk_hRE(6p zE$n=nCv%OVWIJb+KUq6Y@YVYHmOYwS^!jS~7V}6}`?mdo=;G7N|K>IOcxXq1k-7Nf z!Q1o(3=+xN;o3V}te5OlNwacBJxLJtFUsA{H*xGb; zMTSm9thSES*b2DuyFW%D&d5k2Ym)l4Bzy1>E7a#W`|WSME93&DT=Iu~S3R8&PSVjh>IV zPrsdmVS+C{Z+>OrbChZ8Lm;xZapv`SO*mNS{zsVMgM)CuN}w%WiOKHKH&)|qxg`!} z%?%JaM0uL+Kz(_66zku79TMBwTM_u~v=MoqE-(DcclW#H*yq!o#@MotwEbJStBTbS z=-3?7a^_~G{Ul)P3>g!kwX2%LI0UsjTM!}_F;d@qzpec|Q!~Xk`W5}!e@@8RK;VfV zrn*7Yj2j02RCuuWgOJN(FOh}sd_ot8h(O}0ffWbu+eS_U*Q2=z%QLXnEJr|p#HYZ| zsQ=?3VJQI>9CT*JR|(_svC?cRR!ru5>@P7az@m>@D(rr&;K(b#RzK<`qwZzXuj


CD!aQ~oQ)>&1|6=#s9UlecT(hA1y#L(}TJ*fs}B9KhT28y#>44c2^cO#juvEad+FT!Zr~YR zN|t7Z<8inQ3$p*ZV(el9+WJQOZ;s)ZX;-8A#_*@k?CYxFEHaSIqDjXxAR}S+OdBJ`q=5=r(tu-Q=1RkwHHb36`|v>MdePLwP8q=XihnZZIer@$D&4 zKfg%Mq)*Pd%;WSKogNN=7iOY}3BrycjwRHviQL7Q4fFl; zxNd4x@qhp`+A4t!dIhPA96n;iGHLqJI6iun_qIiUj`o1bR?t_Eh>JiOm_s?s*q`+m z@+lpO&G763($~Ru7GMc-d}(!hKg@j&wPZ43JRDZYoQ8VSAyIdpJqtzU0Z7dpR2J~` z=Qf(BcsH%c=!e?yoIW*gvYcwpu={jhG+n+QqBWc0@Y*2aVAf#N?onF0>0?6{@;5_% z$?9@`6U0&HXjyQW4ZjDc8Vfq{+xMmJPN`D|bdVYEOpQ0g9Jm%9$!HI4GzCE+(U`XK z#{``BYBo+a?&|nsX`Cs;SS^g4wyPcV4_uXa?aim%V12f;HQ^AV+mqqfE3v1xH)A}q zvbHtTGnsl5(#fj-F$Q)R?0D>{Gq9IHqLrF1c4fg_JM?J9}mBBw^lBX)!Dtq;|JQw#fcYI3`< ztDe@|5@L5J8p(LeZe*^iyC1L8OSs&A~Q<{K5BFL zJe+vCdeI+vyVYvoJg`vrUP)QSWd3_|#aC+-oI%JCRQtG_3COJQ9B&{JBzk-UvHgWS zrll%;x<#E{7&ExvQB6N-!J}48QRHbYVaDvDwa{v;4_hYRp%&~)?<7NeE9ufKKa9O{YN!&=&ySnMA|6-_v=At)x#-v zzGK@Bh(>eC1twPbk(>tD5bDVw6PR&j+6r2eaJwly#Gjq^mE!sbv70H9`#}x)lq%zn zsh&gNcZ8IMA7Mv;wzH!;uyBV?$~okI&uYU&NxA$HLvo2b<*MD$n&s15N0*v<`tKzU z?l7~`(#c1$o8zuXlp!n@od=N{yU8CfujGP>7kdFC>{-$lhES0mE z)@3oz4Q@lIWIPCp=)TXWQ|4 zTgx^7zJ@?0poO4DG(I4?s2vXd6 ziEDluR#Vf&2l5U&={AGq^Q{L*zM0Y3=~7#)4a+ia65%=3)}`85Z1ML}gYcJG>#tqT zb|_WyMUwE)y5n_?{bArYgYfWJjU?@qDa)V~`*hEMpdMBT@+`l}%=h8YhcX2!)hZeQ zv*}$tH;UWj{=m34$OKwc-6PiV4GiEQY)-o1(n@vk=nbuf>wRYz9c{j>jhh1t1BHqqm9&k1##4+C>E*BF+)N6Yt!Pg1S zT6)tb0K@vDpwN!HAcTT!<^S~zwAlW+m_v6T&u;G>;ojG<;Njb#JN^v;SD=JDsBj*z zCYZ`C)!GX7!K!fll%ae(Jv~&#@T*pDH^yGP2>d5pyN>>3zmb)~Ws(4}7~4QP^BV%y z(+9{7skD%q{djn9RT2kPOn0rTX5PlvQ0AbIp|YhQUljfAxg~`AlSghCOtu_MXGOcZ z+~tnsw9!S>=C~Po`N>wvAunXzBAqYg8w~;I{oocmb}aAWgT%L1mE$Nn;XpH{khIlI zX(SkfV^cZp;RMpdu#W9KE&=wF%7g9^ebvX#G}+H$2~EzntYFKCn|BuXimG3e1c1q3 zuBEOs6&0g1*qe^#*lpO78G8lmOFQ%Ct<P-tl9bcqE*`1P7&YqI|*-l3=bLXKqQ&d z=`Zr?cX8+GG`#VYNp$&5tiS?W9X;P`pA$+TdzPsG*qz$6p(9A#jciRVL~I>a_K+z} z?^~TS{sotUEKqeCD^jtID3!G^kppGcZgJ+rr>M+p4ju39Ly>^TrQS%f=QD4Y&6ib- z8C2I3TKCO+iD2UIe*Y`|Qj-0K(PGpllUD!x%iq53xx#X{iQ0ibMEpTksKM7gWQvqq zGcoQiJ%E-*Tkt7E&6T8d^@o+3O(*h`(QlH;c~^7wQ+Wp%Bu~Yv0Azh+YFLW&$9JGm zt?jw!lPbi%TpJ*|L*sZcEP42JZRm*vlK9W#em95qL>e~VMp&iFvN5EMr;1c-m$rEjPzzg_ZTqlAPcN)H4SsEBK{2v{i9Km5?v z7^uUc^i`lV3oqhQkEZ6doj0yDJ)09o?R_RqBo#_*d$VLsF{g7qSrA~Z^+Mhc7M9 zd){+?e*Yi-gm5K$FV-Hp*UUXLB*AnKcX+p|L3P_NrYsv21fn*AtiPYVl1hI|oxloD z)-46X4r|7aq#d-P*=rFZ;3<6I2pTo%F5)W0d7JfAVh@bZ8vxHJmx%A74cru!)9v|c z3c$X}8=FYf4T=FHW!kSP9x4c+d2|Nx@6<|5Y zBeeL=DRF^KNvAh<|6SHEoA|M-*2-=LHEnBgxD*?2?vrYy+oxL)?f(h?h2OUNn4+)Tfl6vTW$YW-xvaRH6^VrH2!<$L%%R@9_~8W)>dks@l%OmtOXqB)1Lf-1|s(*6F&&MLUkE` zo#l0df8C{=)jewHP0F;4=uj48Q4*uHfx?omAy>6Hi4E|+&yV!nEXDz(N090rB$Z_r zlj)`xw;JZqe(b=8{1({=UOA>*D+p=?&GngS8W2XPz03rr?7&x^;^Gx;dqw3*j*ID{ zyd7kbBw`S#H^qDx@wJ@2OYZ_O{F+p+-tqfU=AzRinqZZ+1#-)KrLWU=rcf5@H(I)J z8!oBo6||@sxCXix>Q%C4+@CMQgdoh4xcw06I{T?JN1s|ol86;^CkAgVLR~;94S-%< zi%{}@`i%G?XaBuKvIZxczA&bJ)T zt|0)0icc*GH$sw*P}@tqSBs9vj<3dJTugwCS#gFH7_&7=eJ`E2L^$MGnK8Cor=;#v zNtoi)anjW7sq;CsJ5u?aQ3~yhsfc^7WCBtFIou{Eel#Bl?a9JdjZiL{qa7N8nN@$3 zN|Deb@AL@4Br8)oy%KUd_P?Kfn$BLg#aJ##h;M`YzzfT#N4~QAsg-v2O%2w62oSG8 zKl^S5A+Kzo@m?Hi;Q2Y{jg9E*5Tdt3eI>{3;pF$b972!hD17cFSeIKOA*!fVyi_l= zDi9q80Am>DRO5UsVv{Q)p7|S+6$s4U_X3#W#C9?zP8Ow0DKL+y(;%_09JEPu^ttSW zH89fb-x2b*5fr77*lsJWs|Gp<^PGOWUT|T>xy&S=Sz)cMfBCS;?=9RMy;aCh*nYOHcj6eZW3(QKKo*i%`qI$w`%i6BvSmRJqQH)rUp@as zg+y5YM1?jbR(#9& zNr8`VBD(QKMy(US`Txp$`x7jm0E>M?CA-%RP@%Qw*XMnmB}HjP8+C&FqSp0#ib9r> zOPJVy&CJvnrpyDSVYwOuT30kz?@qj6W^cDri&SQTVrFKK1fVPU9{%uXiq^jsY+sV) z@mkNLF5GVuB&MPEa+2{oRC|r?Kmdl_bPMq&IRi^Ks{&$a3JrIod1?#83*R4R_~^lh z>=Ff&T}$00zkac9%vF6&{rtJdr-Lv21}=ZBqqa55a3hlRYq}z_^{;NEbVOHj6Zw0F z1i9}Kbm~u2Z{J|*r6Tqwdw|~n;)%_G9Lp51Oc2<;aIWqKHZa@w2hKyYs@b-dT$B#A zFUB$403^%bm2qxRxMzdi<|cB%)!ZNZe-@48Xgu87d2)~A0C>vKqmP+>*Lm0#`ZGxQuJ@Q=sa4`m?=O>eh4mYjy_ZTJo>V9yJblxk1!?YHd43_;SXKQ_h<8(f6Hed<;K5=21Va@utUpSa}ck z+Bf;sof26qVo#OAnWC})cOwe1cCL?EjR=!+uD{r?N)lIY%VdC`X2UOU-g4l8L&`Y_ zy^m!R*VdL)Kf|aK8vTM2l!aEjj_L7IlASN_GnfT4%T>;L_>%JVvWdvDc8TXS&?05= z$0r$&h-Go<3&JPH$SW!e9U&JpGjrGw&Iu+L9yp9|38H=&s1%Uao&LIA}A$Xn!aRKe3JT$7xup z#n?o{=;|f?MRuBQFg_giH~!x#6Ao_j3~ZeHpx6PEdcT#RWH)D94c`bhZ zhYJ9iq|5G09PNnZOwk-yBz@zs5^eY?N2a)P^QF~?$ka5@J2z}ATEshpA{)lg^ILX^ z@}{;%u|m(dKgVh9pxlgZ<3~A-x(vV=%)NU^@DS8wJ{ViGpzp)*pdH2pK+X~E3DRH0 zfTq!6o2t|m7-nSNKy&c~a1QIQEc-&C2MRZ){_8nDi-Lkmvqik0ZTm!aI7^wiW; zG3dPAdi&3+CDU7utim#p6t|%mKDgoC6vm=F!H>00Kx6QTaKY;!mB#3RWIb~*Dooe7 zs=D;n0Od!i*o|!kn)?TsHK2Ufu^J(|^P9VtH-#6yf#<%gW{uhg20a@al$u&vRh8^) z#dkYw`rUiF2o%+ZWQPXCVJ3sifIll=wWyf4-eJ*Q4!44A)Q&Hi>U2M#U5d@teW<`}6SN4dn-@!?i z5LxAdY29SmQ7=b(Rqqm-xfF_yUGkmP^5`->i!nPcb{-K{xxmP(uv&r-$`EIi%`BE< zvld{A{;2!z)`&JymZozo7qK|}B6g=`k;>{12t~f|2TE#Z<{x>~N|@4E8`R%# znxu1ov9Ym5YcKWP(BwvO&K-NS@HroSnWJ!FM}+p13iJ_Gl5Y88=-T;(mk@~S=4Nnt z$7!ea;jNp0sSzZ8z{+}O7wE(>6H(R5$Mz*H(TwGMCVBjrd9z&aa--|+ za+BBp(6-UJpR3++H86czrnx!fHYP6p?z_?9EBh7m=cmVkn2K^kXTkV0_8BUd$QZA0 z4S9)vs9K0VIfHfF$cJ*v)s#iU&FGY=+||v~fK)O|8PWt{-7uIJr{6tutILbapk5WV zfk>tKqrnMHKnEW9^zKEzAJ?nP1Fi2#9DunB0|)+DrX_4l^lC)%95}J0)M!%dE>{r( z;asgx*6#E{)(0k|u2VuZuL`{fUAN>IlLCtRqaFcfPuf%aY4lbN^W`tO-%q2A?kKPV zvq~B-d-?~kBsoywaRYLbG4v3b8^!HYoi+r7j;{$H=s%Jq?&nvJz8$yR zpximbgN|h0F|uLE_KFj}X8pph4&!%_xwB+T8EL0lh|aQpj-&t(+rKHux4gK^Eqb&j zq#r^n*|9vrAOyIqc$k?0FwJZj8hLgUkc>}_EN?LNnY8NDG{YX=FGm^$obP*{e`GU0 zI|nUrYhi2QKs)(gMQH_C^XNT0#%uy88R-Fs^M!@Xv+=BxG=79cH_e_`&L}%=ZSf0T zO_W?i3-&e-->>#;$RlJKNgfPSxLO5h%8*=O>eQ=L%pXGMg1acdMMN2oh}MGT%>cXQ zj!H@z7pf^u*t*0}^Ot3B0CIf5_(U6|sTnhSTPI48;H}tk8@bOoNJP_hqW_|j)2rM6 zy;lqJx%Bqx+pe0u#Zi?Kh5#@4WBstUEYMHXg(kOJGL(htq7VSX1^-^VZk*p!HJU*p z#XB)RqUN#;e<6=gvM%C&B2OBsDDwi{$&Y~=BqzVjs=VIC8!EC>rw_KNpE3!mD~ioI z)R_myqEdGDi)rKTMTfjOxsmvo@}MDdF=*j3MI$;!Lg4p~MwrXg7MH2i)|#-jiZ5m6 z;@%eU`$NzL3m$Z8`Ob!w#*Ts|U>E5X_fG!JG`34sgcW-1yhMvgSmkPBZvwlj5Qa%b z$vMG>d2J@9Adlk4I-3ai*ms|}@z9kQX|s1o5>yAMn}7I4b`oqL`&0Khw4@daZfXoD z{x)<_`pmO_1!cwxC$`jz28TA?n3H&3pxv+0DQ|?rR%|SvF}O6q&z5w#g9kjqac!f4 zsRcHn!8zR%^qc&C$MTX5MPDTvi5SObZ~#TL6CN5!fSXkJFEGSlcXuQJRu+>+D{1{( z+!ww`w}~o@6}Z=Cp;<57G#`He#E5!M^yGV$Gf)aCjLh{xfr7;Y1q{B*8f!&U#fGF8lnX>;cnR zU5xAB21h$h@X8y;csGLMIyrLpi|Hq)IaTs3+n)DUen=rDX9M=i8B89IHk(%+7wjSd z$C-_1=ytY644IL6Dr)<1~5I(nL?=+x9dLwWU=#;m8=#lK$({wZdJW`MatGqLwV z1x)1D>j%?WM*7{NB0h)}?KkLIKI_uVBSsP{R$qmAVwYU17JgIEg?{$1G5yq!>|7TV zS4zab*ACg&m@L6P)Gr|j*uw_oWpH}Fw=4Q$soFvRh>wH;KNULKyoEeRv^G{el48EJ zUjpjxtdGA8$?pnksYQgirm7f&*?7X_^F}A_@cL@nh)%WWH@tm+MnP1#;spt8f!6De zY+No!R7JPRx1WCovg;T5>%L^=_e~If*v`F*p!VbUur{i+r*`^yru|!8oY^zOE`Xi@ zS%?h*3FU`Eo70#^HsX zmdUC=wnzLWv@McXYGzjN^81y*)%%pyPAa3DSKmk}4$5=uj+uTLLbO^+)89Ww#gu8F znEI(GgQsTlS%CMjQUxEQ@4N4j(c90+vI33>@6T_voNf>JOWp6R>iXm1(U53NJrh8( z+{o2QTClD9D8{q+TNhwO6f3TT%PybKi7;*Q7b@K#VRR1einlHn*?ll>>k5 z`5I>-+MNl*OEC^H#_|$mfs{0jL=d>Jc9KWA2ZPY35djsb_h|tE3D(ObIDEvlBslM`rPG4vCSqN?XUP+7P(Z3VL0lM%-@1 zCpx}&m2J8lWl<+Nx-fY_@yo<+kLhb|(d=UKCpiVcJ8ucaN&3!a5cqrJxULlmvqJL- z>}QU*rOX-MJzK(B9G0VH$ZuY?pG>o@e_z(aYUH7wjqJZo{ zYfeG?zk*m!3d2NaIPALEmDUH>dO0J0e_m8@auCStd~_g0R0n|UK@VAN?2_7z$e;_n z?piiVdtCFxYr#Ew4A0pV#s{du7_^VX==J(72-C8g5h;TNuPU--x74t=*A1nmfeT`5pzz$b%!}>ztdt_K$`|@uGgl^;;34{3Xz+cZJO@S?8^)^3sX=`8^C!<9424B z)99ml5K=(Ksx`vve@HMYH2LV=6H($lMjX{_U;-o7z&<ay7V6EP)8*Lrc53h;3+a z<$#KAs%)5g-l1)Kkt$!F_7jajb3G5l)PVEIF)94IVU?{ap>r}{JuVvcZ9|x4lZ$pt zERtF_Q`{m9t{EOmwzmljboeuhZ^Rw5-B5R81v{Tpj4$zCLwv|DR2| zuCvDC!!?l%dQlz$__ALe+AH1B@}YGWXChpyB6l9K_jN)nsrp%k-cHr~QKhB#S38ga zt@aR~;}(p?2K(^r&|PL0)1eq6&MQwX|9Q^ie}Iw~=x1DyrGL~g@Hx3xB156i_lb@5 z>SS==aU*R9V{Kc@u%9>_BeKE=*YfQR#)4XokC+786|+pL7beZEkQSv&owJU7Cl1}d zG~B8quY&OJ4HH99n-*Vq5q|x&IEwbnQH)UmjXL?+V14@cL137vW_EE!wvKrOvNE_|R3vHI(`iWCv9Qb=Qbxx>aUDP*fe3pD}t zP)lnxiTI`tgmyPK6s+MWK!KWxWY?^Be|qvqn%Y3>r|*T8xu{aXAA7?Z$+jvJxj)pU zJC(j+d?<-dQ7O5ZYjYL6JXi@`eLB3O209J3FI&y{g$xP*T!Nsv8okdq0@buXgOw3X zc2V5f`H;^&+{{VWxgyO>YlXoW${5%e_0|8K0>sSdG}^ifAiH^9$Elkow62j6*X&Y8q>)4uh*+Z8$> zgipz658!~Nnoc!BN}UEb#+yZUjEjVuePz>`YTJIYuL}|}K>|b^dpGSGpjmkhB<0co zmRh~-B*AtTg1KhKyGPceDQ@QFXU*!eTTU(|C zRDzN_iL2~P>-A`irQnKy-@v7YCl4;-cIvED638U0u3(45(R%r%sl|%pvlos0IKzF%Xi?dlKV51a9z4!7k98!kc zp4hW|qv-nN0Uf(xLHvxC1f)9e!TRBe4(RjGpkNO1oV7iXt^6X7Wi4N8wOfI$BMN`j z6Jcj$)Wq5xt$c^7Y?NO43K(dJLXV@)^mCu#E~O!GGVbJo3&4t>?Esy+vq$OKJdmYv zQ8bGK7-QVMJt=d=8Vji6H3ryGyA*b?0t1S|1HR&cwQ(4xW{oU23+O1`D)t$DesaeW zk;eWVAY%Yr*sx~7`4ien)Ue0C2$An;its3j6m;l#VRYLqc|Evu+;z$FnSBNVO(}j* zJmkHyBEJkBwEKq%6^@unp0e*WG5qqM)ChJU{%Vt6hGI#RKX4w&IZumQQU=c~c9p zb_uglq=j%!_}jHG3OP$u%SS1dxm5*kYw&F${S&NOrPj9ixICUcNwwMPiqioG&D|^- z5v#l@&R)bKf;SXM@#7--=51gSr^BHRqfft?zEY+|Od-|ImMl&xqE@?rm2tqWA4DyS ziP$X*7f`!n8M6!2_730JB4NgWRWSlrT;4jqb7{|$`@pOqE(e!;sK6A(vf-mM@*VLr z8~se+A;7I+O#mr-GqTh56&%iMFCthujF?VI(LEG{jw2mt_}i;B$lDgx=lyZ#p-;nR zL&u5v!P2W3C_Bfb^>@$mt(U9vrK(}-Ps?Y7y^jk#Gjq*UYUQ_kCfGYuj0rzV7|M_j zWg^lgbGEwfvio12;rD8+P0hPD)=o%UAWYD%)zkK=Qv4vDGmJ42vFlHC5>a-YN=Gy2 zAdnfrfsf`{6F~IE$l5oyFQYmQDsIR`hem?#$MHJMH7N`ha1)isr*U zUYzUkn^|Z!v}1Pe!jEK@@+|FHv%IA{sR8A)X}tZ<=vu@A#7g4LarFQ&A)$&gB@uQ< zcWl4FgSOdVT;X@a-NDluM=>kCP?G~)*EL(AY1h-$2QRn7_2)Y_V}zH(=uiSl+>sjd zCIS41jBq)iEkgKjRtTU3r*gdl5 zI@)S#3A~HIkOP={eC@YC8dwy*jk?=4ma`x~B#_9OQaguy!VA}o@Lof~qOn+8HIiIg zoa5pTGZ3ub$PGX0Cv8{_3O`zm`~AatOB0zF1)4Usn|H2gdb&-m5dF3JY+Em)z&F5V zL(3JChUddEh|=lg+SUvEqG}8loy-@fUpcc+p`tbLDwd>wDb#iMQqt}2VkD^fcpv_lT5~!uk-i#iji#laF3? z8n+0!6r=;HxQD~tk-()ykVV+2fU5DI<1qe;RAMmqgT_Oh@uFg<5w!_({7tlk8~)0L z4+Ew;3-u_Yxf1H1Pcq>Raudvr@-Zccu=7!P1 zT@6x^uj$#*5-(8VEgGc&3SaT{idHoDQMQTcyC7|(^%_fO2a5Pyq=m%K07ORA6KEhL zp5-h8*mJzP+;TfEpDTD-^|9+%3lmxg(b@9OPSp~J*V=KuVVv^*;Z`KjE z^|jnY&$DGJKCfGnEWac3^RKuHnIZyhhzyAUJFH3)?N|gs>j<=5(BQ$%Hwu00yoF8G zL)|i|(CfW1GDirCt==?pa~5FCXP3r`1=*M)-_vTno%u8iFDH@7dzkTk(dkP47 zD_sGRzx8hWl|ZnG0gN%JWbSS6YgB-Ont5JW@WjCmbE0Y&MRqx675#-EUec}g%K?!9 zV*mEA%GspZn|95wfuAprzC%MqvG$Ag>%?!_~>^nZJ*3dVh;+ zB>J=b83yc=0?(*C+OmPauAi-sC1zmX=IAz)YKTJgkmu&wODF*d5uNN7ZX_eSC#?zY zd=qtiFN6+P@wYh6H^qT$ImXv-3h=}V>Tus0}`EHT~3nkakN z*P5Jw>F}aYOETvmnUXaOji=~_-}PR%kNM@_HY%=`_WLV10E6Wr)%GmnxpV;im|TD} z8bJ}Jwrm&@z#1qknF~qW$hJhXAzR}^*B=cv6Kpg)2Gu*vfAG0njns2!$Ex|p)^nlu!OJ_ZzCBE}WHCp(;fCD0|bER%TjcgiM?HCTex% zTyL?IjLF*;Vs&xv9IGY`)z*#hy95Wl#2?K!2IG+~+ONa0@{KU7&2qPD%BfAE9Q1`CgH0ovcZ^w3izW)$qmL7t{pNnh$Y|1Pg%=pGjQBK9s-JXNyaqO#~W=89Thd>PNThPr`#?eK^5O2$=~c)Ss13-GT^B zY`9X(MJ<&n7^kaxvT8w}=g7nxvPDDgqbZFugNlW-EcP=M^lNwI4#U7%=_fah0A<&g zV=KouB2|uXE4$W;0N;Bn3LChKdvu=i2R=92VZgde|*S`8c+a z9-cVY?zyXOSG0(gY4*%%nsvQ5@a-BF|LZ`Y?BF{}4B~1!!^>~F5BAI4d!~X zUm1g6Mtdh6&NrFL%8s^4uS?UizeWwpn}p7tf%qC>)9cKnAa}s&9_KqPp*rJI#@tn( z@AFt#eyjSEo*sE)vKpH?S7nXe;@GN@%*i<^KQ(HeExI=E{^o#P{{Gj{Gu-;44THyR zf&bM8_&_GfwM?z-L3|%VflSts4E~t2ozcx@$$s|t&gibLuDe%vjZW4&KX=uqiFYT; zRme`~Nj&^qxs5$aUfjGnYnml^LgZChAFa*uX@e8PzxYZf;l)=9i53qkUWhg@tUC5x z`O)(hL1azmpi4^*sl7nwY`O{$H7F@*+hhoY0~6>5^Uid*-;c)2)U-8*<+Fu(lNYDY zIqz2Y6CwbBdLkz!t}Z8?-F91}*N=5gwlGWttrT~LO24t`;*KK6=Ae?%KRE<;ME!m! zG#K9ZJRQ#cYuunucKW5$`rJ@cwoJq!5p&A$6Vk0*EEjBYN=hyl0_%^{t$CEB{{A=? ziddYGmy0OarEXMG&LM^k;q*~!aPD)lEJ5z2&66W4Ae170DddonKb-Dx{Z*%8^+X~fZ{fjbG~Di0W)VD<9t!&PwnF4>G9>vUkxkF>`G&F z+hXSxajfdnt!nAUY2=<+zSqvO~hvwPOA4&wx zhFks`=0AO5<1-umRyp8REEMyBD{o_e=NHrHlJln4wsjlGE9uV=vcixBD}d3S9Jk z%Y-JF6Qy8q;OY+xMFRT6^n~#Gd}jl97^3`JmJ?`jfcg&E#R|%PHS-c}hCQ*vYDxWq z4GuVc51knAephF&)|lM6PYIzY>Fzx#tGRteEKf2st-#uD`9_yFpZd?T|8zx71%dfV z|0Xv7^e%#MP*i$c%OudRtvx*uNuNW#zK(NT*_RCw`V)NI2l4!lRjSFHoUz2$%IWP9 zzg0<%gpJ7LGCmoctu;pJtnrnruS~DX+c`pbSH(zS(Z_k|8SfL;Ak&YLv|wpPfDORO zPXvC$NrRM_8Q92HiLR>qCf` zRwx93g$D)Y|MO)mAgoxP6$jFPuJz}lIV{Eit1C9)|8r3(5Gvr~mp7|%fBk9holKs5s7G-(`751 Date: Mon, 26 Feb 2024 14:04:02 +0000 Subject: [PATCH 104/176] Update changeset from minor to patch. Signed-off-by: Phill Morton Signed-off-by: Phillip Morton --- .changeset/forty-oranges-joke.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/forty-oranges-joke.md b/.changeset/forty-oranges-joke.md index fb08927fa0..28d162442c 100644 --- a/.changeset/forty-oranges-joke.md +++ b/.changeset/forty-oranges-joke.md @@ -1,5 +1,5 @@ --- -'@backstage/integration-react': minor +'@backstage/integration-react': patch --- Updated `microsoftAuthApi` scopes for Azure DevOps to be fully qualified. From 930b5c197ad2dd3185045b06d2464732a97ffec3 Mon Sep 17 00:00:00 2001 From: Harrison Hogg Date: Mon, 26 Feb 2024 13:39:00 +0000 Subject: [PATCH 105/176] Added root and label class keys for autocomplete pickers Signed-off-by: Harrison Hogg --- .changeset/hungry-points-burn.md | 5 +++ .changeset/pretty-boats-promise.md | 5 +++ plugins/catalog-react/api-report.md | 11 ++++-- .../EntityAutocompletePicker.tsx | 19 ++++++++-- .../EntityAutocompletePicker/index.ts | 1 + .../EntityOwnerPicker/EntityOwnerPicker.tsx | 8 +++-- .../EntityProcessingStatusPicker.tsx | 11 ++++-- .../src/overridableComponents.ts | 2 ++ plugins/scaffolder-react/api-report-alpha.md | 17 +++++++++ .../TemplateCategoryPicker.tsx | 16 ++++++++- .../TemplateCategoryPicker/index.ts | 1 + plugins/scaffolder-react/src/next/index.ts | 1 + .../src/next/overridableComponents.ts | 36 +++++++++++++++++++ 13 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 .changeset/hungry-points-burn.md create mode 100644 .changeset/pretty-boats-promise.md create mode 100644 plugins/scaffolder-react/src/next/overridableComponents.ts diff --git a/.changeset/hungry-points-burn.md b/.changeset/hungry-points-burn.md new file mode 100644 index 0000000000..c0dabf0446 --- /dev/null +++ b/.changeset/hungry-points-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Added 'root' and 'label' class keys for EntityAutocompletePicker, EntityOwnerPicker and EntityProcessingStatusPicker diff --git a/.changeset/pretty-boats-promise.md b/.changeset/pretty-boats-promise.md new file mode 100644 index 0000000000..751ccaf14c --- /dev/null +++ b/.changeset/pretty-boats-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added 'root' and 'label' class key to TemplateCategoryPicker diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 62bdf3f2c9..a492a72e9f 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -93,8 +93,12 @@ export type CatalogReactComponentsNameToClassKey = { CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey; CatalogReactEntityOwnerPicker: CatalogReactEntityOwnerPickerClassKey; CatalogReactEntityProcessingStatusPicker: CatalogReactEntityProcessingStatusPickerClassKey; + CatalogReactEntityAutocompletePickerClassKey: CatalogReactEntityAutocompletePickerClassKey; }; +// @public (undocumented) +export type CatalogReactEntityAutocompletePickerClassKey = 'root' | 'label'; + // @public export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon'; @@ -105,10 +109,13 @@ export type CatalogReactEntityLifecyclePickerClassKey = 'input'; export type CatalogReactEntityNamespacePickerClassKey = 'input'; // @public (undocumented) -export type CatalogReactEntityOwnerPickerClassKey = 'input'; +export type CatalogReactEntityOwnerPickerClassKey = 'input' | 'root' | 'label'; // @public (undocumented) -export type CatalogReactEntityProcessingStatusPickerClassKey = 'input'; +export type CatalogReactEntityProcessingStatusPickerClassKey = + | 'input' + | 'root' + | 'label'; // @public (undocumented) export type CatalogReactEntitySearchBarClassKey = 'searchToolbar' | 'input'; diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index 98e680aa12..1b7539b912 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Box, TextFieldProps, Typography } from '@material-ui/core'; +import { Box, TextFieldProps, Typography, makeStyles } from '@material-ui/core'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { Autocomplete } from '@material-ui/lab'; import React, { useEffect, useMemo, useState } from 'react'; @@ -52,6 +52,17 @@ export type EntityAutocompletePickerProps< initialSelectedOptions?: string[]; }; +/** @public */ +export type CatalogReactEntityAutocompletePickerClassKey = 'root' | 'label'; + +const useStyles = makeStyles( + { + root: {}, + label: {}, + }, + { name: 'CatalogReactEntityAutocompletePicker' }, +); + /** @public */ export function EntityAutocompletePicker< T extends DefaultEntityFilters = DefaultEntityFilters, @@ -67,6 +78,8 @@ export function EntityAutocompletePicker< initialSelectedOptions = [], } = props; + const classes = useStyles(); + const { updateFilters, filters, @@ -127,8 +140,8 @@ export function EntityAutocompletePicker< if (availableOptions.length <= 1) return null; return ( - - + + {label} multiple diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts index 684e7cef55..87811be894 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts @@ -16,6 +16,7 @@ export { EntityAutocompletePicker } from './EntityAutocompletePicker'; export type { + CatalogReactEntityAutocompletePickerClassKey, EntityAutocompletePickerProps, AllowedEntityFilters, } from './EntityAutocompletePicker'; diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 5b5bd39310..87de6411b2 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -44,10 +44,12 @@ import { withStyles } from '@material-ui/core/styles'; import { useEntityPresentation } from '../../apis'; /** @public */ -export type CatalogReactEntityOwnerPickerClassKey = 'input'; +export type CatalogReactEntityOwnerPickerClassKey = 'input' | 'root' | 'label'; const useStyles = makeStyles( { + root: {}, + label: {}, input: {}, fullWidth: { width: '100%' }, boxLabel: { @@ -174,8 +176,8 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { } return ( - - + + Owner { const availableAdvancedItems = ['Is Orphan', 'Has Error']; return ( - - + + Processing Status multiple diff --git a/plugins/catalog-react/src/overridableComponents.ts b/plugins/catalog-react/src/overridableComponents.ts index 6424c95ab5..b45fd30103 100644 --- a/plugins/catalog-react/src/overridableComponents.ts +++ b/plugins/catalog-react/src/overridableComponents.ts @@ -26,6 +26,7 @@ import { CatalogReactEntityOwnerPickerClassKey, CatalogReactEntityProcessingStatusPickerClassKey, } from './components'; +import { CatalogReactEntityAutocompletePickerClassKey } from './components/EntityAutocompletePicker/EntityAutocompletePicker'; /** @public */ export type CatalogReactComponentsNameToClassKey = { @@ -36,6 +37,7 @@ export type CatalogReactComponentsNameToClassKey = { CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey; CatalogReactEntityOwnerPicker: CatalogReactEntityOwnerPickerClassKey; CatalogReactEntityProcessingStatusPicker: CatalogReactEntityProcessingStatusPickerClassKey; + CatalogReactEntityAutocompletePickerClassKey: CatalogReactEntityAutocompletePickerClassKey; }; /** @public */ diff --git a/plugins/scaffolder-react/api-report-alpha.md b/plugins/scaffolder-react/api-report-alpha.md index a521fecc70..683b8cf8be 100644 --- a/plugins/scaffolder-react/api-report-alpha.md +++ b/plugins/scaffolder-react/api-report-alpha.md @@ -16,6 +16,7 @@ import { IconComponent } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { LayoutOptions } from '@backstage/plugin-scaffolder-react'; +import { Overrides } from '@material-ui/core/styles/overrides'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; @@ -25,6 +26,7 @@ import { ScaffolderRJSFFormProps } from '@backstage/plugin-scaffolder-react'; import { ScaffolderStep } from '@backstage/plugin-scaffolder-react'; import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react'; import { SetStateAction } from 'react'; +import { StyleRules } from '@material-ui/core/styles/withStyles'; import { TaskStep } from '@backstage/plugin-scaffolder-common'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; @@ -32,6 +34,13 @@ import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; import { TemplatePresentationV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UiSchema } from '@rjsf/utils'; +// @alpha (undocumented) +export type BackstageOverrides = Overrides & { + [Name in keyof ScaffolderReactComponentsNameToClassKey]?: Partial< + StyleRules + >; +}; + // @alpha (undocumented) export const createAsyncValidators: ( rootSchema: JsonObject, @@ -132,6 +141,14 @@ export type ScaffolderPageContextMenuProps = { onCreateClicked?: () => void; }; +// @alpha (undocumented) +export type ScaffolderReactComponentsNameToClassKey = { + ScaffolderReactTemplateCategoryPicker: ScaffolderReactTemplateCategoryPickerClassKey; +}; + +// @alpha (undocumented) +export type ScaffolderReactTemplateCategoryPickerClassKey = 'root' | 'label'; + // @alpha export const Stepper: (stepperProps: StepperProps) => React_2.JSX.Element; diff --git a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.tsx b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.tsx index eba6aebf93..9c85cacfc2 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.tsx @@ -23,6 +23,7 @@ import { FormControlLabel, TextField, Typography, + makeStyles, } from '@material-ui/core'; import CheckBoxIcon from '@material-ui/icons/CheckBox'; import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; @@ -34,12 +35,24 @@ import { alertApiRef, useApi } from '@backstage/core-plugin-api'; const icon = ; const checkedIcon = ; +/** @alpha */ +export type ScaffolderReactTemplateCategoryPickerClassKey = 'root' | 'label'; + +const useStyles = makeStyles( + { + root: {}, + label: {}, + }, + { name: 'ScaffolderReactTemplateCategoryPicker' }, +); + /** * The Category Picker that is rendered on the left side for picking * categories and filtering the template list. * @alpha */ export const TemplateCategoryPicker = () => { + const classes = useStyles(); const alertApi = useApi(alertApiRef); const { error, loading, availableTypes, selectedTypes, setSelectedTypes } = useEntityTypeFilter(); @@ -57,8 +70,9 @@ export const TemplateCategoryPicker = () => { if (!availableTypes) return null; return ( - + + >; +}; + +declare module '@backstage/theme' { + interface OverrideComponentNameToClassKeys + extends ScaffolderReactComponentsNameToClassKey {} +} From fb02400618e78b7f8c1c4a3e0423ca262a494562 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:24:22 +0000 Subject: [PATCH 106/176] fix(deps): update dependency mysql2 to v3.9.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 5439b536e2..83f9132421 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35590,8 +35590,8 @@ __metadata: linkType: hard "mysql2@npm:^3.0.0": - version: 3.9.1 - resolution: "mysql2@npm:3.9.1" + version: 3.9.2 + resolution: "mysql2@npm:3.9.2" dependencies: denque: ^2.1.0 generate-function: ^2.3.1 @@ -35601,7 +35601,7 @@ __metadata: named-placeholders: ^1.1.3 seq-queue: ^0.0.5 sqlstring: ^2.3.2 - checksum: 067353f8735d3e91654ecc01f562729c87f4fa141e870d524af06c5db98ac3341d91f3130357fead247833f41b1b93d1c6dd6e1f1b98687d28998bd9673b9ce7 + checksum: a236a52659d67812af494bc41d09a2bd906d12755887ba75bbdf271c25ad5668030e0a2e91dc61ba4b3b225fb2b5a6aa07a168e077fcdaf50d6a73930c11de5d languageName: node linkType: hard From e998fb7889157311e0ebf3819b4abaf74c0c6a66 Mon Sep 17 00:00:00 2001 From: Harrison Hogg <7130591+HHogg@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:29:20 +0000 Subject: [PATCH 107/176] Update plugins/scaffolder-react/src/next/overridableComponents.ts Co-authored-by: Philipp Hugenroth Signed-off-by: Harrison Hogg <7130591+HHogg@users.noreply.github.com> --- plugins/scaffolder-react/src/next/overridableComponents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-react/src/next/overridableComponents.ts b/plugins/scaffolder-react/src/next/overridableComponents.ts index 0a97a18645..56986d4f80 100644 --- a/plugins/scaffolder-react/src/next/overridableComponents.ts +++ b/plugins/scaffolder-react/src/next/overridableComponents.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. From ce73c3b3ebec5a04ad9c59675ab03cd7352681cc Mon Sep 17 00:00:00 2001 From: Harrison Hogg Date: Mon, 26 Feb 2024 15:20:42 +0000 Subject: [PATCH 108/176] Removed inline color of select icon Signed-off-by: Harrison Hogg --- .changeset/modern-impalas-add.md | 5 +++++ .../src/components/Select/static/ClosedDropdown.tsx | 3 ++- .../src/components/Select/static/OpenedDropdown.tsx | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/modern-impalas-add.md diff --git a/.changeset/modern-impalas-add.md b/.changeset/modern-impalas-add.md new file mode 100644 index 0000000000..8964e07fea --- /dev/null +++ b/.changeset/modern-impalas-add.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Removed the inline color from select icon to allow it to be colored via a theme diff --git a/packages/core-components/src/components/Select/static/ClosedDropdown.tsx b/packages/core-components/src/components/Select/static/ClosedDropdown.tsx index 812afe34eb..00c748ce29 100644 --- a/packages/core-components/src/components/Select/static/ClosedDropdown.tsx +++ b/packages/core-components/src/components/Select/static/ClosedDropdown.tsx @@ -27,6 +27,7 @@ const useStyles = makeStyles( position: 'absolute', right: theme.spacing(0.5), pointerEvents: 'none', + color: '#616161', }, }), { name: 'BackstageClosedDropdown' }, @@ -42,7 +43,7 @@ const ClosedDropdown = () => { > ); diff --git a/packages/core-components/src/components/Select/static/OpenedDropdown.tsx b/packages/core-components/src/components/Select/static/OpenedDropdown.tsx index b87a00c26a..4ca2a5a065 100644 --- a/packages/core-components/src/components/Select/static/OpenedDropdown.tsx +++ b/packages/core-components/src/components/Select/static/OpenedDropdown.tsx @@ -26,6 +26,7 @@ const useStyles = makeStyles( position: 'absolute', right: theme.spacing(0.5), pointerEvents: 'none', + color: '#616161', }, }), { name: 'BackstageOpenedDropdown' }, @@ -41,7 +42,7 @@ const OpenedDropdown = () => { > ); From a8d046319e52902f95bc8c0139239512c958b703 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 15:38:48 -0500 Subject: [PATCH 109/176] create a new guest auth provider. running into an issue on reload of an active state Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- app-config.yaml | 4 + packages/app-defaults/src/defaults/apis.ts | 17 +++ packages/app/src/App.tsx | 2 +- packages/app/src/identityProviders.ts | 7 ++ packages/backend/package.json | 1 + packages/backend/src/plugins/auth.ts | 2 + .../implementations/auth/guest/GuestAuth.ts | 113 ++++++++++++++++++ .../apis/implementations/auth/guest/index.ts | 16 +++ .../src/apis/implementations/auth/index.ts | 1 + .../src/apis/definitions/auth.ts | 12 ++ .../.eslintrc.js | 1 + .../README.md | 5 + .../package.json | 42 +++++++ .../src/createGuestAuthFactory.ts | 54 +++++++++ .../src/createGuestAuthRouteHandlers.ts | 111 +++++++++++++++++ .../src/index.ts | 25 ++++ .../src/module.ts | 44 +++++++ .../src/resolvers.ts | 40 +++++++ .../src/types.ts | 25 ++++ .../auth-backend/src/providers/guest/index.ts | 16 +++ .../src/providers/guest/provider.ts | 49 ++++++++ .../auth-backend/src/providers/providers.ts | 3 + yarn.lock | 34 +++++- 23 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/guest/index.ts create mode 100644 plugins/auth-backend-module-guest-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-guest-provider/README.md create mode 100644 plugins/auth-backend-module-guest-provider/package.json create mode 100644 plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts create mode 100644 plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts create mode 100644 plugins/auth-backend-module-guest-provider/src/index.ts create mode 100644 plugins/auth-backend-module-guest-provider/src/module.ts create mode 100644 plugins/auth-backend-module-guest-provider/src/resolvers.ts create mode 100644 plugins/auth-backend-module-guest-provider/src/types.ts create mode 100644 plugins/auth-backend/src/providers/guest/index.ts create mode 100644 plugins/auth-backend/src/providers/guest/provider.ts diff --git a/app-config.yaml b/app-config.yaml index 9b059da216..df0fd153f8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -399,6 +399,10 @@ auth: scopes: ${AUTH_ATLASSIAN_SCOPES} myproxy: development: {} + guest: + development: + clientId: t123 + clientSecret: test123 costInsights: engineerCost: 200000 engineerThreshold: 0.5 diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 4e9e1a492c..3285b05dcf 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -35,6 +35,7 @@ import { createFetchApi, FetchMiddlewares, VMwareCloudAuth, + GuestAuth, } from '@backstage/core-app-api'; import { @@ -58,6 +59,7 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, + guestAuthApiRef, } from '@backstage/core-plugin-api'; import { permissionApiRef, @@ -277,6 +279,21 @@ export const apis = [ }); }, }), + + createApiFactory({ + api: guestAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => { + return GuestAuth.create({ + configApi, + discoveryApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), createApiFactory({ api: permissionApiRef, deps: { diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3d8bd45e5a..5357ad4d16 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -128,7 +128,7 @@ const app = createApp({ return ( diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 66f1460210..9f2ed58e8d 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -23,6 +23,7 @@ import { oneloginAuthApiRef, bitbucketAuthApiRef, bitbucketServerAuthApiRef, + guestAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ @@ -74,4 +75,10 @@ export const providers = [ message: 'Sign In using Bitbucket Server', apiRef: bitbucketServerAuthApiRef, }, + { + id: 'guest-auth-provider', + title: 'Guest', + message: 'Sign in as a guest', + apiRef: guestAuthApiRef, + }, ]; diff --git a/packages/backend/package.json b/packages/backend/package.json index e6102b69cf..989d64eeec 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,6 +35,7 @@ "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", + "@backstage/plugin-auth-backend-module-guest-provider": "^0.0.0", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-common": "workspace:^", diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 0d92315f92..773d3f4270 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -141,6 +141,8 @@ export default async function createPlugin( }, }, }), + + guest: providers.guest.create(), }, }); } diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts new file mode 100644 index 0000000000..88d4612973 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts @@ -0,0 +1,113 @@ +/* + * 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 { + AuthRequestOptions, + BackstageIdentityApi, + ProfileInfo, + ProfileInfoApi, + SessionApi, + SessionState, + BackstageIdentityResponse, +} from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/types'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { AuthApiCreateOptions } from '../types'; + +type GuestSession = { + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; + +const DEFAULT_PROVIDER = { + id: 'guest', + title: 'Guest', + icon: () => null, +}; + +/** + * Implements a guest auth flow. + * + * @public + */ +export default class GuestAuth + implements ProfileInfoApi, BackstageIdentityApi, SessionApi +{ + static create(options: AuthApiCreateOptions) { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + } = options; + + const connector = new DirectAuthConnector({ + discoveryApi, + environment, + provider, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([]), + sessionScopes: (_: GuestSession) => new Set(), + sessionShouldRefresh: (session: GuestSession) => { + let min = Infinity; + if (session.backstageIdentity?.expiresAt) { + min = Math.min( + min, + (session.backstageIdentity.expiresAt.getTime() - Date.now()) / 1000, + ); + } + return min < 60 * 5; + }, + }); + + return new GuestAuth({ sessionManager }); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + private readonly sessionManager: SessionManager; + + private constructor(options: { + sessionManager: SessionManager; + }) { + this.sessionManager = options.sessionManager; + } + + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } +} diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/index.ts b/packages/core-app-api/src/apis/implementations/auth/guest/index.ts new file mode 100644 index 0000000000..42db58cfe6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/guest/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { default as GuestAuth } from './GuestAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index e02e07961a..58db084760 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -26,4 +26,5 @@ export * from './bitbucket'; export * from './bitbucketServer'; export * from './atlassian'; export * from './vmwareCloud'; +export * from './guest'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index d89544cf68..b11352b373 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -469,3 +469,15 @@ export const vmwareCloudAuthApiRef: ApiRef< > = createApiRef({ id: 'core.auth.vmware-cloud', }); + +/** + * Provides guest authentication support. + * + * @public + * @remarks + */ +export const guestAuthApiRef: ApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.guest', +}); diff --git a/plugins/auth-backend-module-guest-provider/.eslintrc.js b/plugins/auth-backend-module-guest-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-guest-provider/README.md b/plugins/auth-backend-module-guest-provider/README.md new file mode 100644 index 0000000000..65da015958 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/README.md @@ -0,0 +1,5 @@ +# backstage-plugin-auth-backend-module-guest-provider + +The guest-provider backend module for the auth plugin. + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/auth-backend-module-guest-provider/package.json b/plugins/auth-backend-module-guest-provider/package.json new file mode 100644 index 0000000000..c35162fd71 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/package.json @@ -0,0 +1,42 @@ +{ + "name": "@backstage/plugin-auth-backend-module-guest-provider", + "description": "The guest-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "passport-oauth2": "^1.7.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "express": "^4.18.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts new file mode 100644 index 0000000000..8331870528 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2023 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 { SignInResolverFactory } from '@backstage/plugin-auth-node'; +import type { + AuthProviderFactory, + ProfileTransform, + SignInResolver, +} from '@backstage/plugin-auth-node'; +import { createGuestAuthRouteHandlers } from './createGuestAuthRouteHandlers'; +import { GuestInfo } from './types'; +import { guestResolver } from './resolvers'; + +/** @public */ +export function createGuestAuthProviderFactory(options?: { + profileTransform?: ProfileTransform; + signInResolver?: SignInResolver; + signInResolverFactories?: Record< + string, + SignInResolverFactory + >; +}): AuthProviderFactory { + return ctx => { + const signInResolver = options?.signInResolver ?? guestResolver(); + + if (!signInResolver) { + throw new Error( + `No sign-in resolver configured for guest auth provider '${ctx.providerId}'`, + ); + } + + return createGuestAuthRouteHandlers({ + signInResolver, + baseUrl: ctx.baseUrl, + appUrl: ctx.appUrl, + config: ctx.config, + resolverContext: ctx.resolverContext, + profileTransform: options?.profileTransform, + }); + }; +} diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts new file mode 100644 index 0000000000..07d0d3e87a --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts @@ -0,0 +1,111 @@ +/* + * 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 type { Request, Response } from 'express'; +import type { Config } from '@backstage/config'; +import { + AuthProviderRouteHandlers, + AuthResolverContext, + ClientAuthResponse, + ProfileTransform, + SignInResolver, + prepareBackstageIdentityResponse, + sendWebMessageResponse, +} from '@backstage/plugin-auth-node'; +import { GuestInfo } from './types'; + +/** @public */ +export interface GuestAuthRouteHandlersOptions { + config: Config; + baseUrl: string; + appUrl: string; + resolverContext: AuthResolverContext; + signInResolver: SignInResolver; + profileTransform?: ProfileTransform; +} + +const DEFAULT_RESULT: GuestInfo = { name: 'Guest' }; + +/** @public */ +export function createGuestAuthRouteHandlers( + options: GuestAuthRouteHandlersOptions, +): AuthProviderRouteHandlers { + const { resolverContext, signInResolver, appUrl } = options; + + const defaultTransform: ProfileTransform = async result => { + return { + profile: { + displayName: result.name, + }, + }; + }; + + const profileTransform = options.profileTransform ?? defaultTransform; + return { + async start(_, res): Promise { + res.redirect('handler/frame'); + }, + + async frameHandler(_, res): Promise { + const { profile } = await profileTransform( + DEFAULT_RESULT, + resolverContext, + ); + const response: ClientAuthResponse = { + profile, + providerInfo: { + name: 'Guest', + }, + }; + if (signInResolver) { + const identity = await signInResolver( + { profile, result: DEFAULT_RESULT }, + resolverContext, + ); + response.backstageIdentity = prepareBackstageIdentityResponse(identity); + } + // post message back to popup if successful + sendWebMessageResponse(res, appUrl, { + type: 'authorization_response', + response, + }); + }, + + async refresh(this: never, _: Request, res: Response): Promise { + const { profile } = await profileTransform( + DEFAULT_RESULT, + resolverContext, + ); + + const identity = await signInResolver( + { profile, result: DEFAULT_RESULT }, + resolverContext, + ); + + const response: ClientAuthResponse<{}> = { + profile, + providerInfo: {}, + backstageIdentity: prepareBackstageIdentityResponse(identity), + }; + + res.status(200).json(response); + }, + + async logout(_, res) { + res.end(); + }, + }; +} diff --git a/plugins/auth-backend-module-guest-provider/src/index.ts b/plugins/auth-backend-module-guest-provider/src/index.ts new file mode 100644 index 0000000000..b1a89763b9 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * The guest-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { createGuestAuthProviderFactory } from './createGuestAuthFactory'; +export type { GuestInfo } from './types'; +export { authModuleGuestProvider as default } from './module'; diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts new file mode 100644 index 0000000000..c9fd3feea4 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -0,0 +1,44 @@ +/* + * 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { + createOAuthProviderFactory, + commonSignInResolvers, + authProvidersExtensionPoint, +} from '@backstage/plugin-auth-node'; +import { createGuestAuthProviderFactory } from './createGuestAuthFactory'; + +export const authModuleGuestProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'guest-provider', + register(reg) { + reg.registerInit({ + deps: { + logger: coreServices.logger, + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'guest', + factory: createGuestAuthProviderFactory(), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts new file mode 100644 index 0000000000..47d340f013 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -0,0 +1,40 @@ +/* + * 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { createSignInResolverFactory } from '@backstage/plugin-auth-node'; + +export const guestResolver = createSignInResolverFactory({ + create() { + return async (_, ctx) => { + const userRef = stringifyEntityRef({ + kind: 'user', + name: 'guest', + }); + try { + return ctx.signInWithCatalogUser({ entityRef: userRef }); + } catch (err) { + // We can't guarantee that a guest user exists in the catalog, so we issue a token directly, + return ctx.issueToken({ + claims: { + sub: userRef, + ent: [userRef], + }, + }); + } + }; + }, +}); diff --git a/plugins/auth-backend-module-guest-provider/src/types.ts b/plugins/auth-backend-module-guest-provider/src/types.ts new file mode 100644 index 0000000000..9d0ace0a33 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/src/types.ts @@ -0,0 +1,25 @@ +/* + * 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 { ProfileTransform } from '@backstage/plugin-auth-node'; + +export type GuestInfo = { + name: string; +}; + +export interface GuestAuthenticator { + defaultProfileTransform: ProfileTransform; +} diff --git a/plugins/auth-backend/src/providers/guest/index.ts b/plugins/auth-backend/src/providers/guest/index.ts new file mode 100644 index 0000000000..7b384798b0 --- /dev/null +++ b/plugins/auth-backend/src/providers/guest/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { guest } from './provider'; diff --git a/plugins/auth-backend/src/providers/guest/provider.ts b/plugins/auth-backend/src/providers/guest/provider.ts new file mode 100644 index 0000000000..7d3a9e724c --- /dev/null +++ b/plugins/auth-backend/src/providers/guest/provider.ts @@ -0,0 +1,49 @@ +/* + * 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 { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { AuthHandler, SignInResolver } from '../types'; +import { createGuestAuthProviderFactory } from '@backstage/plugin-auth-backend-module-guest-provider'; +import { GuestInfo } from '@backstage/plugin-auth-backend-module-guest-provider'; + +/** + * Auth provider integration for Google auth + * + * @public + */ +export const guest = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; + }) { + return createGuestAuthProviderFactory({ + profileTransform: options?.authHandler, + signInResolver: options?.signIn?.resolver, + }); + }, +}); diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index 76ac51f662..d527bf8b13 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -30,6 +30,7 @@ import { oidc } from './oidc'; import { okta } from './okta'; import { onelogin } from './onelogin'; import { saml } from './saml'; +import { guest } from './guest'; import { bitbucketServer } from './bitbucketServer'; import { easyAuth } from './azure-easyauth'; import { AuthProviderFactory } from '@backstage/plugin-auth-node'; @@ -58,6 +59,7 @@ export const providers = Object.freeze({ onelogin, saml, easyAuth, + guest, }); /** @@ -83,4 +85,5 @@ export const defaultAuthProviderFactories: { bitbucket: bitbucket.create(), bitbucketServer: bitbucketServer.create(), atlassian: atlassian.create(), + guest: guest.create(), }; diff --git a/yarn.lock b/yarn.lock index 73413df542..d1af60c782 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,6 +1,3 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - __metadata: version: 6 cacheKey: 8 @@ -4682,6 +4679,22 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-guest-provider@^0.0.0, @backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + express: ^4.18.2 + passport-oauth2: ^1.7.0 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-microsoft-provider@workspace:^, @backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider" @@ -4838,6 +4851,7 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-guest-provider": ^0.0.0 "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" @@ -27446,6 +27460,7 @@ __metadata: "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-backend-module-guest-provider": ^0.0.0 "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-common": "workspace:^" @@ -37346,6 +37361,19 @@ __metadata: languageName: node linkType: hard +"passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0": + version: 1.7.0 + resolution: "passport-oauth2@npm:1.7.0" + dependencies: + base64url: 3.x.x + oauth: 0.10.x + passport-strategy: 1.x.x + uid2: 0.0.x + utils-merge: 1.x.x + checksum: a9a80b968343c9c1906f74ef613b346ec2d6a6acfe17af81e673fd774779b436729252485755c3ce182f2cdba2434d75067418952d722404d65b93c0360ca02b + languageName: node + linkType: hard + "passport-oauth@npm:1.0.0, passport-oauth@npm:^1.0.0": version: 1.0.0 resolution: "passport-oauth@npm:1.0.0" From 08b7c8a59434b0a3502dd70cd0d2dc7a9158d5ec Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:08:35 -0500 Subject: [PATCH 110/176] needed to support refreshing the token Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .../implementations/auth/guest/GuestAuth.ts | 4 +- .../lib/AuthConnector/DirectAuthConnector.ts | 2 +- .../RefreshingDirectAuthConnector.ts | 54 +++++++++++++++++++ .../src/createGuestAuthRouteHandlers.ts | 7 ++- .../src/module.ts | 6 +-- 5 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts index 88d4612973..880aa0f675 100644 --- a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts @@ -24,10 +24,10 @@ import { BackstageIdentityResponse, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; +import { RefreshingDirectAuthConnector } from '../../../../lib/AuthConnector/RefreshingDirectAuthConnector'; type GuestSession = { profile: ProfileInfo; @@ -55,7 +55,7 @@ export default class GuestAuth provider = DEFAULT_PROVIDER, } = options; - const connector = new DirectAuthConnector({ + const connector = new RefreshingDirectAuthConnector({ discoveryApi, environment, provider, diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts index 200ba755ac..4cb0553efc 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -70,7 +70,7 @@ export class DirectAuthConnector { } } - private async buildUrl(path: string): Promise { + protected async buildUrl(path: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('auth'); return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; } diff --git a/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts new file mode 100644 index 0000000000..34969bf356 --- /dev/null +++ b/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts @@ -0,0 +1,54 @@ +/* + * 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 { DirectAuthConnector } from './DirectAuthConnector'; + +export class RefreshingDirectAuthConnector< + DirectAuthResponse, +> extends DirectAuthConnector { + async refreshSession(): Promise { + const res = await fetch( + `${await this.buildUrl('/refresh')}&optional=true`, + { + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }, + ).catch(error => { + throw new Error(`Auth refresh request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error( + `Auth refresh request failed, ${res.statusText}`, + ); + error.status = res.status; + throw error; + } + + const authInfo = await res.json(); + + if (authInfo.error) { + const error = new Error(authInfo.error.message); + if (authInfo.error.name) { + error.name = authInfo.error.name; + } + throw error; + } + return authInfo; + } +} diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts index 07d0d3e87a..8d1cfe78b4 100644 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts @@ -56,6 +56,7 @@ export function createGuestAuthRouteHandlers( const profileTransform = options.profileTransform ?? defaultTransform; return { async start(_, res): Promise { + // We are the auth provider for guests, skip this step. res.redirect('handler/frame'); }, @@ -66,9 +67,7 @@ export function createGuestAuthRouteHandlers( ); const response: ClientAuthResponse = { profile, - providerInfo: { - name: 'Guest', - }, + providerInfo: DEFAULT_RESULT, }; if (signInResolver) { const identity = await signInResolver( @@ -97,7 +96,7 @@ export function createGuestAuthRouteHandlers( const response: ClientAuthResponse<{}> = { profile, - providerInfo: {}, + providerInfo: DEFAULT_RESULT, backstageIdentity: prepareBackstageIdentityResponse(identity), }; diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index c9fd3feea4..69b5eba48d 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -17,11 +17,7 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { - createOAuthProviderFactory, - commonSignInResolvers, - authProvidersExtensionPoint, -} from '@backstage/plugin-auth-node'; +import { authProvidersExtensionPoint } from '@backstage/plugin-auth-node'; import { createGuestAuthProviderFactory } from './createGuestAuthFactory'; export const authModuleGuestProvider = createBackendModule({ From 1bedb23da027f2ad5b9e29fcfcb8ab84e9f6d47e Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:10:36 -0500 Subject: [PATCH 111/176] add changesets Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .changeset/cold-boats-sell.md | 5 +++++ .changeset/gentle-starfishes-camp.md | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/cold-boats-sell.md create mode 100644 .changeset/gentle-starfishes-camp.md diff --git a/.changeset/cold-boats-sell.md b/.changeset/cold-boats-sell.md new file mode 100644 index 0000000000..112d9022bc --- /dev/null +++ b/.changeset/cold-boats-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-guest-provider': patch +--- + +Adds a new guest provider that maps guest users to actual tokens. diff --git a/.changeset/gentle-starfishes-camp.md b/.changeset/gentle-starfishes-camp.md new file mode 100644 index 0000000000..229d4c39c9 --- /dev/null +++ b/.changeset/gentle-starfishes-camp.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-plugin-api': minor +'@backstage/app-defaults': minor +'@backstage/core-app-api': minor +'@backstage/plugin-auth-backend': minor +--- + +Adds in support for the new guest provider added by `@backstage/plugin-auth-backend-module-guest-provider`. From 1aedf6c5245f12f80c1f070870da83447605b4d1 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:11:37 -0500 Subject: [PATCH 112/176] update app config to remove unnecessary keys Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- app-config.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index df0fd153f8..57d4941f2f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,8 +401,7 @@ auth: development: {} guest: development: - clientId: t123 - clientSecret: test123 + costInsights: engineerCost: 200000 engineerThreshold: 0.5 From 085ddf4084f67d008d7c9d05d4bd597e8c7d389f Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:31:08 -0500 Subject: [PATCH 113/176] add comments Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- packages/app/src/identityProviders.ts | 12 ++-- .../implementations/auth/guest/GuestAuth.ts | 2 +- .../RefreshingDirectAuthConnector.ts | 6 ++ .../src/createGuestAuthFactory.ts | 21 +++--- .../src/createGuestAuthRouteHandlers.ts | 71 +++++++------------ .../src/index.ts | 1 - .../src/resolvers.ts | 6 ++ .../src/types.ts | 6 +- 8 files changed, 60 insertions(+), 65 deletions(-) diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 9f2ed58e8d..c59a55b9d1 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -27,6 +27,12 @@ import { } from '@backstage/core-plugin-api'; export const providers = [ + { + id: 'guest-auth-provider', + title: 'Guest', + message: 'Sign in as a guest', + apiRef: guestAuthApiRef, + }, { id: 'google-auth-provider', title: 'Google', @@ -75,10 +81,4 @@ export const providers = [ message: 'Sign In using Bitbucket Server', apiRef: bitbucketServerAuthApiRef, }, - { - id: 'guest-auth-provider', - title: 'Guest', - message: 'Sign in as a guest', - apiRef: guestAuthApiRef, - }, ]; diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts index 880aa0f675..b054187373 100644 --- a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts @@ -41,7 +41,7 @@ const DEFAULT_PROVIDER = { }; /** - * Implements a guest auth flow. + * Implements a guest auth flow. Heavily based on SAML flow with added support for refreshing the token. * * @public */ diff --git a/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts index 34969bf356..94f7486210 100644 --- a/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts @@ -16,9 +16,15 @@ import { DirectAuthConnector } from './DirectAuthConnector'; +/** + * Add support for refreshing direct tokens. Used for guest authentication. + */ export class RefreshingDirectAuthConnector< DirectAuthResponse, > extends DirectAuthConnector { + /** + * Pulled from DefaultAuthConnector and adapted for use with DirectAuthConnector. + */ async refreshSession(): Promise { const res = await fetch( `${await this.buildUrl('/refresh')}&optional=true`, diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts index 8331870528..acd08940a3 100644 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts @@ -21,17 +21,21 @@ import type { SignInResolver, } from '@backstage/plugin-auth-node'; import { createGuestAuthRouteHandlers } from './createGuestAuthRouteHandlers'; -import { GuestInfo } from './types'; import { guestResolver } from './resolvers'; +const defaultTransform: ProfileTransform<{}> = async () => { + return { + profile: { + displayName: 'Guest', + }, + }; +}; + /** @public */ export function createGuestAuthProviderFactory(options?: { - profileTransform?: ProfileTransform; - signInResolver?: SignInResolver; - signInResolverFactories?: Record< - string, - SignInResolverFactory - >; + profileTransform?: ProfileTransform<{}>; + signInResolver?: SignInResolver<{}>; + signInResolverFactories?: Record>; }): AuthProviderFactory { return ctx => { const signInResolver = options?.signInResolver ?? guestResolver(); @@ -41,6 +45,7 @@ export function createGuestAuthProviderFactory(options?: { `No sign-in resolver configured for guest auth provider '${ctx.providerId}'`, ); } + const profileTransform = options?.profileTransform ?? defaultTransform; return createGuestAuthRouteHandlers({ signInResolver, @@ -48,7 +53,7 @@ export function createGuestAuthProviderFactory(options?: { appUrl: ctx.appUrl, config: ctx.config, resolverContext: ctx.resolverContext, - profileTransform: options?.profileTransform, + profileTransform, }); }; } diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts index 8d1cfe78b4..ab4f9922cd 100644 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts @@ -25,7 +25,6 @@ import { prepareBackstageIdentityResponse, sendWebMessageResponse, } from '@backstage/plugin-auth-node'; -import { GuestInfo } from './types'; /** @public */ export interface GuestAuthRouteHandlersOptions { @@ -33,77 +32,61 @@ export interface GuestAuthRouteHandlersOptions { baseUrl: string; appUrl: string; resolverContext: AuthResolverContext; - signInResolver: SignInResolver; - profileTransform?: ProfileTransform; + signInResolver: SignInResolver<{}>; + profileTransform: ProfileTransform<{}>; } -const DEFAULT_RESULT: GuestInfo = { name: 'Guest' }; - /** @public */ export function createGuestAuthRouteHandlers( options: GuestAuthRouteHandlersOptions, ): AuthProviderRouteHandlers { - const { resolverContext, signInResolver, appUrl } = options; + const { resolverContext, signInResolver, appUrl, profileTransform } = options; + + const createGuestSession = async (): Promise> => { + const { profile } = await profileTransform({}, resolverContext); + + const identity = await signInResolver( + { profile, result: {} }, + resolverContext, + ); - const defaultTransform: ProfileTransform = async result => { return { - profile: { - displayName: result.name, - }, + profile, + providerInfo: {}, + backstageIdentity: prepareBackstageIdentityResponse(identity), }; }; - const profileTransform = options.profileTransform ?? defaultTransform; return { async start(_, res): Promise { // We are the auth provider for guests, skip this step. res.redirect('handler/frame'); }, + /** + * This is where we create the token for the guest user. You can override the + * entityRef for the guest user with `signInResolver`. + */ async frameHandler(_, res): Promise { - const { profile } = await profileTransform( - DEFAULT_RESULT, - resolverContext, - ); - const response: ClientAuthResponse = { - profile, - providerInfo: DEFAULT_RESULT, - }; - if (signInResolver) { - const identity = await signInResolver( - { profile, result: DEFAULT_RESULT }, - resolverContext, - ); - response.backstageIdentity = prepareBackstageIdentityResponse(identity); - } + const session = await createGuestSession(); // post message back to popup if successful sendWebMessageResponse(res, appUrl, { type: 'authorization_response', - response, + response: session, }); }, + /** + * Support refreshing the guest user's token. This should just improve the experience of + * browsing while in guest mode. + */ async refresh(this: never, _: Request, res: Response): Promise { - const { profile } = await profileTransform( - DEFAULT_RESULT, - resolverContext, - ); - - const identity = await signInResolver( - { profile, result: DEFAULT_RESULT }, - resolverContext, - ); - - const response: ClientAuthResponse<{}> = { - profile, - providerInfo: DEFAULT_RESULT, - backstageIdentity: prepareBackstageIdentityResponse(identity), - }; - - res.status(200).json(response); + const session = await createGuestSession(); + res.status(200).json(session); }, async logout(_, res) { + // If we don't send a response or it gets cached into a 204, the page will hang. res.end(); }, }; diff --git a/plugins/auth-backend-module-guest-provider/src/index.ts b/plugins/auth-backend-module-guest-provider/src/index.ts index b1a89763b9..0c4a382a87 100644 --- a/plugins/auth-backend-module-guest-provider/src/index.ts +++ b/plugins/auth-backend-module-guest-provider/src/index.ts @@ -21,5 +21,4 @@ */ export { createGuestAuthProviderFactory } from './createGuestAuthFactory'; -export type { GuestInfo } from './types'; export { authModuleGuestProvider as default } from './module'; diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts index 47d340f013..5922530c5c 100644 --- a/plugins/auth-backend-module-guest-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -17,6 +17,12 @@ import { stringifyEntityRef } from '@backstage/catalog-model'; import { createSignInResolverFactory } from '@backstage/plugin-auth-node'; +/** + * Provide a default implementation of the user to resolve to. By default, this + * is `user:default/guest`. We will attempt to get that user if they're in the + * catalog. If that user doesn't exist in the catalog, we will still create a + * token for them so they can keep viewing. + */ export const guestResolver = createSignInResolverFactory({ create() { return async (_, ctx) => { diff --git a/plugins/auth-backend-module-guest-provider/src/types.ts b/plugins/auth-backend-module-guest-provider/src/types.ts index 9d0ace0a33..c831014cb5 100644 --- a/plugins/auth-backend-module-guest-provider/src/types.ts +++ b/plugins/auth-backend-module-guest-provider/src/types.ts @@ -16,10 +16,6 @@ import { ProfileTransform } from '@backstage/plugin-auth-node'; -export type GuestInfo = { - name: string; -}; - export interface GuestAuthenticator { - defaultProfileTransform: ProfileTransform; + defaultProfileTransform: ProfileTransform<{}>; } From bb710815b2fd76562c6fa1cdc225fff7f6ff7811 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:53:43 -0500 Subject: [PATCH 114/176] adding more documentation Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .../src/layout/SignInPage/providers.tsx | 1 + .../README.md | 78 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index e456a6948b..20613b7509 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -43,6 +43,7 @@ export type SignInProviderType = { }; const signInProviders: { [key: string]: SignInProvider } = { + /** @deprecated Use `@backstage/plugin-auth-backend-module-guest-provider` */ guest: guestProvider, custom: customProvider, common: commonProvider, diff --git a/plugins/auth-backend-module-guest-provider/README.md b/plugins/auth-backend-module-guest-provider/README.md index 65da015958..9c297aebe6 100644 --- a/plugins/auth-backend-module-guest-provider/README.md +++ b/plugins/auth-backend-module-guest-provider/README.md @@ -1,5 +1,77 @@ -# backstage-plugin-auth-backend-module-guest-provider +# Auth Module: Guest Provider -The guest-provider backend module for the auth plugin. +This module provides a guest auth provider implementation for `@backstage/plugin-auth-backend`. This is meant to supersede the existing `'guest'` option for authentication that does not emit tokens and is completely stored as frontend state. -_This plugin was created through the Backstage CLI_ +**NOTE**: + +## Installation + +### Backend + +#### New Backend + +```diff +const backend = createBackend(); +... + ++backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); + +... +backend.start(); +``` + +#### Old Backend + +This module was also backported for the old backend and can be used like so, + +```diff ++import { ++ providers, ++} from '@backstage/plugin-auth-backend'; + .... + return await createRouter({ + ... + providerFactories: { + gitlab: providers.gitlab(), ++ guest: providers.guest(), + ... + } + ... +``` + +### Frontend + +Add the following to your `SignInPage` providers, + +```diff ++import { ++ guestAuthApiRef, ++} from '@backstage/core-plugin-api'; + +const providers = [ ++ { ++ id: 'guest-auth-provider', ++ title: 'Guest', ++ message: 'Sign in as a guest', ++ apiRef: guestAuthApiRef, ++ }, + ... +``` + +### Config + +Similar to the other authentication providers, you have to enable the provider in config. Add the following to your `app-config.local.yaml`, + +```diff +auth: + providers: ++ guest: ++ development: {} +``` + +We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object. + +## Links + +- [Backstage](https://backstage.io) +- [Repository](https://github.com/backstage/backstage/tree/master/plugins/auth-backend-module-guest-provider) From d1be48bcfabb3df44006afdf75636dbe31da68e8 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 16:59:44 -0500 Subject: [PATCH 115/176] add warning and prevent startup in production. Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- plugins/auth-backend-module-guest-provider/README.md | 2 +- plugins/auth-backend-module-guest-provider/src/module.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend-module-guest-provider/README.md b/plugins/auth-backend-module-guest-provider/README.md index 9c297aebe6..471b522859 100644 --- a/plugins/auth-backend-module-guest-provider/README.md +++ b/plugins/auth-backend-module-guest-provider/README.md @@ -2,7 +2,7 @@ This module provides a guest auth provider implementation for `@backstage/plugin-auth-backend`. This is meant to supersede the existing `'guest'` option for authentication that does not emit tokens and is completely stored as frontend state. -**NOTE**: +**NOTE**: This provider should only ever be enabled for `development` or `test`. Enabling this for production is strongly discouraged as it would give everyone a way to bypass your other authentication methods. ## Installation diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index 69b5eba48d..ad5c8c95a0 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -30,6 +30,11 @@ export const authModuleGuestProvider = createBackendModule({ providers: authProvidersExtensionPoint, }, async init({ providers }) { + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'Guest provider does not support authenticating production workloads.', + ); + } providers.registerProvider({ providerId: 'guest', factory: createGuestAuthProviderFactory(), From a83eb21b89bc05135329fa07493d48d821a7483b Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:02:11 -0500 Subject: [PATCH 116/176] add object instead of empty Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- app-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-config.yaml b/app-config.yaml index 57d4941f2f..e18c45aefa 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -400,7 +400,7 @@ auth: myproxy: development: {} guest: - development: + development: {} costInsights: engineerCost: 200000 From a8f7904588980b8a0f13e9e9cedf191ecdb36585 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:07:59 -0500 Subject: [PATCH 117/176] fix build issues Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .changeset/selfish-glasses-cheer.md | 5 +++++ packages/backend/package.json | 1 - plugins/auth-backend-module-guest-provider/package.json | 1 - yarn.lock | 4 +--- 4 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 .changeset/selfish-glasses-cheer.md diff --git a/.changeset/selfish-glasses-cheer.md b/.changeset/selfish-glasses-cheer.md new file mode 100644 index 0000000000..0a56a8b489 --- /dev/null +++ b/.changeset/selfish-glasses-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': minor +--- + +**DEPRECATED** `SignInPage`'s `'guest'` provider is deprecated. Use `@backstage/plugin-auth-backend-module-guest-provider` instead. diff --git a/packages/backend/package.json b/packages/backend/package.json index 989d64eeec..e6102b69cf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,7 +35,6 @@ "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", - "@backstage/plugin-auth-backend-module-guest-provider": "^0.0.0", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-common": "workspace:^", diff --git a/plugins/auth-backend-module-guest-provider/package.json b/plugins/auth-backend-module-guest-provider/package.json index c35162fd71..ae50007225 100644 --- a/plugins/auth-backend-module-guest-provider/package.json +++ b/plugins/auth-backend-module-guest-provider/package.json @@ -5,7 +5,6 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", diff --git a/yarn.lock b/yarn.lock index d1af60c782..f2f121736c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4679,7 +4679,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-guest-provider@^0.0.0, @backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider": +"@backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider" dependencies: @@ -4851,7 +4851,6 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" - "@backstage/plugin-auth-backend-module-guest-provider": ^0.0.0 "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" @@ -27460,7 +27459,6 @@ __metadata: "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" - "@backstage/plugin-auth-backend-module-guest-provider": ^0.0.0 "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-common": "workspace:^" From 8d8e37abcc57c82746c5c22b24da5ba65a0a3b2f Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:12:58 -0500 Subject: [PATCH 118/176] fix tsc issue Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- plugins/auth-backend/src/providers/guest/provider.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/src/providers/guest/provider.ts b/plugins/auth-backend/src/providers/guest/provider.ts index 7d3a9e724c..2efc584d5d 100644 --- a/plugins/auth-backend/src/providers/guest/provider.ts +++ b/plugins/auth-backend/src/providers/guest/provider.ts @@ -16,7 +16,6 @@ import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { AuthHandler, SignInResolver } from '../types'; import { createGuestAuthProviderFactory } from '@backstage/plugin-auth-backend-module-guest-provider'; -import { GuestInfo } from '@backstage/plugin-auth-backend-module-guest-provider'; /** * Auth provider integration for Google auth @@ -29,7 +28,7 @@ export const guest = createAuthProviderIntegration({ * The profile transformation function used to verify and convert the auth response * into the profile that will be presented to the user. */ - authHandler?: AuthHandler; + authHandler?: AuthHandler<{}>; /** * Configure sign-in for this provider, without it the provider can not be used to sign users in. @@ -38,7 +37,7 @@ export const guest = createAuthProviderIntegration({ /** * Maps an auth result to a Backstage identity for the user. */ - resolver: SignInResolver; + resolver: SignInResolver<{}>; }; }) { return createGuestAuthProviderFactory({ From 4506a1b2241c390d7e18e6338d09b9401f263cce Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:13:38 -0500 Subject: [PATCH 119/176] add dependency Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- plugins/auth-backend/package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 456cc18af2..3ea1d26475 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -49,6 +49,7 @@ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^", "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^", diff --git a/yarn.lock b/yarn.lock index f2f121736c..769d1f52a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4679,7 +4679,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider": +"@backstage/plugin-auth-backend-module-guest-provider@workspace:^, @backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-guest-provider@workspace:plugins/auth-backend-module-guest-provider" dependencies: @@ -4851,6 +4851,7 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" From d4b0688c6d9f1fdf11448803f600452f51b9d808 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:33:34 -0500 Subject: [PATCH 120/176] fix test case Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- packages/frontend-app-api/src/wiring/createApp.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 70f8cfa2ae..af9bfaf751 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -292,6 +292,7 @@ describe('createApp', () => { + ] " From 68c6f67f0cacd0b138cc7a80d37aaf5387d33a3b Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 17:38:13 -0500 Subject: [PATCH 121/176] fix visibility tags Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .../api-report.md | 22 +++++++++++++++++++ .../src/createGuestAuthRouteHandlers.ts | 2 -- .../src/module.ts | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 plugins/auth-backend-module-guest-provider/api-report.md diff --git a/plugins/auth-backend-module-guest-provider/api-report.md b/plugins/auth-backend-module-guest-provider/api-report.md new file mode 100644 index 0000000000..adaf4313fb --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-auth-backend-module-guest-provider" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type { AuthProviderFactory } from '@backstage/plugin-auth-node'; +import { BackendFeature } from '@backstage/backend-plugin-api'; +import type { ProfileTransform } from '@backstage/plugin-auth-node'; +import type { SignInResolver } from '@backstage/plugin-auth-node'; +import { SignInResolverFactory } from '@backstage/plugin-auth-node'; + +// @public (undocumented) +const authModuleGuestProvider: () => BackendFeature; +export default authModuleGuestProvider; + +// @public (undocumented) +export function createGuestAuthProviderFactory(options?: { + profileTransform?: ProfileTransform<{}>; + signInResolver?: SignInResolver<{}>; + signInResolverFactories?: Record>; +}): AuthProviderFactory; +``` diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts index ab4f9922cd..76a69ca75b 100644 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts +++ b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts @@ -26,7 +26,6 @@ import { sendWebMessageResponse, } from '@backstage/plugin-auth-node'; -/** @public */ export interface GuestAuthRouteHandlersOptions { config: Config; baseUrl: string; @@ -36,7 +35,6 @@ export interface GuestAuthRouteHandlersOptions { profileTransform: ProfileTransform<{}>; } -/** @public */ export function createGuestAuthRouteHandlers( options: GuestAuthRouteHandlersOptions, ): AuthProviderRouteHandlers { diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index ad5c8c95a0..4d191ac9e1 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -20,6 +20,7 @@ import { import { authProvidersExtensionPoint } from '@backstage/plugin-auth-node'; import { createGuestAuthProviderFactory } from './createGuestAuthFactory'; +/** @public */ export const authModuleGuestProvider = createBackendModule({ pluginId: 'auth', moduleId: 'guest-provider', From 215a37bc3795ba963eacff41c968403225461886 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 18:06:29 -0500 Subject: [PATCH 122/176] fix api reports again Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- packages/core-app-api/api-report.md | 20 ++++++++++++++++++++ packages/core-plugin-api/api-report.md | 5 +++++ plugins/auth-backend/api-report.md | 15 +++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 8b8cc991c8..0ecb0193db 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -464,6 +464,26 @@ export class GoogleAuth { static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } +// @public +export class GuestAuth + implements ProfileInfoApi, BackstageIdentityApi, SessionApi +{ + // (undocumented) + static create(options: AuthApiCreateOptions): GuestAuth; + // (undocumented) + getBackstageIdentity( + options?: AuthRequestOptions, + ): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; +} + // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 2a993687b3..ad91a0431e 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -503,6 +503,11 @@ export const googleAuthApiRef: ApiRef< SessionApi >; +// @public +export const guestAuthApiRef: ApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +>; + // @public export type IconComponent = ComponentType< | { diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 1beddb6419..431caf3e91 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -644,6 +644,21 @@ export const providers: Readonly<{ ) => AuthProviderFactory_2; resolvers: never; }>; + guest: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler<{}> | undefined; + signIn?: + | { + resolver: SignInResolver<{}>; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory_2; + resolvers: never; + }>; }>; // @public @deprecated (undocumented) From 875d1137c771421a83ad86fba0f6a8613fc97590 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sat, 27 Jan 2024 21:25:44 -0500 Subject: [PATCH 123/176] add catalog-info file Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .../catalog-info.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 plugins/auth-backend-module-guest-provider/catalog-info.yaml diff --git a/plugins/auth-backend-module-guest-provider/catalog-info.yaml b/plugins/auth-backend-module-guest-provider/catalog-info.yaml new file mode 100644 index 0000000000..5d3513296b --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-guest-provider + title: '@backstage/plugin-auth-backend-module-guest-provider' + description: The guest-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers From 1c64b2af45d88056655d169027534dc72874bfb6 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sun, 11 Feb 2024 12:45:38 -0500 Subject: [PATCH 124/176] update to a proxied sign in identity instead of a separate guest provider Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- app-config.yaml | 5 +- packages/app-defaults/src/defaults/apis.ts | 17 --- packages/app/src/App.tsx | 2 +- packages/app/src/identityProviders.ts | 7 -- packages/backend-next/package.json | 1 + packages/backend-next/src/index.ts | 3 + .../implementations/auth/guest/GuestAuth.ts | 113 ------------------ .../apis/implementations/auth/guest/index.ts | 16 --- .../src/apis/implementations/auth/index.ts | 1 - .../lib/AuthConnector/DirectAuthConnector.ts | 2 +- .../layout/SignInPage/GuestUserIdentity.ts | 3 + .../src/layout/SignInPage/guestProvider.tsx | 78 +++++++----- .../src/layout/SignInPage/providers.tsx | 1 - .../src/apis/definitions/auth.ts | 12 -- .../src/wiring/createApp.test.tsx | 1 - .../README.md | 33 +---- .../src/authenticator.ts} | 12 +- .../src/createGuestAuthFactory.ts | 59 --------- .../src/createGuestAuthRouteHandlers.ts | 91 -------------- .../src/index.ts | 1 - .../src/module.ts | 15 ++- .../src/resolvers.ts | 40 +++---- .../src/providers/guest/provider.ts | 48 -------- .../auth-backend/src/providers/providers.ts | 3 - yarn.lock | 19 +-- 25 files changed, 108 insertions(+), 475 deletions(-) delete mode 100644 packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts delete mode 100644 packages/core-app-api/src/apis/implementations/auth/guest/index.ts rename plugins/{auth-backend/src/providers/guest/index.ts => auth-backend-module-guest-provider/src/authenticator.ts} (67%) delete mode 100644 plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts delete mode 100644 plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts delete mode 100644 plugins/auth-backend/src/providers/guest/provider.ts diff --git a/app-config.yaml b/app-config.yaml index e18c45aefa..b864c3b610 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -400,7 +400,10 @@ auth: myproxy: development: {} guest: - development: {} + development: + signIn: + resolvers: + - resolver: guestUser costInsights: engineerCost: 200000 diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 3285b05dcf..4e9e1a492c 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -35,7 +35,6 @@ import { createFetchApi, FetchMiddlewares, VMwareCloudAuth, - GuestAuth, } from '@backstage/core-app-api'; import { @@ -59,7 +58,6 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, - guestAuthApiRef, } from '@backstage/core-plugin-api'; import { permissionApiRef, @@ -279,21 +277,6 @@ export const apis = [ }); }, }), - - createApiFactory({ - api: guestAuthApiRef, - deps: { - discoveryApi: discoveryApiRef, - configApi: configApiRef, - }, - factory: ({ discoveryApi, configApi }) => { - return GuestAuth.create({ - configApi, - discoveryApi, - environment: configApi.getOptionalString('auth.environment'), - }); - }, - }), createApiFactory({ api: permissionApiRef, deps: { diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5357ad4d16..3d8bd45e5a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -128,7 +128,7 @@ const app = createApp({ return ( diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index c59a55b9d1..66f1460210 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -23,16 +23,9 @@ import { oneloginAuthApiRef, bitbucketAuthApiRef, bitbucketServerAuthApiRef, - guestAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ - { - id: 'guest-auth-provider', - title: 'Guest', - message: 'Sign in as a guest', - apiRef: guestAuthApiRef, - }, { id: 'google-auth-provider', title: 'Google', diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index 74016539b0..333484051b 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -33,6 +33,7 @@ "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-badges-backend": "workspace:^", diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index e4dd127208..58fa994658 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -57,4 +57,7 @@ backend.add(import('@backstage/plugin-sonarqube-backend')); backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@backstage/plugin-notifications-backend')); +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); + backend.start(); diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts b/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts deleted file mode 100644 index b054187373..0000000000 --- a/packages/core-app-api/src/apis/implementations/auth/guest/GuestAuth.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 { - AuthRequestOptions, - BackstageIdentityApi, - ProfileInfo, - ProfileInfoApi, - SessionApi, - SessionState, - BackstageIdentityResponse, -} from '@backstage/core-plugin-api'; -import { Observable } from '@backstage/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { AuthApiCreateOptions } from '../types'; -import { RefreshingDirectAuthConnector } from '../../../../lib/AuthConnector/RefreshingDirectAuthConnector'; - -type GuestSession = { - profile: ProfileInfo; - backstageIdentity: BackstageIdentityResponse; -}; - -const DEFAULT_PROVIDER = { - id: 'guest', - title: 'Guest', - icon: () => null, -}; - -/** - * Implements a guest auth flow. Heavily based on SAML flow with added support for refreshing the token. - * - * @public - */ -export default class GuestAuth - implements ProfileInfoApi, BackstageIdentityApi, SessionApi -{ - static create(options: AuthApiCreateOptions) { - const { - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - } = options; - - const connector = new RefreshingDirectAuthConnector({ - discoveryApi, - environment, - provider, - }); - - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set([]), - sessionScopes: (_: GuestSession) => new Set(), - sessionShouldRefresh: (session: GuestSession) => { - let min = Infinity; - if (session.backstageIdentity?.expiresAt) { - min = Math.min( - min, - (session.backstageIdentity.expiresAt.getTime() - Date.now()) / 1000, - ); - } - return min < 60 * 5; - }, - }); - - return new GuestAuth({ sessionManager }); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - private readonly sessionManager: SessionManager; - - private constructor(options: { - sessionManager: SessionManager; - }) { - this.sessionManager = options.sessionManager; - } - - async signIn() { - await this.getBackstageIdentity({}); - } - async signOut() { - await this.sessionManager.removeSession(); - } - - async getBackstageIdentity( - options: AuthRequestOptions = {}, - ): Promise { - const session = await this.sessionManager.getSession(options); - return session?.backstageIdentity; - } - - async getProfile(options: AuthRequestOptions = {}) { - const session = await this.sessionManager.getSession(options); - return session?.profile; - } -} diff --git a/packages/core-app-api/src/apis/implementations/auth/guest/index.ts b/packages/core-app-api/src/apis/implementations/auth/guest/index.ts deleted file mode 100644 index 42db58cfe6..0000000000 --- a/packages/core-app-api/src/apis/implementations/auth/guest/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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. - */ -export { default as GuestAuth } from './GuestAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index 58db084760..e02e07961a 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -26,5 +26,4 @@ export * from './bitbucket'; export * from './bitbucketServer'; export * from './atlassian'; export * from './vmwareCloud'; -export * from './guest'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts index 4cb0553efc..200ba755ac 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -70,7 +70,7 @@ export class DirectAuthConnector { } } - protected async buildUrl(path: string): Promise { + private async buildUrl(path: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('auth'); return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; } diff --git a/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts b/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts index db4731704c..6abb412090 100644 --- a/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts +++ b/packages/core-components/src/layout/SignInPage/GuestUserIdentity.ts @@ -20,6 +20,9 @@ import { BackstageUserIdentity, } from '@backstage/core-plugin-api'; +/** + * @deprecated Use `@backstage/plugin-auth-backend-module-guest-provider` instead. + */ export class GuestUserIdentity implements IdentityApi { getUserId(): string { return 'guest'; diff --git a/packages/core-components/src/layout/SignInPage/guestProvider.tsx b/packages/core-components/src/layout/SignInPage/guestProvider.tsx index 5393c1ea89..018feb2241 100644 --- a/packages/core-components/src/layout/SignInPage/guestProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/guestProvider.tsx @@ -20,39 +20,55 @@ import Button from '@material-ui/core/Button'; import { InfoCard } from '../InfoCard/InfoCard'; import { GridItem } from './styles'; import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; -import { GuestUserIdentity } from './GuestUserIdentity'; +import { ProxiedSignInIdentity } from '../ProxiedSignInPage/ProxiedSignInIdentity'; +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; -const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => ( - - { - onSignInStarted(); - onSignInSuccess(new GuestUserIdentity()); - }} - > - Enter - - } - > - - Enter as a Guest User. -
- You will not have a verified identity, -
- meaning some features might be unavailable. -
-
-
-); +const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => { + const discoveryApi = useApi(discoveryApiRef); + return ( + + { + onSignInStarted(); + onSignInSuccess( + new ProxiedSignInIdentity({ + provider: 'guest', + discoveryApi, + }), + ); + }} + > + Enter + + } + > + Sign in as a Guest. + + + ); +}; -const loader: ProviderLoader = async () => { - return new GuestUserIdentity(); +const loader: ProviderLoader = async apis => { + const identity = new ProxiedSignInIdentity({ + provider: 'guest', + discoveryApi: apis.get(discoveryApiRef)!, + }); + + await identity.start(); + + const identityResponse = await identity.getBackstageIdentity(); + + if (!identityResponse) { + return undefined; + } + + return identity; }; export const guestProvider: SignInProvider = { Component, loader }; diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 20613b7509..e456a6948b 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -43,7 +43,6 @@ export type SignInProviderType = { }; const signInProviders: { [key: string]: SignInProvider } = { - /** @deprecated Use `@backstage/plugin-auth-backend-module-guest-provider` */ guest: guestProvider, custom: customProvider, common: commonProvider, diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index b11352b373..d89544cf68 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -469,15 +469,3 @@ export const vmwareCloudAuthApiRef: ApiRef< > = createApiRef({ id: 'core.auth.vmware-cloud', }); - -/** - * Provides guest authentication support. - * - * @public - * @remarks - */ -export const guestAuthApiRef: ApiRef< - ProfileInfoApi & BackstageIdentityApi & SessionApi -> = createApiRef({ - id: 'core.auth.guest', -}); diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index af9bfaf751..70f8cfa2ae 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -292,7 +292,6 @@ describe('createApp', () => { - ] " diff --git a/plugins/auth-backend-module-guest-provider/README.md b/plugins/auth-backend-module-guest-provider/README.md index 471b522859..20c1eb2f15 100644 --- a/plugins/auth-backend-module-guest-provider/README.md +++ b/plugins/auth-backend-module-guest-provider/README.md @@ -2,7 +2,7 @@ This module provides a guest auth provider implementation for `@backstage/plugin-auth-backend`. This is meant to supersede the existing `'guest'` option for authentication that does not emit tokens and is completely stored as frontend state. -**NOTE**: This provider should only ever be enabled for `development` or `test`. Enabling this for production is strongly discouraged as it would give everyone a way to bypass your other authentication methods. +**NOTE**: This provider should only ever be enabled for `development`. This package is explicitly disabled for non-development environments. ## Installation @@ -20,42 +20,15 @@ const backend = createBackend(); backend.start(); ``` -#### Old Backend - -This module was also backported for the old backend and can be used like so, - -```diff -+import { -+ providers, -+} from '@backstage/plugin-auth-backend'; - .... - return await createRouter({ - ... - providerFactories: { - gitlab: providers.gitlab(), -+ guest: providers.guest(), - ... - } - ... -``` - ### Frontend Add the following to your `SignInPage` providers, ```diff -+import { -+ guestAuthApiRef, -+} from '@backstage/core-plugin-api'; - const providers = [ -+ { -+ id: 'guest-auth-provider', -+ title: 'Guest', -+ message: 'Sign in as a guest', -+ apiRef: guestAuthApiRef, -+ }, ++ 'guest', ... +] ``` ### Config diff --git a/plugins/auth-backend/src/providers/guest/index.ts b/plugins/auth-backend-module-guest-provider/src/authenticator.ts similarity index 67% rename from plugins/auth-backend/src/providers/guest/index.ts rename to plugins/auth-backend-module-guest-provider/src/authenticator.ts index 7b384798b0..d33fc2c7c9 100644 --- a/plugins/auth-backend/src/providers/guest/index.ts +++ b/plugins/auth-backend-module-guest-provider/src/authenticator.ts @@ -1,3 +1,5 @@ +import { createProxyAuthenticator } from '@backstage/plugin-auth-node'; + /* * Copyright 2024 The Backstage Authors * @@ -13,4 +15,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { guest } from './provider'; +export const guestAuthenticator = createProxyAuthenticator({ + defaultProfileTransform: async () => { + return { profile: {} }; + }, + initialize() {}, + async authenticate() { + return { result: {} }; + }, +}); diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts deleted file mode 100644 index acd08940a3..0000000000 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthFactory.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2023 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 { SignInResolverFactory } from '@backstage/plugin-auth-node'; -import type { - AuthProviderFactory, - ProfileTransform, - SignInResolver, -} from '@backstage/plugin-auth-node'; -import { createGuestAuthRouteHandlers } from './createGuestAuthRouteHandlers'; -import { guestResolver } from './resolvers'; - -const defaultTransform: ProfileTransform<{}> = async () => { - return { - profile: { - displayName: 'Guest', - }, - }; -}; - -/** @public */ -export function createGuestAuthProviderFactory(options?: { - profileTransform?: ProfileTransform<{}>; - signInResolver?: SignInResolver<{}>; - signInResolverFactories?: Record>; -}): AuthProviderFactory { - return ctx => { - const signInResolver = options?.signInResolver ?? guestResolver(); - - if (!signInResolver) { - throw new Error( - `No sign-in resolver configured for guest auth provider '${ctx.providerId}'`, - ); - } - const profileTransform = options?.profileTransform ?? defaultTransform; - - return createGuestAuthRouteHandlers({ - signInResolver, - baseUrl: ctx.baseUrl, - appUrl: ctx.appUrl, - config: ctx.config, - resolverContext: ctx.resolverContext, - profileTransform, - }); - }; -} diff --git a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts b/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts deleted file mode 100644 index 76a69ca75b..0000000000 --- a/plugins/auth-backend-module-guest-provider/src/createGuestAuthRouteHandlers.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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 type { Request, Response } from 'express'; -import type { Config } from '@backstage/config'; -import { - AuthProviderRouteHandlers, - AuthResolverContext, - ClientAuthResponse, - ProfileTransform, - SignInResolver, - prepareBackstageIdentityResponse, - sendWebMessageResponse, -} from '@backstage/plugin-auth-node'; - -export interface GuestAuthRouteHandlersOptions { - config: Config; - baseUrl: string; - appUrl: string; - resolverContext: AuthResolverContext; - signInResolver: SignInResolver<{}>; - profileTransform: ProfileTransform<{}>; -} - -export function createGuestAuthRouteHandlers( - options: GuestAuthRouteHandlersOptions, -): AuthProviderRouteHandlers { - const { resolverContext, signInResolver, appUrl, profileTransform } = options; - - const createGuestSession = async (): Promise> => { - const { profile } = await profileTransform({}, resolverContext); - - const identity = await signInResolver( - { profile, result: {} }, - resolverContext, - ); - - return { - profile, - providerInfo: {}, - backstageIdentity: prepareBackstageIdentityResponse(identity), - }; - }; - - return { - async start(_, res): Promise { - // We are the auth provider for guests, skip this step. - res.redirect('handler/frame'); - }, - - /** - * This is where we create the token for the guest user. You can override the - * entityRef for the guest user with `signInResolver`. - */ - async frameHandler(_, res): Promise { - const session = await createGuestSession(); - // post message back to popup if successful - sendWebMessageResponse(res, appUrl, { - type: 'authorization_response', - response: session, - }); - }, - - /** - * Support refreshing the guest user's token. This should just improve the experience of - * browsing while in guest mode. - */ - async refresh(this: never, _: Request, res: Response): Promise { - const session = await createGuestSession(); - res.status(200).json(session); - }, - - async logout(_, res) { - // If we don't send a response or it gets cached into a 204, the page will hang. - res.end(); - }, - }; -} diff --git a/plugins/auth-backend-module-guest-provider/src/index.ts b/plugins/auth-backend-module-guest-provider/src/index.ts index 0c4a382a87..c6ada31c3a 100644 --- a/plugins/auth-backend-module-guest-provider/src/index.ts +++ b/plugins/auth-backend-module-guest-provider/src/index.ts @@ -20,5 +20,4 @@ * @packageDocumentation */ -export { createGuestAuthProviderFactory } from './createGuestAuthFactory'; export { authModuleGuestProvider as default } from './module'; diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index 4d191ac9e1..a52b8a4ac2 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -17,8 +17,12 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; -import { authProvidersExtensionPoint } from '@backstage/plugin-auth-node'; -import { createGuestAuthProviderFactory } from './createGuestAuthFactory'; +import { + authProvidersExtensionPoint, + createProxyAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { guestAuthenticator } from './authenticator'; +import { signInAsGuestUser } from './resolvers'; /** @public */ export const authModuleGuestProvider = createBackendModule({ @@ -31,14 +35,17 @@ export const authModuleGuestProvider = createBackendModule({ providers: authProvidersExtensionPoint, }, async init({ providers }) { - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV !== 'development') { throw new Error( 'Guest provider does not support authenticating production workloads.', ); } providers.registerProvider({ providerId: 'guest', - factory: createGuestAuthProviderFactory(), + factory: createProxyAuthProviderFactory({ + authenticator: guestAuthenticator, + signInResolver: signInAsGuestUser, + }), }); }, }); diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts index 5922530c5c..05acf676ec 100644 --- a/plugins/auth-backend-module-guest-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -15,7 +15,7 @@ */ import { stringifyEntityRef } from '@backstage/catalog-model'; -import { createSignInResolverFactory } from '@backstage/plugin-auth-node'; +import { SignInResolver } from '@backstage/plugin-auth-node'; /** * Provide a default implementation of the user to resolve to. By default, this @@ -23,24 +23,20 @@ import { createSignInResolverFactory } from '@backstage/plugin-auth-node'; * catalog. If that user doesn't exist in the catalog, we will still create a * token for them so they can keep viewing. */ -export const guestResolver = createSignInResolverFactory({ - create() { - return async (_, ctx) => { - const userRef = stringifyEntityRef({ - kind: 'user', - name: 'guest', - }); - try { - return ctx.signInWithCatalogUser({ entityRef: userRef }); - } catch (err) { - // We can't guarantee that a guest user exists in the catalog, so we issue a token directly, - return ctx.issueToken({ - claims: { - sub: userRef, - ent: [userRef], - }, - }); - } - }; - }, -}); +export const signInAsGuestUser: SignInResolver<{}> = async (_, ctx) => { + const userRef = stringifyEntityRef({ + kind: 'user', + name: 'guest', + }); + try { + return ctx.signInWithCatalogUser({ entityRef: userRef }); + } catch (err) { + // We can't guarantee that a guest user exists in the catalog, so we issue a token directly, + return ctx.issueToken({ + claims: { + sub: userRef, + ent: [userRef], + }, + }); + } +}; diff --git a/plugins/auth-backend/src/providers/guest/provider.ts b/plugins/auth-backend/src/providers/guest/provider.ts deleted file mode 100644 index 2efc584d5d..0000000000 --- a/plugins/auth-backend/src/providers/guest/provider.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { AuthHandler, SignInResolver } from '../types'; -import { createGuestAuthProviderFactory } from '@backstage/plugin-auth-backend-module-guest-provider'; - -/** - * Auth provider integration for Google auth - * - * @public - */ -export const guest = createAuthProviderIntegration({ - create(options?: { - /** - * The profile transformation function used to verify and convert the auth response - * into the profile that will be presented to the user. - */ - authHandler?: AuthHandler<{}>; - - /** - * Configure sign-in for this provider, without it the provider can not be used to sign users in. - */ - signIn?: { - /** - * Maps an auth result to a Backstage identity for the user. - */ - resolver: SignInResolver<{}>; - }; - }) { - return createGuestAuthProviderFactory({ - profileTransform: options?.authHandler, - signInResolver: options?.signIn?.resolver, - }); - }, -}); diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index d527bf8b13..76ac51f662 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -30,7 +30,6 @@ import { oidc } from './oidc'; import { okta } from './okta'; import { onelogin } from './onelogin'; import { saml } from './saml'; -import { guest } from './guest'; import { bitbucketServer } from './bitbucketServer'; import { easyAuth } from './azure-easyauth'; import { AuthProviderFactory } from '@backstage/plugin-auth-node'; @@ -59,7 +58,6 @@ export const providers = Object.freeze({ onelogin, saml, easyAuth, - guest, }); /** @@ -85,5 +83,4 @@ export const defaultAuthProviderFactories: { bitbucket: bitbucket.create(), bitbucketServer: bitbucketServer.create(), atlassian: atlassian.create(), - guest: guest.create(), }; diff --git a/yarn.lock b/yarn.lock index 769d1f52a3..7e674683b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,3 +1,6 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + __metadata: version: 6 cacheKey: 8 @@ -27411,6 +27414,7 @@ __metadata: "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-badges-backend": "workspace:^" @@ -37347,7 +37351,7 @@ __metadata: languageName: node linkType: hard -"passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1": +"passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0": version: 1.8.0 resolution: "passport-oauth2@npm:1.8.0" dependencies: @@ -37360,19 +37364,6 @@ __metadata: languageName: node linkType: hard -"passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0": - version: 1.7.0 - resolution: "passport-oauth2@npm:1.7.0" - dependencies: - base64url: 3.x.x - oauth: 0.10.x - passport-strategy: 1.x.x - uid2: 0.0.x - utils-merge: 1.x.x - checksum: a9a80b968343c9c1906f74ef613b346ec2d6a6acfe17af81e673fd774779b436729252485755c3ce182f2cdba2434d75067418952d722404d65b93c0360ca02b - languageName: node - linkType: hard - "passport-oauth@npm:1.0.0, passport-oauth@npm:^1.0.0": version: 1.0.0 resolution: "passport-oauth@npm:1.0.0" From 4f4fce91cb55d9beec426c653991d17a3397266b Mon Sep 17 00:00:00 2001 From: Aramis Date: Sun, 11 Feb 2024 12:56:51 -0500 Subject: [PATCH 125/176] small fixes Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- app-config.yaml | 5 +- packages/backend/src/plugins/auth.ts | 2 - packages/core-app-api/api-report.md | 20 ------- .../RefreshingDirectAuthConnector.ts | 60 ------------------- packages/core-plugin-api/api-report.md | 5 -- .../api-report.md | 11 ---- plugins/auth-backend/api-report.md | 15 ----- 7 files changed, 1 insertion(+), 117 deletions(-) delete mode 100644 packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts diff --git a/app-config.yaml b/app-config.yaml index b864c3b610..e18c45aefa 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -400,10 +400,7 @@ auth: myproxy: development: {} guest: - development: - signIn: - resolvers: - - resolver: guestUser + development: {} costInsights: engineerCost: 200000 diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 773d3f4270..0d92315f92 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -141,8 +141,6 @@ export default async function createPlugin( }, }, }), - - guest: providers.guest.create(), }, }); } diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 0ecb0193db..8b8cc991c8 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -464,26 +464,6 @@ export class GoogleAuth { static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } -// @public -export class GuestAuth - implements ProfileInfoApi, BackstageIdentityApi, SessionApi -{ - // (undocumented) - static create(options: AuthApiCreateOptions): GuestAuth; - // (undocumented) - getBackstageIdentity( - options?: AuthRequestOptions, - ): Promise; - // (undocumented) - getProfile(options?: AuthRequestOptions): Promise; - // (undocumented) - sessionState$(): Observable; - // (undocumented) - signIn(): Promise; - // (undocumented) - signOut(): Promise; -} - // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) diff --git a/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts deleted file mode 100644 index 94f7486210..0000000000 --- a/packages/core-app-api/src/lib/AuthConnector/RefreshingDirectAuthConnector.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 { DirectAuthConnector } from './DirectAuthConnector'; - -/** - * Add support for refreshing direct tokens. Used for guest authentication. - */ -export class RefreshingDirectAuthConnector< - DirectAuthResponse, -> extends DirectAuthConnector { - /** - * Pulled from DefaultAuthConnector and adapted for use with DirectAuthConnector. - */ - async refreshSession(): Promise { - const res = await fetch( - `${await this.buildUrl('/refresh')}&optional=true`, - { - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }, - ).catch(error => { - throw new Error(`Auth refresh request failed, ${error}`); - }); - - if (!res.ok) { - const error: any = new Error( - `Auth refresh request failed, ${res.statusText}`, - ); - error.status = res.status; - throw error; - } - - const authInfo = await res.json(); - - if (authInfo.error) { - const error = new Error(authInfo.error.message); - if (authInfo.error.name) { - error.name = authInfo.error.name; - } - throw error; - } - return authInfo; - } -} diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index ad91a0431e..2a993687b3 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -503,11 +503,6 @@ export const googleAuthApiRef: ApiRef< SessionApi >; -// @public -export const guestAuthApiRef: ApiRef< - ProfileInfoApi & BackstageIdentityApi & SessionApi ->; - // @public export type IconComponent = ComponentType< | { diff --git a/plugins/auth-backend-module-guest-provider/api-report.md b/plugins/auth-backend-module-guest-provider/api-report.md index adaf4313fb..773b80b9ba 100644 --- a/plugins/auth-backend-module-guest-provider/api-report.md +++ b/plugins/auth-backend-module-guest-provider/api-report.md @@ -3,20 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import type { AuthProviderFactory } from '@backstage/plugin-auth-node'; import { BackendFeature } from '@backstage/backend-plugin-api'; -import type { ProfileTransform } from '@backstage/plugin-auth-node'; -import type { SignInResolver } from '@backstage/plugin-auth-node'; -import { SignInResolverFactory } from '@backstage/plugin-auth-node'; // @public (undocumented) const authModuleGuestProvider: () => BackendFeature; export default authModuleGuestProvider; - -// @public (undocumented) -export function createGuestAuthProviderFactory(options?: { - profileTransform?: ProfileTransform<{}>; - signInResolver?: SignInResolver<{}>; - signInResolverFactories?: Record>; -}): AuthProviderFactory; ``` diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 431caf3e91..1beddb6419 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -644,21 +644,6 @@ export const providers: Readonly<{ ) => AuthProviderFactory_2; resolvers: never; }>; - guest: Readonly<{ - create: ( - options?: - | { - authHandler?: AuthHandler<{}> | undefined; - signIn?: - | { - resolver: SignInResolver<{}>; - } - | undefined; - } - | undefined, - ) => AuthProviderFactory_2; - resolvers: never; - }>; }>; // @public @deprecated (undocumented) From 10d56c1d7c46852baa012d0a63577fec96a9b3a4 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sun, 11 Feb 2024 13:07:55 -0500 Subject: [PATCH 126/176] more clean up Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- .changeset/gentle-starfishes-camp.md | 8 -------- .changeset/selfish-glasses-cheer.md | 10 +++++++++- plugins/auth-backend/package.json | 1 - yarn.lock | 1 - 4 files changed, 9 insertions(+), 11 deletions(-) delete mode 100644 .changeset/gentle-starfishes-camp.md diff --git a/.changeset/gentle-starfishes-camp.md b/.changeset/gentle-starfishes-camp.md deleted file mode 100644 index 229d4c39c9..0000000000 --- a/.changeset/gentle-starfishes-camp.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/core-plugin-api': minor -'@backstage/app-defaults': minor -'@backstage/core-app-api': minor -'@backstage/plugin-auth-backend': minor ---- - -Adds in support for the new guest provider added by `@backstage/plugin-auth-backend-module-guest-provider`. diff --git a/.changeset/selfish-glasses-cheer.md b/.changeset/selfish-glasses-cheer.md index 0a56a8b489..ffd9bb74e0 100644 --- a/.changeset/selfish-glasses-cheer.md +++ b/.changeset/selfish-glasses-cheer.md @@ -2,4 +2,12 @@ '@backstage/core-components': minor --- -**DEPRECATED** `SignInPage`'s `'guest'` provider is deprecated. Use `@backstage/plugin-auth-backend-module-guest-provider` instead. +**BREAKING** `SignInPage`'s `'guest'` provider now uses `@backstage/plugin-auth-backend-module-guest-provider` to generate tokens. You must install that provider into your backend to continue using the `'guest'` option. + +```diff +const backend = createBackend(); + ++backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); + +backend.start(); +``` diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 3ea1d26475..456cc18af2 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -49,7 +49,6 @@ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", - "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^", "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 7e674683b6..1bd1a644bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4854,7 +4854,6 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" - "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" From 4fa994f91584548ac1a270f21ccc8e5943b8e1f9 Mon Sep 17 00:00:00 2001 From: Aramis Date: Sun, 11 Feb 2024 14:34:23 -0500 Subject: [PATCH 127/176] run yarn fix Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- plugins/auth-backend-module-guest-provider/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/auth-backend-module-guest-provider/package.json b/plugins/auth-backend-module-guest-provider/package.json index ae50007225..edf3210759 100644 --- a/plugins/auth-backend-module-guest-provider/package.json +++ b/plugins/auth-backend-module-guest-provider/package.json @@ -10,6 +10,11 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/auth-backend-module-guest-provider" + }, "backstage": { "role": "backend-plugin-module" }, From 9fc765af207bf9b36e95eae0b3ae2f42debb1982 Mon Sep 17 00:00:00 2001 From: Aramis Date: Mon, 12 Feb 2024 10:02:11 -0500 Subject: [PATCH 128/176] update with a guide Signed-off-by: Aramis Signed-off-by: aramissennyeydd --- docs/auth/guest/provider.md | 65 +++++++++++++++++++ microsite/sidebars.json | 1 + mkdocs.yml | 1 + .../README.md | 42 ------------ .../src/authenticator.ts | 5 +- 5 files changed, 70 insertions(+), 44 deletions(-) create mode 100644 docs/auth/guest/provider.md diff --git a/docs/auth/guest/provider.md b/docs/auth/guest/provider.md new file mode 100644 index 0000000000..09c52fcd3b --- /dev/null +++ b/docs/auth/guest/provider.md @@ -0,0 +1,65 @@ +--- +id: provider +title: Guest Authentication Provider +sidebar_label: Guest +description: Adding a guest authentication provider in Backstage +--- + +Audience: Admins or developers + +## Summary + +The goal of this guide is to get you set up with a guest authentication provider that emits tokens. This is different than the old guest authentication that is purely stored on the frontend and does not have tokens. The main reason you'd want to use this provider is to use permissioned plugins. + +:::caution +This provider should only ever be enabled for `development`. To prevent unauthorized access to your data, this package is _explicitly_ disabled for non-development environments. +::: + +## Installation + +### Backend + +:::note +This will only work with the new backend system. There is no support for this in the old backend. +::: + +Add the `@backstage/plugin-auth-backend-module-guest-provider` to your backend installation. + +``` +yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-guest-provider +``` + +Then, add it to your backend's `index.ts` file, + +```diff +const backend = createBackend(); + +backend.add('@backstage/plugin-auth-backend'); ++backend.add('@backstage/plugin-auth-backend-module-guest-provider'); + +await backend.start(); +``` + +### Frontend + +Add the following to your `SignInPage` providers, + +```diff +const providers = [ ++ 'guest', + ... +] +``` + +### Config + +Similar to the other authentication providers, you have to enable the provider in config. Add the following to your `app-config.local.yaml`, + +```diff +auth: + providers: ++ guest: ++ development: {} +``` + +We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 4f2724c0b6..fd13fcb57b 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -307,6 +307,7 @@ "auth/gitlab/provider", "auth/google/provider", "auth/google/gcp-iap-auth", + "auth/guest/provider", "auth/okta/provider", "auth/oauth2-proxy/provider", "auth/onelogin/provider", diff --git a/mkdocs.yml b/mkdocs.yml index 2a17613672..a2b2749233 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -161,6 +161,7 @@ nav: - GitLab: 'auth/gitlab/provider.md' - Google: 'auth/google/provider.md' - Google IAP: 'auth/google/gcp-iap-auth.md' + - Guest: 'auth/guest/provider.md' - OAuth2Proxy: 'auth/oauth2-proxy/provider.md' - Okta: 'auth/okta/provider.md' - OneLogin: 'auth/onelogin/provider.md' diff --git a/plugins/auth-backend-module-guest-provider/README.md b/plugins/auth-backend-module-guest-provider/README.md index 20c1eb2f15..79591c987b 100644 --- a/plugins/auth-backend-module-guest-provider/README.md +++ b/plugins/auth-backend-module-guest-provider/README.md @@ -2,48 +2,6 @@ This module provides a guest auth provider implementation for `@backstage/plugin-auth-backend`. This is meant to supersede the existing `'guest'` option for authentication that does not emit tokens and is completely stored as frontend state. -**NOTE**: This provider should only ever be enabled for `development`. This package is explicitly disabled for non-development environments. - -## Installation - -### Backend - -#### New Backend - -```diff -const backend = createBackend(); -... - -+backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); - -... -backend.start(); -``` - -### Frontend - -Add the following to your `SignInPage` providers, - -```diff -const providers = [ -+ 'guest', - ... -] -``` - -### Config - -Similar to the other authentication providers, you have to enable the provider in config. Add the following to your `app-config.local.yaml`, - -```diff -auth: - providers: -+ guest: -+ development: {} -``` - -We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object. - ## Links - [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-guest-provider/src/authenticator.ts b/plugins/auth-backend-module-guest-provider/src/authenticator.ts index d33fc2c7c9..436b72617d 100644 --- a/plugins/auth-backend-module-guest-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-guest-provider/src/authenticator.ts @@ -1,5 +1,3 @@ -import { createProxyAuthenticator } from '@backstage/plugin-auth-node'; - /* * Copyright 2024 The Backstage Authors * @@ -15,6 +13,9 @@ import { createProxyAuthenticator } from '@backstage/plugin-auth-node'; * See the License for the specific language governing permissions and * limitations under the License. */ + +import { createProxyAuthenticator } from '@backstage/plugin-auth-node'; + export const guestAuthenticator = createProxyAuthenticator({ defaultProfileTransform: async () => { return { profile: {} }; From d622690f8cc944ca7b2309335891d9ff4dbfeaea Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sat, 17 Feb 2024 17:38:36 -0500 Subject: [PATCH 129/176] code review updates Signed-off-by: aramissennyeydd --- .changeset/cold-boats-sell.md | 7 +++- .changeset/selfish-glasses-cheer.md | 10 +---- app-config.yaml | 2 +- docs/auth/guest/provider.md | 4 +- .../examples/acme/team-a-group.yaml | 14 +++++++ .../templates/default-app/examples/org.yaml | 9 +++++ .../config.d.ts | 27 +++++++++++++ .../src/module.ts | 11 +++-- .../src/resolvers.ts | 40 ++++++++++--------- 9 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 plugins/auth-backend-module-guest-provider/config.d.ts diff --git a/.changeset/cold-boats-sell.md b/.changeset/cold-boats-sell.md index 112d9022bc..e47517140d 100644 --- a/.changeset/cold-boats-sell.md +++ b/.changeset/cold-boats-sell.md @@ -2,4 +2,9 @@ '@backstage/plugin-auth-backend-module-guest-provider': patch --- -Adds a new guest provider that maps guest users to actual tokens. +Adds a new guest provider that maps guest users to actual tokens. This also shifts the default guest login to `user:development/guest` to reduce overlap with your production/real data. To change that (or set it back to the old default, use the new `auth.guestEntityRef` config key) like so, + +```yaml title=app-config.yaml +auth: + guestEntityRef: user:default/guest +``` diff --git a/.changeset/selfish-glasses-cheer.md b/.changeset/selfish-glasses-cheer.md index ffd9bb74e0..4d880f0523 100644 --- a/.changeset/selfish-glasses-cheer.md +++ b/.changeset/selfish-glasses-cheer.md @@ -2,12 +2,4 @@ '@backstage/core-components': minor --- -**BREAKING** `SignInPage`'s `'guest'` provider now uses `@backstage/plugin-auth-backend-module-guest-provider` to generate tokens. You must install that provider into your backend to continue using the `'guest'` option. - -```diff -const backend = createBackend(); - -+backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); - -backend.start(); -``` +`SignInPage`'s `'guest'` provider now supports the `@backstage/plugin-auth-backend-module-guest-provider` package to generate tokens. It will continue to use the old frontend-only auth as a fallback. diff --git a/app-config.yaml b/app-config.yaml index e18c45aefa..4b92687653 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -242,7 +242,7 @@ catalog: - Domain - Location providers: - openapi: + backstageOpenapi: plugins: - catalog - search diff --git a/docs/auth/guest/provider.md b/docs/auth/guest/provider.md index 09c52fcd3b..ca5bee1d09 100644 --- a/docs/auth/guest/provider.md +++ b/docs/auth/guest/provider.md @@ -59,7 +59,9 @@ Similar to the other authentication providers, you have to enable the provider i auth: providers: + guest: -+ development: {} ++ development: + // new optional property to override the default value. ++ loginAs: user:default/guest ``` We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object. diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml index 7fe0e7b3f3..eb95c47093 100644 --- a/packages/catalog-model/examples/acme/team-a-group.yaml +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -57,3 +57,17 @@ spec: displayName: Guest User email: guest@example.com memberOf: [team-a] +--- +# This user is added as an example, to make it more easy for the "Guest" +# sign-in option to demonstrate some entities being owned. In a regular org, +# a guest user would probably not be registered like this. +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: guest + namespace: development +spec: + profile: + displayName: Guest User + email: guest@example.com + memberOf: [group:default/team-a] diff --git a/packages/create-app/templates/default-app/examples/org.yaml b/packages/create-app/templates/default-app/examples/org.yaml index a10e81fc7f..1c4fb91a1e 100644 --- a/packages/create-app/templates/default-app/examples/org.yaml +++ b/packages/create-app/templates/default-app/examples/org.yaml @@ -7,6 +7,15 @@ metadata: spec: memberOf: [guests] --- +# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-user +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: guest + namespace: development +spec: + memberOf: [guests] +--- # https://backstage.io/docs/features/software-catalog/descriptor-format#kind-group apiVersion: backstage.io/v1alpha1 kind: Group diff --git a/plugins/auth-backend-module-guest-provider/config.d.ts b/plugins/auth-backend-module-guest-provider/config.d.ts new file mode 100644 index 0000000000..d6de29bed6 --- /dev/null +++ b/plugins/auth-backend-module-guest-provider/config.d.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export interface Config { + /** Configuration options for the auth plugin */ + auth?: { + /** + * EXPERIMENTAL value: Allow users to configure what the guest provider logs in as. + * @visibility frontend + * @default user:default/guest + */ + guestEntityRef?: string; + }; +} diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index a52b8a4ac2..75eb4f849c 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -33,18 +33,21 @@ export const authModuleGuestProvider = createBackendModule({ deps: { logger: coreServices.logger, providers: authProvidersExtensionPoint, + config: coreServices.rootConfig, }, - async init({ providers }) { + async init({ providers, logger, config }) { if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Guest provider does not support authenticating production workloads.', + logger.warn( + 'You should NOT be using the guest provider outside of a development environment.', ); } providers.registerProvider({ providerId: 'guest', factory: createProxyAuthProviderFactory({ authenticator: guestAuthenticator, - signInResolver: signInAsGuestUser, + signInResolver: signInAsGuestUser( + config.getOptionalString('auth.guestEntityRef'), + ), }), }); }, diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts index 05acf676ec..b2c2f8cf7d 100644 --- a/plugins/auth-backend-module-guest-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -19,24 +19,28 @@ import { SignInResolver } from '@backstage/plugin-auth-node'; /** * Provide a default implementation of the user to resolve to. By default, this - * is `user:default/guest`. We will attempt to get that user if they're in the + * is `user:development/guest`. We will attempt to get that user if they're in the * catalog. If that user doesn't exist in the catalog, we will still create a * token for them so they can keep viewing. */ -export const signInAsGuestUser: SignInResolver<{}> = async (_, ctx) => { - const userRef = stringifyEntityRef({ - kind: 'user', - name: 'guest', - }); - try { - return ctx.signInWithCatalogUser({ entityRef: userRef }); - } catch (err) { - // We can't guarantee that a guest user exists in the catalog, so we issue a token directly, - return ctx.issueToken({ - claims: { - sub: userRef, - ent: [userRef], - }, - }); - } -}; +export const signInAsGuestUser: (entityRef?: string) => SignInResolver<{}> = + (entityRef?: string) => async (_, ctx) => { + const userRef = + entityRef ?? + stringifyEntityRef({ + kind: 'user', + namespace: 'development', + name: 'guest', + }); + try { + return ctx.signInWithCatalogUser({ entityRef: userRef }); + } catch (err) { + // We can't guarantee that a guest user exists in the catalog, so we issue a token directly, + return ctx.issueToken({ + claims: { + sub: userRef, + ent: [userRef], + }, + }); + } + }; From 24cc1a472a8107e893f98f90c9577e780f333c47 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 18 Feb 2024 00:20:19 -0500 Subject: [PATCH 130/176] update guest provider to support both old and new guest sessions Signed-off-by: aramissennyeydd --- .../src/layout/SignInPage/guestProvider.tsx | 81 ++++++++++++++----- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/core-components/src/layout/SignInPage/guestProvider.tsx b/packages/core-components/src/layout/SignInPage/guestProvider.tsx index 018feb2241..a2ec2c1078 100644 --- a/packages/core-components/src/layout/SignInPage/guestProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/guestProvider.tsx @@ -22,28 +22,61 @@ import { GridItem } from './styles'; import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; import { ProxiedSignInIdentity } from '../ProxiedSignInPage/ProxiedSignInIdentity'; import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; +import { GuestUserIdentity } from './GuestUserIdentity'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { ResponseError } from '@backstage/errors'; + +const getIdentity = async (identity: ProxiedSignInIdentity) => { + try { + const identityResponse = await identity.getBackstageIdentity(); + return identityResponse; + } catch (error) { + if ( + error instanceof ResponseError && + error.cause.name === 'NotFoundError' + ) { + return undefined; + } + throw error; + } +}; const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => { const discoveryApi = useApi(discoveryApiRef); + const [_, setUseLegacyGuestToken] = useLocalStorage('enableLegacyGuestToken'); + + const handle = async () => { + onSignInStarted(); + + const identity = new ProxiedSignInIdentity({ + provider: 'guest', + discoveryApi, + }); + + const identityResponse = await getIdentity(identity); + + if (!identityResponse) { + // eslint-disable-next-line no-alert + const useLegacyGuestTokenResponse = confirm( + 'Failed to sign in as a guest using the auth backend. Do you want to fallback to the legacy guest token?', + ); + if (useLegacyGuestTokenResponse) { + setUseLegacyGuestToken(true); + onSignInSuccess(new GuestUserIdentity()); + return; + } + } + + onSignInSuccess(identity); + }; + return ( { - onSignInStarted(); - onSignInSuccess( - new ProxiedSignInIdentity({ - provider: 'guest', - discoveryApi, - }), - ); - }} - > + } @@ -55,17 +88,29 @@ const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => { }; const loader: ProviderLoader = async apis => { + const useLegacyGuestToken = + localStorage.getItem('enableLegacyGuestToken') === 'true'; + const identity = new ProxiedSignInIdentity({ provider: 'guest', discoveryApi: apis.get(discoveryApiRef)!, }); + const identityResponse = await getIdentity(identity); - await identity.start(); - - const identityResponse = await identity.getBackstageIdentity(); - - if (!identityResponse) { + if (!identityResponse && !useLegacyGuestToken) { return undefined; + } else if (identityResponse && useLegacyGuestToken) { + // eslint-disable-next-line no-alert + const switchToNewGuestToken = confirm( + 'You are currently using the legacy guest token, but you have the new guest backend module installed. Do you want to use the new module?', + ); + if (switchToNewGuestToken) { + localStorage.removeItem('enableLegacyGuestToken'); + } else { + return new GuestUserIdentity(); + } + } else if (useLegacyGuestToken) { + return new GuestUserIdentity(); } return identity; From 6f2fbff528867aae12540dfc9abd949bb9ed0db1 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 18 Feb 2024 00:35:37 -0500 Subject: [PATCH 131/176] add signin failure error Signed-off-by: aramissennyeydd --- .../src/layout/SignInPage/guestProvider.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core-components/src/layout/SignInPage/guestProvider.tsx b/packages/core-components/src/layout/SignInPage/guestProvider.tsx index a2ec2c1078..adb5f4b027 100644 --- a/packages/core-components/src/layout/SignInPage/guestProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/guestProvider.tsx @@ -41,7 +41,11 @@ const getIdentity = async (identity: ProxiedSignInIdentity) => { } }; -const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => { +const Component: ProviderComponent = ({ + onSignInStarted, + onSignInSuccess, + onSignInFailure, +}) => { const discoveryApi = useApi(discoveryApiRef); const [_, setUseLegacyGuestToken] = useLocalStorage('enableLegacyGuestToken'); @@ -65,6 +69,10 @@ const Component: ProviderComponent = ({ onSignInStarted, onSignInSuccess }) => { onSignInSuccess(new GuestUserIdentity()); return; } + onSignInFailure(); + throw new Error( + `You cannot sign in as a guest, you must either enable the legacy guest token or configure the auth backend to support guest sign in.`, + ); } onSignInSuccess(identity); From 9332425e1c6268b6f0c616d009dcb88269469c7e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 16:37:10 +0100 Subject: [PATCH 132/176] catalog: fix alpha entity 404 Signed-off-by: Patrik Oldsberg --- .changeset/lemon-lemons-sparkle.md | 5 +++++ plugins/catalog/src/alpha/pages.tsx | 31 +++++++++++++++-------------- 2 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 .changeset/lemon-lemons-sparkle.md diff --git a/.changeset/lemon-lemons-sparkle.md b/.changeset/lemon-lemons-sparkle.md new file mode 100644 index 0000000000..6aae1a7f8d --- /dev/null +++ b/.changeset/lemon-lemons-sparkle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +The entity page extension provided by the `/alpha` plugin now correctly renders the entity 404 page. diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 15f7d6cc91..5c583e6a19 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -65,22 +65,23 @@ export const catalogEntityPage = createPageExtension({ loader: async ({ inputs }) => { const { EntityLayout } = await import('../components/EntityLayout'); const Component = () => { - const { entity, ...rest } = useEntityFromUrl(); return ( - - {entity ? ( - - {inputs.contents - .filter(({ output: { filterFunction, filterExpression } }) => - buildFilterFn(filterFunction, filterExpression)(entity), - ) - .map(({ output: { path, title, element } }) => ( - - {element} - - ))} - - ) : null} + + + {inputs.contents.map(({ output }) => ( + + {output.element} + + ))} + ); }; From 4ba74478473b50e4a1e469cdf5f4e7c8b50268b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 26 Feb 2024 16:38:20 +0100 Subject: [PATCH 133/176] Update plugins/auth-backend/config.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/auth-backend/config.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 0a1425c98b..f5f4dd29fd 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -185,7 +185,7 @@ export interface Config { /** @visibility frontend */ cfaccess?: { teamName: string; - /** @visibility secret */ + /** @deepVisibility secret */ serviceTokens?: Array<{ token: string; subject: string; From aebb8dc87317873e9631c6a636f9ab06fcfddcee Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 26 Feb 2024 16:42:10 +0100 Subject: [PATCH 134/176] chore: update changeset Signed-off-by: blam --- .changeset/clever-eagles-boil.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/clever-eagles-boil.md b/.changeset/clever-eagles-boil.md index 6e69d2db2f..a2f47ed092 100644 --- a/.changeset/clever-eagles-boil.md +++ b/.changeset/clever-eagles-boil.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-adr': patch +'@backstage/plugin-adr-common': patch --- -Fixed Azure DevOps ADR file path +Fixed Azure DevOps ADR file path reading From cceebae5ac8f37949262d297cb400499482cbaa7 Mon Sep 17 00:00:00 2001 From: Rickard Dybeck Date: Mon, 26 Feb 2024 10:50:54 -0500 Subject: [PATCH 135/176] [code-coverage] fix jacoco to not require scm-only Currently the jacoco plugin only works if you have the annotation set to scm-only. Signed-off-by: Rickard Dybeck --- .changeset/empty-wolves-rule.md | 5 +++++ .../src/service/converter/jacoco.test.ts | 6 ++++++ .../code-coverage-backend/src/service/converter/jacoco.ts | 8 +++++--- 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .changeset/empty-wolves-rule.md diff --git a/.changeset/empty-wolves-rule.md b/.changeset/empty-wolves-rule.md new file mode 100644 index 0000000000..a549cf3628 --- /dev/null +++ b/.changeset/empty-wolves-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage-backend': patch +--- + +Fix jacoco convertor to not require annotation to be set to scm-only. diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts index 80de585d44..b6b43234e6 100644 --- a/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.test.ts @@ -54,4 +54,10 @@ describe('convert jacoco', () => { expect(files.sort()).toEqual(expected.sort()); }); + + it('works when not providing files (as per not setting annotation to scm-only)', () => { + const files = converter.convert(fixture, []); + + expect(files).toHaveLength(4); + }); }); diff --git a/plugins/code-coverage-backend/src/service/converter/jacoco.ts b/plugins/code-coverage-backend/src/service/converter/jacoco.ts index ac5ccbfa45..d461cfe7d7 100644 --- a/plugins/code-coverage-backend/src/service/converter/jacoco.ts +++ b/plugins/code-coverage-backend/src/service/converter/jacoco.ts @@ -40,7 +40,6 @@ export class Jacoco implements Converter { */ convert(xml: JacocoXML, scmFiles: Array): Array { const jscov: Array = []; - xml.report.package.forEach(r => { const packageName = r.$.name; r.sourcefile.forEach(sf => { @@ -68,9 +67,12 @@ export class Jacoco implements Converter { .map(f => f.trimEnd()) .find(f => f.endsWith(packageAndFilename)); this.logger.debug(`matched ${packageAndFilename} to ${currentFile}`); - if (Object.keys(lineHits).length > 0 && currentFile) { + if ( + scmFiles.length === 0 || + (Object.keys(lineHits).length > 0 && currentFile) + ) { jscov.push({ - filename: currentFile, + filename: currentFile || packageAndFilename, branchHits: branchHits, lineHits: lineHits, }); From 6f2442e977fd5db75c0c2e57598175a995007b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 26 Feb 2024 16:34:04 +0100 Subject: [PATCH 136/176] Update plugins/azure-sites-backend/README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/azure-sites-backend/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index 554a3d0cc4..18c51239c3 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -51,17 +51,17 @@ Here's how to get the backend plugin up and running: } from '@backstage/plugin-azure-sites-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; - import { CatalogClient } from '@backstage/catalog-client' + import { CatalogClient } from '@backstage/catalog-client'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + const catalogApi = new CatalogClient({ discoveryApi: env.discovery }); return await createRouter({ - const catalogApi = new CatalogClient({ discoveryApi: env.discovery }) logger: env.logger, azureSitesApi: AzureSitesApi.fromConfig(env.config), permissions: env.permissions, - catalogApi + catalogApi, }); } ``` From 4b277033714facd9144c9ea93ed78b0a3114f812 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Date: Mon, 26 Feb 2024 11:59:01 -0500 Subject: [PATCH 137/176] Apply suggestions from code review Co-authored-by: Patrik Oldsberg Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> --- .changeset/cold-boats-sell.md | 2 +- .changeset/selfish-glasses-cheer.md | 2 +- .../core-components/src/layout/SignInPage/guestProvider.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/cold-boats-sell.md b/.changeset/cold-boats-sell.md index e47517140d..af0dd1e295 100644 --- a/.changeset/cold-boats-sell.md +++ b/.changeset/cold-boats-sell.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-auth-backend-module-guest-provider': patch +'@backstage/plugin-auth-backend-module-guest-provider': minor --- Adds a new guest provider that maps guest users to actual tokens. This also shifts the default guest login to `user:development/guest` to reduce overlap with your production/real data. To change that (or set it back to the old default, use the new `auth.guestEntityRef` config key) like so, diff --git a/.changeset/selfish-glasses-cheer.md b/.changeset/selfish-glasses-cheer.md index 4d880f0523..be267eb0f3 100644 --- a/.changeset/selfish-glasses-cheer.md +++ b/.changeset/selfish-glasses-cheer.md @@ -1,5 +1,5 @@ --- -'@backstage/core-components': minor +'@backstage/core-components': patch --- `SignInPage`'s `'guest'` provider now supports the `@backstage/plugin-auth-backend-module-guest-provider` package to generate tokens. It will continue to use the old frontend-only auth as a fallback. diff --git a/packages/core-components/src/layout/SignInPage/guestProvider.tsx b/packages/core-components/src/layout/SignInPage/guestProvider.tsx index adb5f4b027..563d1cc124 100644 --- a/packages/core-components/src/layout/SignInPage/guestProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/guestProvider.tsx @@ -32,8 +32,8 @@ const getIdentity = async (identity: ProxiedSignInIdentity) => { return identityResponse; } catch (error) { if ( - error instanceof ResponseError && - error.cause.name === 'NotFoundError' + error.name === 'ResponseError' && + (error as ResponseError).cause.name === 'NotFoundError' ) { return undefined; } From 62c581e86d30f7ba2adcaf545cc7f0801ae7315a Mon Sep 17 00:00:00 2001 From: Sameer Vohra Date: Mon, 26 Feb 2024 12:00:45 -0500 Subject: [PATCH 138/176] Update versioning-policy.md fix minor typo Signed-off-by: Sameer Vohra --- docs/overview/versioning-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/overview/versioning-policy.md b/docs/overview/versioning-policy.md index 2ff3588af1..8b12138499 100644 --- a/docs/overview/versioning-policy.md +++ b/docs/overview/versioning-policy.md @@ -34,7 +34,7 @@ their own release cadence and versioning policy. Release cadence: Monthly, specifically on the Tuesday before the third Wednesday of each month. The first release took place in March 2022. -The main release line in versioned with a major, minor and patch version but +The main release line is versioned with a major, minor and patch version but does **not** adhere to [semver](https://semver.org). The version format is `..`, for example `1.3.0`. From f8b8e2fe6f0adcbba3c8eb43192e671df1473e67 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 26 Feb 2024 18:48:05 +0100 Subject: [PATCH 139/176] refactor: move to core components and rename it Signed-off-by: Camila Belo --- .changeset/friendly-news-sin.md | 4 +- .changeset/red-taxis-swim.md | 2 +- packages/core-compat-api/api-report.md | 11 --- packages/core-compat-api/package.json | 1 - .../src/components/SystemIcon.tsx | 78 ------------------- .../core-compat-api/src/components/index.ts | 17 ---- packages/core-compat-api/src/index.ts | 2 - packages/core-components/api-report.md | 14 +++- .../src/icons/icons.test.tsx} | 26 +++---- packages/core-components/src/icons/icons.tsx | 53 +++++++++---- plugins/api-docs/src/alpha.tsx | 4 +- yarn.lock | 1 - 12 files changed, 66 insertions(+), 147 deletions(-) delete mode 100644 packages/core-compat-api/src/components/SystemIcon.tsx delete mode 100644 packages/core-compat-api/src/components/index.ts rename packages/{core-compat-api/src/components/SystemIcon.test.tsx => core-components/src/icons/icons.test.tsx} (59%) diff --git a/.changeset/friendly-news-sin.md b/.changeset/friendly-news-sin.md index ca337370af..29843c54fb 100644 --- a/.changeset/friendly-news-sin.md +++ b/.changeset/friendly-news-sin.md @@ -1,5 +1,5 @@ --- -'@backstage/core-compat-api': patch +'@backstage/core-components': minor --- -Create an abstraction to consume legacy system icons in new system extensions. +Create a component abstraction to consume system icons. diff --git a/.changeset/red-taxis-swim.md b/.changeset/red-taxis-swim.md index 7cd2428fac..13c758d552 100644 --- a/.changeset/red-taxis-swim.md +++ b/.changeset/red-taxis-swim.md @@ -2,4 +2,4 @@ '@backstage/plugin-api-docs': patch --- -Use the system icon compatibility component in the navigation item extension. +Use the `AppIcon` component in the navigation item extension. diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index 82f13f1482..be80ca50e9 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -8,11 +8,9 @@ import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; -import { ComponentProps } from 'react'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; -import { IconComponent } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; @@ -71,15 +69,6 @@ export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { captureEvent(_event: AnalyticsEvent | AnalyticsEvent_2): void; } -// @public -export function SystemIcon(props: SystemIconProps): React_2.JSX.Element; - -// @public -export type SystemIconProps = ComponentProps & { - keys: string | string[]; - Fallback?: IconComponent; -}; - // @public export type ToNewRouteRef = T extends RouteRef diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 21bf8ede49..ee70bc8089 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -45,7 +45,6 @@ "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-puppetdb": "workspace:^", "@backstage/plugin-stackstorm": "workspace:^", - "@backstage/test-utils": "workspace:^", "@oriflame/backstage-plugin-score-card": "^0.8.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0" diff --git a/packages/core-compat-api/src/components/SystemIcon.tsx b/packages/core-compat-api/src/components/SystemIcon.tsx deleted file mode 100644 index 7aa7110a97..0000000000 --- a/packages/core-compat-api/src/components/SystemIcon.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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, { ComponentProps } from 'react'; -import { useApp, IconComponent } from '@backstage/core-plugin-api'; -import { compatWrapper } from '../compatWrapper'; - -/** - * @public - * Props for the SystemIcon component. - */ -export type SystemIconProps = ComponentProps & { - // The id of the system icon to render, if provided as an array, the first icon found will be rendered. - keys: string | string[]; - // An optional fallback icon component to render when the system icon is not found. - // Default to () => null. - Fallback?: IconComponent; -}; - -function SystemIcon(props: SystemIconProps) { - const { keys, Fallback = () => null, ...rest } = props; - const app = useApp(); - for (const key of Array.isArray(keys) ? keys : [keys]) { - const Icon = app.getSystemIcon(key); - if (Icon) return ; - } - return ; -} - -/** - * @public - * SystemIcon is a component that renders a system icon by its id. - * @example - * Rendering the "kind:api" icon: - * ```tsx - * - * ``` - * @example - * Providing multiple icon ids: - * ```tsx - * - * ``` - * @example - * Customizing the fallback icon: - * ```tsx - * - * ``` - * @example - * Customizing the icon font size: - * ```tsx - * - * ``` - */ -function CompatSystemIcon(props: SystemIconProps) { - try { - // Check if the app context is available - useApp(); - return ; - } catch { - // Fallback to the compat wrapper if the app context is not available - return compatWrapper(); - } -} - -export { CompatSystemIcon as SystemIcon }; diff --git a/packages/core-compat-api/src/components/index.ts b/packages/core-compat-api/src/components/index.ts deleted file mode 100644 index e02ab727c6..0000000000 --- a/packages/core-compat-api/src/components/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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. - */ - -export { SystemIcon, type SystemIconProps } from './SystemIcon'; diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index 3da227e554..88e1892eac 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -17,8 +17,6 @@ export * from './compatWrapper'; export * from './apis'; -export * from './components'; - export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyRouteRef, diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 27b9c62653..7641386176 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -66,6 +66,15 @@ export type AlertDisplayProps = { transientTimeoutMs?: number; }; +// @public +export function AppIcon(props: AppIconProps): React_2.JSX.Element; + +// @public +export type AppIconProps = IconComponentProps & { + id: string; + Fallback?: IconComponent; +}; + // @public export const AutoLogout: (props: AutoLogoutProps) => JSX.Element | null; @@ -130,8 +139,6 @@ export type BreadcrumbsClickableTextClassKey = 'root'; // @public (undocumented) export type BreadcrumbsStyledBoxClassKey = 'root'; -// Warning: (ae-forgotten-export) The symbol "IconComponentProps" needs to be exported by the entry point index.d.ts -// // @public export function BrokenImageIcon(props: IconComponentProps): React_2.JSX.Element; @@ -541,6 +548,9 @@ export type HorizontalScrollGridClassKey = | 'buttonLeft' | 'buttonRight'; +// @public +export type IconComponentProps = ComponentProps; + // @public (undocumented) export function IconLinkVertical({ color, diff --git a/packages/core-compat-api/src/components/SystemIcon.test.tsx b/packages/core-components/src/icons/icons.test.tsx similarity index 59% rename from packages/core-compat-api/src/components/SystemIcon.test.tsx rename to packages/core-components/src/icons/icons.test.tsx index 12b8f41ea5..7cd54f37d0 100644 --- a/packages/core-compat-api/src/components/SystemIcon.test.tsx +++ b/packages/core-components/src/icons/icons.test.tsx @@ -17,24 +17,24 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; -import { SystemIcon } from './SystemIcon'; +import { AppIcon } from './icons'; -describe('SystemIcon', () => { +describe('AppIcon', () => { it('should render the correct system icon', async () => { - const { container } = await renderInTestApp(); - expect(container.querySelector('svg')).toBeDefined(); + await renderInTestApp(); + expect(screen.getByTestId('Api Icon')).toBeDefined(); }); - it('should render the first found icon when multiple keys are provided', async () => { - const { container } = await renderInTestApp( - , - ); - expect(container.querySelector('svg')).toBeDefined(); - }); - - it('should render the fallback component when no system icon is found', async () => { + it('should render the default fallback component', async () => { await renderInTestApp( -
Fallback Icon
} />, + , + ); + expect(screen.getByTestId('Fallback Icon')).toBeDefined(); + }); + + it('should render the custom fallback component', async () => { + await renderInTestApp( +
Fallback Icon
} />, ); expect(screen.getByText('Fallback Icon')).toBeInTheDocument(); }); diff --git a/packages/core-components/src/icons/icons.tsx b/packages/core-components/src/icons/icons.tsx index 8d22af4664..ca3b6fa76a 100644 --- a/packages/core-components/src/icons/icons.tsx +++ b/packages/core-components/src/icons/icons.tsx @@ -18,61 +18,80 @@ import { IconComponent, useApp } from '@backstage/core-plugin-api'; import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage'; import React, { ComponentProps } from 'react'; -type IconComponentProps = ComponentProps; +/** + * @public + * Props for the {@link @backstage/core-plugin-api#IconComponent} component. + */ +export type IconComponentProps = ComponentProps; -function useSystemIcon(key: string, props: IconComponentProps) { +/** + * @public + * Props for the {@link AppIcon} component. + */ +export type AppIconProps = IconComponentProps & { + // The key of the system icon to render. + id: string; + // An optional fallback icon component to render when the system icon is not found. + // Default to () => null. + Fallback?: IconComponent; +}; + +/** + * @public + * A component that renders a system icon by its id. + */ +export function AppIcon(props: AppIconProps) { + const { id: key, Fallback = MuiBrokenImageIcon, ...rest } = props; const app = useApp(); - const Icon = app.getSystemIcon(key); - return Icon ? : ; + const Icon = app.getSystemIcon(key) ?? Fallback; + return ; } // Should match the list of overridable system icon keys in @backstage/core-app-api /** * Broken Image Icon - * * @public - * */ export function BrokenImageIcon(props: IconComponentProps) { - return useSystemIcon('brokenImage', props); + return ; } /** @public */ export function CatalogIcon(props: IconComponentProps) { - return useSystemIcon('catalog', props); + return ; } /** @public */ export function ChatIcon(props: IconComponentProps) { - return useSystemIcon('chat', props); + return ; } /** @public */ export function DashboardIcon(props: IconComponentProps) { - return useSystemIcon('dashboard', props); + return ; } /** @public */ export function DocsIcon(props: IconComponentProps) { - return useSystemIcon('docs', props); + return ; } /** @public */ export function EmailIcon(props: IconComponentProps) { - return useSystemIcon('email', props); + return ; } /** @public */ export function GitHubIcon(props: IconComponentProps) { - return useSystemIcon('github', props); + return ; } /** @public */ export function GroupIcon(props: IconComponentProps) { - return useSystemIcon('group', props); + return ; } /** @public */ export function HelpIcon(props: IconComponentProps) { - return useSystemIcon('help', props); + return ; } /** @public */ export function UserIcon(props: IconComponentProps) { - return useSystemIcon('user', props); + return ; } /** @public */ export function WarningIcon(props: IconComponentProps) { - return useSystemIcon('warning', props); + return ; } diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index fd28a59893..29bb0bcfa9 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -27,7 +27,6 @@ import { } from '@backstage/frontend-plugin-api'; import { - SystemIcon, compatWrapper, convertLegacyRouteRef, } from '@backstage/core-compat-api'; @@ -45,11 +44,12 @@ import { import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute, registerComponentRouteRef } from './routes'; import { apiDocsConfigRef } from './config'; +import { AppIcon } from '@backstage/core-components'; const apiDocsNavItem = createNavItemExtension({ title: 'APIs', routeRef: convertLegacyRouteRef(rootRoute), - icon: () => , + icon: () => , }); const apiDocsConfigApi = createApiExtension({ diff --git a/yarn.lock b/yarn.lock index c8deda0b1d..06edaceef5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3818,7 +3818,6 @@ __metadata: "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-puppetdb": "workspace:^" "@backstage/plugin-stackstorm": "workspace:^" - "@backstage/test-utils": "workspace:^" "@backstage/version-bridge": "workspace:^" "@oriflame/backstage-plugin-score-card": ^0.8.0 "@testing-library/jest-dom": ^6.0.0 From bb48b3fd5e92fc913162cf881237e3ede3852f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 26 Feb 2024 20:52:59 +0100 Subject: [PATCH 140/176] Update .changeset/unlucky-jobs-report.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Signed-off-by: Fredrik Adelöw --- .changeset/unlucky-jobs-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/unlucky-jobs-report.md b/.changeset/unlucky-jobs-report.md index 80e5ecbea0..4eab12d53e 100644 --- a/.changeset/unlucky-jobs-report.md +++ b/.changeset/unlucky-jobs-report.md @@ -4,4 +4,4 @@ Migrated to use the new auth services introduced in [BEP-0003](https://github.com/backstage/backstage/blob/master/beps/0003-auth-architecture-evolution/README.md). -The `createRouter` function now has an optional `identity` argument, and instead gained the new `auth`, `httpAuth`, and `userInfo` arguments that should be set to the values of those respective `coreServices`. For users of the new backend system, this happens automatically without code changes. +The `createRouter` function now accepts `auth`, `httpAuth` and `userInfo` options. Theses are used internally to support the new backend system, and can be ignored. From 46138c2bd73eaa477f23954a0e79abb817232bb2 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Mon, 26 Feb 2024 15:21:35 -0500 Subject: [PATCH 141/176] update to `auth.provider.guest.*` login Signed-off-by: aramissennyeydd --- .changeset/cold-boats-sell.md | 17 ++++++++++-- docs/auth/guest/provider.md | 5 ++-- packages/backend-next/src/index.ts | 4 +-- .../templates/default-app/examples/org.yaml | 9 ------- .../config.d.ts | 27 ++++++++++++++----- .../src/module.ts | 2 +- .../src/resolvers.ts | 21 ++++++++++++--- 7 files changed, 57 insertions(+), 28 deletions(-) diff --git a/.changeset/cold-boats-sell.md b/.changeset/cold-boats-sell.md index af0dd1e295..5b0eba7301 100644 --- a/.changeset/cold-boats-sell.md +++ b/.changeset/cold-boats-sell.md @@ -2,9 +2,22 @@ '@backstage/plugin-auth-backend-module-guest-provider': minor --- -Adds a new guest provider that maps guest users to actual tokens. This also shifts the default guest login to `user:development/guest` to reduce overlap with your production/real data. To change that (or set it back to the old default, use the new `auth.guestEntityRef` config key) like so, +Adds a new guest provider that maps guest users to actual tokens. This also shifts the default guest login to `user:development/guest` to reduce overlap with your production/real data. To change that (or set it back to the old default, use the new `auth.providers.guest.userEntityRef` config key) like so, ```yaml title=app-config.yaml auth: - guestEntityRef: user:default/guest + providers: + guest: + userEntityRef: user:default/guest +``` + +This also adds a new property to control the ownership entity refs, + +```yaml title=app-config.yaml +auth: + providers: + guest: + ownershipEntityRefs: + - guests + - development/custom ``` diff --git a/docs/auth/guest/provider.md b/docs/auth/guest/provider.md index ca5bee1d09..c730877a47 100644 --- a/docs/auth/guest/provider.md +++ b/docs/auth/guest/provider.md @@ -59,9 +59,8 @@ Similar to the other authentication providers, you have to enable the provider i auth: providers: + guest: -+ development: - // new optional property to override the default value. -+ loginAs: user:default/guest ++ userEntityRef: user:default/guest ++ development: {} ``` We need to specify that the provider is enabled for the given environment, and as there are no config values for this provider yet, you can just specify an empty object. diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 58fa994658..403b122ddf 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -20,6 +20,7 @@ const backend = createBackend(); backend.add(import('@backstage/plugin-auth-backend')); backend.add(import('./authModuleGithubProvider')); +backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); backend.add(import('@backstage/plugin-adr-backend')); backend.add(import('@backstage/plugin-app-backend/alpha')); @@ -57,7 +58,4 @@ backend.add(import('@backstage/plugin-sonarqube-backend')); backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@backstage/plugin-notifications-backend')); -backend.add(import('@backstage/plugin-auth-backend')); -backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); - backend.start(); diff --git a/packages/create-app/templates/default-app/examples/org.yaml b/packages/create-app/templates/default-app/examples/org.yaml index 1c4fb91a1e..a10e81fc7f 100644 --- a/packages/create-app/templates/default-app/examples/org.yaml +++ b/packages/create-app/templates/default-app/examples/org.yaml @@ -7,15 +7,6 @@ metadata: spec: memberOf: [guests] --- -# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-user -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: guest - namespace: development -spec: - memberOf: [guests] ---- # https://backstage.io/docs/features/software-catalog/descriptor-format#kind-group apiVersion: backstage.io/v1alpha1 kind: Group diff --git a/plugins/auth-backend-module-guest-provider/config.d.ts b/plugins/auth-backend-module-guest-provider/config.d.ts index d6de29bed6..eb60492393 100644 --- a/plugins/auth-backend-module-guest-provider/config.d.ts +++ b/plugins/auth-backend-module-guest-provider/config.d.ts @@ -17,11 +17,26 @@ export interface Config { /** Configuration options for the auth plugin */ auth?: { - /** - * EXPERIMENTAL value: Allow users to configure what the guest provider logs in as. - * @visibility frontend - * @default user:default/guest - */ - guestEntityRef?: string; + providers: { + guest?: { + /** + * The entity reference to use for the guest user. + * @default user:development/guest + */ + userEntityRef?: string; + + /** + * A list of entity references to user for ownership of the guest user if the user + * is not found in the catalog. + * @default [userEntityRef] + */ + ownershipEntityRefs?: string[]; + + /** + * Allow users to sign in with the guest provider outside of their development environments. + */ + dangerouslyAllowOutsideDevelopment?: boolean; + }; + }; }; } diff --git a/plugins/auth-backend-module-guest-provider/src/module.ts b/plugins/auth-backend-module-guest-provider/src/module.ts index 75eb4f849c..7eac99b0a3 100644 --- a/plugins/auth-backend-module-guest-provider/src/module.ts +++ b/plugins/auth-backend-module-guest-provider/src/module.ts @@ -46,7 +46,7 @@ export const authModuleGuestProvider = createBackendModule({ factory: createProxyAuthProviderFactory({ authenticator: guestAuthenticator, signInResolver: signInAsGuestUser( - config.getOptionalString('auth.guestEntityRef'), + config.getConfig('auth.providers.guest'), ), }), }); diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts index b2c2f8cf7d..bec2ffe12c 100644 --- a/plugins/auth-backend-module-guest-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -15,7 +15,9 @@ */ import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import { SignInResolver } from '@backstage/plugin-auth-node'; +import { NotImplementedError } from '@backstage/errors'; /** * Provide a default implementation of the user to resolve to. By default, this @@ -23,15 +25,26 @@ import { SignInResolver } from '@backstage/plugin-auth-node'; * catalog. If that user doesn't exist in the catalog, we will still create a * token for them so they can keep viewing. */ -export const signInAsGuestUser: (entityRef?: string) => SignInResolver<{}> = - (entityRef?: string) => async (_, ctx) => { +export const signInAsGuestUser: (config: Config) => SignInResolver<{}> = + (config: Config) => async (_, ctx) => { + if ( + process.env.NODE_ENV !== 'development' && + config.getOptionalBoolean('dangerouslyAllowOutsideDevelopment') !== true + ) { + throw new NotImplementedError( + 'The guest provider is NOT recommended for use outside of a development environment. If you want to enable this, set `auth.providers.guest.dangerouslyAllowOutsideDevelopment: true` in your app config.', + ); + } const userRef = - entityRef ?? + config.getOptionalString('userEntityRef') ?? stringifyEntityRef({ kind: 'user', namespace: 'development', name: 'guest', }); + const ownershipRefs = config.getOptionalStringArray( + 'ownershipEntityRefs', + ) ?? [userRef]; try { return ctx.signInWithCatalogUser({ entityRef: userRef }); } catch (err) { @@ -39,7 +52,7 @@ export const signInAsGuestUser: (entityRef?: string) => SignInResolver<{}> = return ctx.issueToken({ claims: { sub: userRef, - ent: [userRef], + ent: ownershipRefs, }, }); } From 5d1046dd206e1f264120a2ff28ef5acb89e8c3c3 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Mon, 26 Feb 2024 15:22:38 -0500 Subject: [PATCH 142/176] add config to guest provider Signed-off-by: aramissennyeydd --- plugins/auth-backend-module-guest-provider/package.json | 1 + plugins/auth-backend-module-guest-provider/src/resolvers.ts | 2 +- yarn.lock | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend-module-guest-provider/package.json b/plugins/auth-backend-module-guest-provider/package.json index edf3210759..bab85c2bd0 100644 --- a/plugins/auth-backend-module-guest-provider/package.json +++ b/plugins/auth-backend-module-guest-provider/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", "express": "^4.18.2" }, "files": [ diff --git a/plugins/auth-backend-module-guest-provider/src/resolvers.ts b/plugins/auth-backend-module-guest-provider/src/resolvers.ts index bec2ffe12c..35f724f746 100644 --- a/plugins/auth-backend-module-guest-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-guest-provider/src/resolvers.ts @@ -15,7 +15,7 @@ */ import { stringifyEntityRef } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; +import type { Config } from '@backstage/config'; import { SignInResolver } from '@backstage/plugin-auth-node'; import { NotImplementedError } from '@backstage/errors'; diff --git a/yarn.lock b/yarn.lock index 1bd1a644bb..3a1d1a2d65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4691,6 +4691,7 @@ __metadata: "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" express: ^4.18.2 From 67276652e6404809c995e3e6c410b69c08d68080 Mon Sep 17 00:00:00 2001 From: Boris Bera Date: Sun, 25 Feb 2024 11:18:11 -0500 Subject: [PATCH 143/176] Make `spec.target` searchable in catalog table for location Signed-off-by: Boris Bera --- .changeset/wet-sheep-reply.md | 5 +++++ .../catalog/src/components/CatalogTable/columns.tsx | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .changeset/wet-sheep-reply.md diff --git a/.changeset/wet-sheep-reply.md b/.changeset/wet-sheep-reply.md new file mode 100644 index 0000000000..27635b133e --- /dev/null +++ b/.changeset/wet-sheep-reply.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Allow the `spec.target` field to be searchable in the catalog table for locations. Previously, only the `spec.targets` field was be searchable. This makes locations generated by providers such as the `GithubEntityProvider` searchable in the catalog table. [#23098](https://github.com/backstage/backstage/issues/23098) diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 4260a0c26b..a191befe59 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -86,6 +86,18 @@ export const columnFactories = Object.freeze({ return { title: 'Targets', field: 'entity.spec.targets', + customFilterAndSearch: (query, row) => { + const targets = []; + if (Array.isArray(row.entity?.spec?.targets)) { + targets.push(...row.entity?.spec?.targets); + } else if (row.entity?.spec?.target) { + targets.push(row.entity?.spec?.target); + } + return targets + .join(', ') + .toLocaleUpperCase('en-US') + .includes(query.toLocaleUpperCase('en-US')); + }, render: ({ entity }) => ( <> {(entity?.spec?.targets || entity?.spec?.target) && ( From 1b2dc6c815c298bea792f3d9a1e0fd88d52a0578 Mon Sep 17 00:00:00 2001 From: Boris Bera Date: Mon, 26 Feb 2024 16:03:46 -0500 Subject: [PATCH 144/176] Appease typescript Signed-off-by: Boris Bera --- .../catalog/src/components/CatalogTable/columns.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index a191befe59..a955aaadb9 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -87,11 +87,14 @@ export const columnFactories = Object.freeze({ title: 'Targets', field: 'entity.spec.targets', customFilterAndSearch: (query, row) => { - const targets = []; - if (Array.isArray(row.entity?.spec?.targets)) { - targets.push(...row.entity?.spec?.targets); + let targets: JsonArray = []; + if ( + row.entity?.spec?.targets && + Array.isArray(row.entity?.spec?.targets) + ) { + targets = row.entity?.spec?.targets; } else if (row.entity?.spec?.target) { - targets.push(row.entity?.spec?.target); + targets = [row.entity?.spec?.target]; } return targets .join(', ') From 2e374918084a017595488dab1c2e37ad9bd38d9e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Feb 2024 01:44:23 +0100 Subject: [PATCH 145/176] Update beps/0003-auth-architecture-evolution/README.md Signed-off-by: Patrik Oldsberg --- beps/0003-auth-architecture-evolution/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index 998eb08dbd..e7bbb2804d 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -232,7 +232,7 @@ export default createBackendPlugin({ // Endpoint that sets the cookie for the user router.get('/cookie', async (req, res) => { - const { expiresAt } = await httpAuth.issueUserCookie(req); + const { expiresAt } = await httpAuth.issueUserCookie(res); res.json({ expiresAt: expiresAt.toISOString() }); }); From 789986094e53c5b2856cd6fb8cd44673ac4c132f Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 27 Feb 2024 09:57:34 +0100 Subject: [PATCH 146/176] fix(api-docs): wrap nav icon with compat wrapper Signed-off-by: Camila Belo --- plugins/api-docs/src/alpha.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 29bb0bcfa9..8cb43e9108 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -49,7 +49,7 @@ import { AppIcon } from '@backstage/core-components'; const apiDocsNavItem = createNavItemExtension({ title: 'APIs', routeRef: convertLegacyRouteRef(rootRoute), - icon: () => , + icon: () => compatWrapper(), }); const apiDocsConfigApi = createApiExtension({ From d42a5529292035003349eff2266fc44eed46c117 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Tue, 27 Feb 2024 10:00:53 +0100 Subject: [PATCH 147/176] fix: update core components changeset Signed-off-by: Camila Belo --- .changeset/friendly-news-sin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/friendly-news-sin.md b/.changeset/friendly-news-sin.md index 29843c54fb..af64190f9c 100644 --- a/.changeset/friendly-news-sin.md +++ b/.changeset/friendly-news-sin.md @@ -1,5 +1,5 @@ --- -'@backstage/core-components': minor +'@backstage/core-components': patch --- Create a component abstraction to consume system icons. From a959dc064df5e4b56cfe661b9bb21f808c6cbd33 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 27 Feb 2024 11:19:47 +0100 Subject: [PATCH 148/176] chore: fix build Signed-off-by: blam --- plugins/azure-sites-backend/src/plugin.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugins/azure-sites-backend/src/plugin.ts b/plugins/azure-sites-backend/src/plugin.ts index 90a9c416bd..8612158f54 100644 --- a/plugins/azure-sites-backend/src/plugin.ts +++ b/plugins/azure-sites-backend/src/plugin.ts @@ -36,9 +36,17 @@ export const azureSitesPlugin = createBackendPlugin({ logger: coreServices.logger, httpRouter: coreServices.httpRouter, permissions: coreServices.permissions, + discovery: coreServices.discovery, catalogApi: catalogServiceRef, }, - async init({ config, logger, httpRouter, permissions, catalogApi }) { + async init({ + config, + logger, + httpRouter, + permissions, + catalogApi, + discovery, + }) { const azureSitesApi = AzureSitesApi.fromConfig(config); httpRouter.use( await createRouter({ @@ -46,6 +54,7 @@ export const azureSitesPlugin = createBackendPlugin({ azureSitesApi, permissions, catalogApi, + discovery, }), ); }, From c7683174db07476338b74a8c2b8c1a675e020c8e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Feb 2024 12:13:38 +0100 Subject: [PATCH 149/176] azure-sites-backend: forward auth services in new system Signed-off-by: Patrik Oldsberg --- plugins/azure-sites-backend/src/plugin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/azure-sites-backend/src/plugin.ts b/plugins/azure-sites-backend/src/plugin.ts index 8612158f54..a8afe4e430 100644 --- a/plugins/azure-sites-backend/src/plugin.ts +++ b/plugins/azure-sites-backend/src/plugin.ts @@ -37,6 +37,8 @@ export const azureSitesPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, permissions: coreServices.permissions, discovery: coreServices.discovery, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, catalogApi: catalogServiceRef, }, async init({ @@ -46,6 +48,8 @@ export const azureSitesPlugin = createBackendPlugin({ permissions, catalogApi, discovery, + auth, + httpAuth, }) { const azureSitesApi = AzureSitesApi.fromConfig(config); httpRouter.use( @@ -55,6 +59,8 @@ export const azureSitesPlugin = createBackendPlugin({ permissions, catalogApi, discovery, + auth, + httpAuth, }), ); }, From 13bb2ee787ef34eb30f16aa12c1607c94745bcd0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Feb 2024 09:48:58 +0100 Subject: [PATCH 150/176] backend-app-api: make sure auth service is compatible with existing plugins in dev Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.test.ts | 2 ++ .../auth/authServiceFactory.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts index f24264930a..4600110b5c 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -28,10 +28,12 @@ import { BackstageServicePrincipal, BackstageUserPrincipal, } from '@backstage/backend-plugin-api'; +import { tokenManagerServiceFactory } from '../tokenManager'; // TODO: Ship discovery mock service in the service factory tester const mockDeps = [ discoveryServiceFactory(), + tokenManagerServiceFactory, mockServices.rootConfig.factory({ data: { backend: { diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 72d5635f61..19dfbe1299 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ServerTokenManager, TokenManager } from '@backstage/backend-common'; +import { TokenManager } from '@backstage/backend-common'; import { AuthService, BackstageCredentials, @@ -27,10 +27,7 @@ import { createServiceFactory, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; -import { - DefaultIdentityClient, - IdentityApiGetIdentityRequest, -} from '@backstage/plugin-auth-node'; +import { IdentityApiGetIdentityRequest } from '@backstage/plugin-auth-node'; import { decodeJwt } from 'jose'; /** @internal */ @@ -204,14 +201,15 @@ export const authServiceFactory = createServiceFactory({ deps: { config: coreServices.rootConfig, logger: coreServices.rootLogger, - discovery: coreServices.discovery, plugin: coreServices.pluginMetadata, + identity: coreServices.identity, + // Re-using the token manager makes sure that we use the same generated keys for + // development as plugins that have not yet been migrated. It's important that this + // keeps working as long as there are plugins that have not been migrated to the + // new auth services in the new backend system. + tokenManager: coreServices.tokenManager, }, - createRootContext({ config, logger }) { - return ServerTokenManager.fromConfig(config, { logger }); - }, - async factory({ discovery, config, plugin }, tokenManager) { - const identity = DefaultIdentityClient.create({ discovery }); + async factory({ config, plugin, identity, tokenManager }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', From e1e540cd1da439edba15b697cc2d45e50293ca56 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 18:40:18 +0100 Subject: [PATCH 151/176] kubernetes-backend: migrate to support new auth services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .changeset/rare-dryers-check.md | 5 ++ packages/backend/src/plugins/kubernetes.ts | 1 + plugins/kubernetes-backend/api-report.md | 11 ++++ plugins/kubernetes-backend/src/plugin.ts | 17 ++++++- .../src/routes/resourceRoutes.test.ts | 51 +++++++++---------- .../src/routes/resourcesRoutes.ts | 21 ++++---- .../src/service/KubernetesBuilder.test.ts | 7 +-- .../src/service/KubernetesBuilder.ts | 28 +++++++++- 8 files changed, 98 insertions(+), 43 deletions(-) create mode 100644 .changeset/rare-dryers-check.md diff --git a/.changeset/rare-dryers-check.md b/.changeset/rare-dryers-check.md new file mode 100644 index 0000000000..584481f54d --- /dev/null +++ b/.changeset/rare-dryers-check.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +**BREAKING**: The `KubernetesBuilder.createBuilder` method now requires the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services. diff --git a/packages/backend/src/plugins/kubernetes.ts b/packages/backend/src/plugins/kubernetes.ts index 3bc6648862..5581083879 100644 --- a/packages/backend/src/plugins/kubernetes.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -28,6 +28,7 @@ export default async function createPlugin( config: env.config, catalogApi, permissions: env.permissions, + discovery: env.discovery, }).build(); return router; } diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index afdb3bcf9a..60331ca044 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -5,12 +5,15 @@ ```ts import { AuthenticationStrategy as AuthenticationStrategy_2 } from '@backstage/plugin-kubernetes-node'; import { AuthMetadata as AuthMetadata_2 } from '@backstage/plugin-kubernetes-node'; +import { AuthService } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { ClusterDetails as ClusterDetails_2 } from '@backstage/plugin-kubernetes-node'; import { Config } from '@backstage/config'; import { CustomResource as CustomResource_2 } from '@backstage/plugin-kubernetes-node'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { Duration } from 'luxon'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import * as k8sAuthTypes from '@backstage/plugin-kubernetes-node'; import { KubernetesClustersSupplier as KubernetesClustersSupplier_2 } from '@backstage/plugin-kubernetes-node'; import { KubernetesCredential as KubernetesCredential_2 } from '@backstage/plugin-kubernetes-node'; @@ -190,6 +193,8 @@ export class KubernetesBuilder { catalogApi: CatalogApi, proxy: KubernetesProxy, permissionApi: PermissionEvaluator, + authService: AuthService, + httpAuth: HttpAuthService, ): express.Router; // (undocumented) protected buildServiceLocator( @@ -272,11 +277,17 @@ export type KubernetesCredential = k8sAuthTypes.KubernetesCredential; // @public (undocumented) export interface KubernetesEnvironment { + // (undocumented) + auth?: AuthService; // (undocumented) catalogApi: CatalogApi; // (undocumented) config: Config; // (undocumented) + discovery: DiscoveryService; + // (undocumented) + httpAuth?: HttpAuthService; + // (undocumented) logger: Logger; // (undocumented) permissions: PermissionEvaluator; diff --git a/plugins/kubernetes-backend/src/plugin.ts b/plugins/kubernetes-backend/src/plugin.ts index 74a8a68e53..d3aacd5190 100644 --- a/plugins/kubernetes-backend/src/plugin.ts +++ b/plugins/kubernetes-backend/src/plugin.ts @@ -178,10 +178,22 @@ export const kubernetesPlugin = createBackendPlugin({ http: coreServices.httpRouter, logger: coreServices.logger, config: coreServices.rootConfig, + discovery: coreServices.discovery, catalogApi: catalogServiceRef, permissions: coreServices.permissions, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, }, - async init({ http, logger, config, catalogApi, permissions }) { + async init({ + http, + logger, + config, + discovery, + catalogApi, + permissions, + auth, + httpAuth, + }) { const winstonLogger = loggerToWinstonLogger(logger); // TODO: expose all of the customization & extension points of the builder here const builder: KubernetesBuilder = KubernetesBuilder.createBuilder({ @@ -189,6 +201,9 @@ export const kubernetesPlugin = createBackendPlugin({ config, catalogApi, permissions, + discovery, + auth, + httpAuth, }) .setObjectsProvider(extPointObjectsProvider.getObjectsProvider()) .setClusterSupplier(extPointClusterSuplier.getClusterSupplier()) diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts index d3dcc83bb0..9195551993 100644 --- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts +++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts @@ -15,7 +15,11 @@ */ import request from 'supertest'; -import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; import { ExtendedHttpServer } from '@backstage/backend-app-api'; import { kubernetesObjectsProviderExtensionPoint } from '@backstage/plugin-kubernetes-node'; import { createBackendModule } from '@backstage/backend-plugin-api'; @@ -102,12 +106,6 @@ describe('resourcesRoutes', () => { }, ], }, - backend: { - auth: { - // TODO: Remove once migrated to support new auth services - dangerouslyDisableDefaultAuthPolicy: true, - }, - }, }, }), import('@backstage/plugin-kubernetes-backend/alpha'), @@ -141,7 +139,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(200, { items: [ { @@ -168,7 +165,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', message: 'entity is a required field' }, request: { @@ -189,7 +185,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -214,7 +209,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -231,6 +225,7 @@ describe('resourcesRoutes', () => { it('401 when no Auth header', async () => { await request(app) .post('/api/kubernetes/resources/workloads/query') + .set('authorization', mockCredentials.none.header()) .send({ entityRef: 'component:someComponent', auth: { @@ -239,7 +234,10 @@ describe('resourcesRoutes', () => { }) .set('Content-Type', 'application/json') .expect(401, { - error: { name: 'AuthenticationError', message: 'No Backstage token' }, + error: { + name: 'AuthenticationError', + message: '', + }, request: { method: 'POST', url: '/api/kubernetes/resources/workloads/query', @@ -258,9 +256,12 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'ffffff') + .set('Authorization', mockCredentials.user.invalidHeader()) .expect(401, { - error: { name: 'AuthenticationError', message: 'No Backstage token' }, + error: { + name: 'AuthenticationError', + message: 'User token is invalid', + }, request: { method: 'POST', url: '/api/kubernetes/resources/workloads/query', @@ -279,7 +280,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(500, { error: { name: 'Error', @@ -312,7 +312,6 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(200, { items: [ { @@ -340,7 +339,6 @@ describe('resourcesRoutes', () => { }, }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -365,7 +363,6 @@ describe('resourcesRoutes', () => { customResources: 'somestring', }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -390,7 +387,6 @@ describe('resourcesRoutes', () => { customResources: [], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -420,7 +416,6 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', message: 'entity is a required field' }, request: { @@ -448,7 +443,6 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -480,7 +474,6 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(400, { error: { name: 'InputError', @@ -497,6 +490,7 @@ describe('resourcesRoutes', () => { it('401 when no Auth header', async () => { await request(app) .post('/api/kubernetes/resources/custom/query') + .set('authorization', mockCredentials.none.header()) .send({ entityRef: 'component:someComponent', auth: { @@ -512,7 +506,10 @@ describe('resourcesRoutes', () => { }) .set('Content-Type', 'application/json') .expect(401, { - error: { name: 'AuthenticationError', message: 'No Backstage token' }, + error: { + name: 'AuthenticationError', + message: '', + }, request: { method: 'POST', url: '/api/kubernetes/resources/custom/query', @@ -538,9 +535,12 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'ffffff') + .set('Authorization', mockCredentials.user.invalidHeader()) .expect(401, { - error: { name: 'AuthenticationError', message: 'No Backstage token' }, + error: { + name: 'AuthenticationError', + message: 'User token is invalid', + }, request: { method: 'POST', url: '/api/kubernetes/resources/custom/query', @@ -566,7 +566,6 @@ describe('resourcesRoutes', () => { ], }) .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer Zm9vYmFy') .expect(500, { error: { name: 'Error', diff --git a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts index 0468799908..6e05f69d04 100644 --- a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts +++ b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts @@ -19,15 +19,17 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import { CatalogApi } from '@backstage/catalog-client'; -import { InputError, AuthenticationError } from '@backstage/errors'; +import { InputError } from '@backstage/errors'; import express, { Request } from 'express'; import { KubernetesObjectsProvider } from '@backstage/plugin-kubernetes-node'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api'; export const addResourceRoutesToRouter = ( router: express.Router, catalogApi: CatalogApi, objectsProvider: KubernetesObjectsProvider, + auth: AuthService, + httpAuth: HttpAuthService, ) => { const getEntityByReq = async (req: Request) => { const rawEntityRef = req.body.entityRef; @@ -44,23 +46,18 @@ export const addResourceRoutesToRouter = ( throw new InputError(`Invalid entity ref, ${error}`); } - const token = getBearerTokenFromAuthorizationHeader( - req.headers.authorization, - ); - - if (!token) { - throw new AuthenticationError('No Backstage token'); - } - - const entity = await catalogApi.getEntityByRef(entityRef, { - token: token, + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await httpAuth.credentials(req), + targetPluginId: 'catalog', }); + const entity = await catalogApi.getEntityByRef(entityRef, { token }); if (!entity) { throw new InputError( `Entity ref missing, ${stringifyEntityRef(entityRef)}`, ); } + return entity; }; diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index 295d11570f..1ac653bb58 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -36,6 +36,7 @@ import { import { setupServer } from 'msw/node'; import { ServiceMock, + mockCredentials, mockServices, setupRequestMockHandlers, startTestBackend, @@ -757,9 +758,9 @@ metadata: }); it('serves permission integration endpoint', async () => { - const response = await request(app).get( - '/api/kubernetes/.well-known/backstage/permissions/metadata', - ); + const response = await request(app) + .get('/api/kubernetes/.well-known/backstage/permissions/metadata') + .set('authorization', mockCredentials.service.header()); expect(response.status).toEqual(200); expect(response.body).toMatchObject({ diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index fec4bc5cc0..c63d039ce9 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -64,6 +64,12 @@ import { } from './KubernetesFanOutHandler'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesProxy } from './KubernetesProxy'; +import { createLegacyAuthAdapters } from '@backstage/backend-common'; +import { + AuthService, + DiscoveryService, + HttpAuthService, +} from '@backstage/backend-plugin-api'; /** * @@ -73,7 +79,10 @@ export interface KubernetesEnvironment { logger: Logger; config: Config; catalogApi: CatalogApi; + discovery: DiscoveryService; permissions: PermissionEvaluator; + auth?: AuthService; + httpAuth?: HttpAuthService; } /** @@ -131,6 +140,13 @@ export class KubernetesBuilder { router: Router(), } as unknown as KubernetesBuilderReturn; } + + const { auth, httpAuth } = createLegacyAuthAdapters({ + auth: this.env.auth, + httpAuth: this.env.httpAuth, + discovery: this.env.discovery, + }); + const customResources = this.buildCustomResources(); const fetcher = this.getFetcher(); @@ -158,6 +174,8 @@ export class KubernetesBuilder { this.env.catalogApi, proxy, permissions, + auth, + httpAuth, ); return { @@ -337,6 +355,8 @@ export class KubernetesBuilder { catalogApi: CatalogApi, proxy: KubernetesProxy, permissionApi: PermissionEvaluator, + authService: AuthService, + httpAuth: HttpAuthService, ): express.Router { const logger = this.env.logger; const router = Router(); @@ -391,7 +411,13 @@ export class KubernetesBuilder { }); }); - addResourceRoutesToRouter(router, catalogApi, objectsProvider); + addResourceRoutesToRouter( + router, + catalogApi, + objectsProvider, + authService, + httpAuth, + ); return router; } From d50a02bf7d4c1c3c019a407e6462c1416b6375de Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Sun, 18 Feb 2024 13:43:00 +0100 Subject: [PATCH 152/176] Introducing createMockActionContext for scaffolder Signed-off-by: bnechyporenko --- packages/scaffolder-test-utils/.eslintrc.js | 1 + packages/scaffolder-test-utils/CHANGELOG.md | 1 + packages/scaffolder-test-utils/README.md | 12 +++++ packages/scaffolder-test-utils/api-report.md | 18 +++++++ .../scaffolder-test-utils/catalog-info.yaml | 9 ++++ packages/scaffolder-test-utils/knip-report.md | 2 + packages/scaffolder-test-utils/package.json | 48 +++++++++++++++++++ .../src/actions/index.ts | 17 +++++++ .../src/actions/mockActionConext.ts | 46 ++++++++++++++++++ packages/scaffolder-test-utils/src/index.ts | 17 +++++++ .../package.json | 3 +- .../src/actions/azure.test.ts | 17 ++----- yarn.lock | 18 +++++++ 13 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 packages/scaffolder-test-utils/.eslintrc.js create mode 100644 packages/scaffolder-test-utils/CHANGELOG.md create mode 100644 packages/scaffolder-test-utils/README.md create mode 100644 packages/scaffolder-test-utils/api-report.md create mode 100644 packages/scaffolder-test-utils/catalog-info.yaml create mode 100644 packages/scaffolder-test-utils/knip-report.md create mode 100644 packages/scaffolder-test-utils/package.json create mode 100644 packages/scaffolder-test-utils/src/actions/index.ts create mode 100644 packages/scaffolder-test-utils/src/actions/mockActionConext.ts create mode 100644 packages/scaffolder-test-utils/src/index.ts diff --git a/packages/scaffolder-test-utils/.eslintrc.js b/packages/scaffolder-test-utils/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/scaffolder-test-utils/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/scaffolder-test-utils/CHANGELOG.md b/packages/scaffolder-test-utils/CHANGELOG.md new file mode 100644 index 0000000000..e290a56ced --- /dev/null +++ b/packages/scaffolder-test-utils/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/scaffolder-test-utils diff --git a/packages/scaffolder-test-utils/README.md b/packages/scaffolder-test-utils/README.md new file mode 100644 index 0000000000..e5810058b3 --- /dev/null +++ b/packages/scaffolder-test-utils/README.md @@ -0,0 +1,12 @@ +# @backstage/scaffolder-test-utils + +Contains utilities that can be used when testing scaffolder features. + +## Installation + +Install the package via Yarn into your own packages: + +```sh +cd # if within a monorepo +yarn add --dev @backstage/scaffolder-test-utils +``` diff --git a/packages/scaffolder-test-utils/api-report.md b/packages/scaffolder-test-utils/api-report.md new file mode 100644 index 0000000000..b95b020f1c --- /dev/null +++ b/packages/scaffolder-test-utils/api-report.md @@ -0,0 +1,18 @@ +## API Report File for "@backstage/scaffolder-test-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { JsonObject } from '@backstage/types'; + +// @public +export const createMockActionContext: < + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +>( + input?: TActionInput | undefined, +) => ActionContext; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/scaffolder-test-utils/catalog-info.yaml b/packages/scaffolder-test-utils/catalog-info.yaml new file mode 100644 index 0000000000..596e9b1f64 --- /dev/null +++ b/packages/scaffolder-test-utils/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-scaffolder-test-utils + title: '@backstage/scaffolder-test-utils' +spec: + lifecycle: experimental + type: backstage-node-library + owner: maintainers diff --git a/packages/scaffolder-test-utils/knip-report.md b/packages/scaffolder-test-utils/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/packages/scaffolder-test-utils/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/packages/scaffolder-test-utils/package.json b/packages/scaffolder-test-utils/package.json new file mode 100644 index 0000000000..9db983d828 --- /dev/null +++ b/packages/scaffolder-test-utils/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/scaffolder-test-utils", + "version": "0.0.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/scaffolder-test-utils" + }, + "backstage": { + "role": "node-library" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@types/react": "*" + }, + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@backstage/types": "workspace:^" + }, + "peerDependencies": { + "@types/jest": "*" + } +} diff --git a/packages/scaffolder-test-utils/src/actions/index.ts b/packages/scaffolder-test-utils/src/actions/index.ts new file mode 100644 index 0000000000..161bae8521 --- /dev/null +++ b/packages/scaffolder-test-utils/src/actions/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createMockActionContext } from './mockActionConext'; diff --git a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts new file mode 100644 index 0000000000..a838c091f8 --- /dev/null +++ b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 { PassThrough } from 'stream'; +import { getVoidLogger } from '@backstage/backend-common'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { JsonObject } from '@backstage/types'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; + +/** + * A utility method to create a mock action context for scaffolder actions. + * + * @param input - a schema for user input parameters + * + * @public + */ +export const createMockActionContext = < + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, +>( + input?: TActionInput, +): ActionContext => { + const mockDir = createMockDirectory(); + + return { + workspacePath: mockDir.path, + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + input: (input ? input : {}) as TActionInput, + }; +}; diff --git a/packages/scaffolder-test-utils/src/index.ts b/packages/scaffolder-test-utils/src/index.ts new file mode 100644 index 0000000000..19eae8d569 --- /dev/null +++ b/packages/scaffolder-test-utils/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './actions'; diff --git a/plugins/scaffolder-backend-module-azure/package.json b/plugins/scaffolder-backend-module-azure/package.json index 26be04f108..2bbcedf897 100644 --- a/plugins/scaffolder-backend-module-azure/package.json +++ b/plugins/scaffolder-backend-module-azure/package.json @@ -47,7 +47,8 @@ "yaml": "^2.0.0" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts index 4401e01007..0dbacaf699 100644 --- a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts +++ b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts @@ -34,10 +34,9 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { import { createPublishAzureAction } from './azure'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { WebApi } from 'azure-devops-node-api'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:azure', () => { const config = new ConfigReader({ @@ -54,16 +53,10 @@ describe('publish:azure', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishAzureAction({ integrations, config }); - const mockContext = { - input: { - repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org', - }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + + const mockContext = createMockActionContext({ + repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org', + }); const mockGitClient = { createRepository: jest.fn(), diff --git a/yarn.lock b/yarn.lock index 3b404940a9..f6a737a69f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8209,6 +8209,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" azure-devops-node-api: ^12.0.0 yaml: ^2.0.0 languageName: unknown @@ -9864,6 +9865,23 @@ __metadata: languageName: unknown linkType: soft +"@backstage/scaffolder-test-utils@workspace:^, @backstage/scaffolder-test-utils@workspace:packages/scaffolder-test-utils": + version: 0.0.0-use.local + resolution: "@backstage/scaffolder-test-utils@workspace:packages/scaffolder-test-utils" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@testing-library/jest-dom": ^6.0.0 + "@types/react": "*" + peerDependencies: + "@types/jest": "*" + languageName: unknown + linkType: soft + "@backstage/test-utils@workspace:^, @backstage/test-utils@workspace:packages/test-utils": version: 0.0.0-use.local resolution: "@backstage/test-utils@workspace:packages/test-utils" From 1615cfdf3f5ee61dcdf9420962397e272ffba970 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Sun, 18 Feb 2024 14:07:16 +0100 Subject: [PATCH 153/176] wip Signed-off-by: bnechyporenko --- .../src/actions/mockActionConext.ts | 13 +++++++--- .../src/actions/azure.examples.test.ts | 11 ++------ .../package.json | 1 + .../src/actions/bitbucketCloud.test.ts | 18 ++++--------- ...itbucketCloudPipelinesRun.examples.test.ts | 12 ++------- .../bitbucketCloudPipelinesRun.test.ts | 12 ++------- .../package.json | 1 + .../src/actions/bitbucketServer.test.ts | 18 ++++--------- .../bitbucketServerPullRequest.test.ts | 26 +++++++------------ .../package.json | 1 + .../src/actions/bitbucket.examples.test.ts | 18 ++++--------- .../src/actions/bitbucket.test.ts | 18 ++++--------- .../package.json | 1 + .../confluenceToMarkdown.examples.test.ts | 11 +++----- yarn.lock | 5 ++++ 15 files changed, 57 insertions(+), 109 deletions(-) diff --git a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts index a838c091f8..6b2e992487 100644 --- a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts +++ b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts @@ -19,12 +19,15 @@ import { getVoidLogger } from '@backstage/backend-common'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { JsonObject } from '@backstage/types'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import * as winston from 'winston'; /** * A utility method to create a mock action context for scaffolder actions. * * @param input - a schema for user input parameters * + * @param workspacePath + * @param logger * @public */ export const createMockActionContext = < @@ -32,12 +35,14 @@ export const createMockActionContext = < TActionOutput extends JsonObject = JsonObject, >( input?: TActionInput, + workspacePath?: string, + logger?: winston.Logger, ): ActionContext => { - const mockDir = createMockDirectory(); - return { - workspacePath: mockDir.path, - logger: getVoidLogger(), + workspacePath: workspacePath + ? workspacePath + : createMockDirectory().resolve('workspace'), + logger: logger ? logger : getVoidLogger(), logStream: new PassThrough(), output: jest.fn(), createTemporaryDirectory: jest.fn(), diff --git a/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts b/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts index 27989650ce..7634480bc5 100644 --- a/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts +++ b/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts @@ -18,11 +18,10 @@ import yaml from 'yaml'; import { ConfigReader } from '@backstage/config'; import { createPublishAzureAction } from './azure'; import { ScmIntegrations } from '@backstage/integration'; -import { getVoidLogger } from '@backstage/backend-common'; import { WebApi } from 'azure-devops-node-api'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { examples } from './azure.examples'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; jest.mock('azure-devops-node-api', () => ({ WebApi: jest.fn(), @@ -55,13 +54,7 @@ describe('publish:azure examples', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishAzureAction({ integrations, config }); - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); const mockGitClient = { createRepository: jest.fn(), diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index 1a40b517d6..1ec9c4083c 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json @@ -50,6 +50,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts index 7045d58e01..0b7bf95a33 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts @@ -32,9 +32,8 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:bitbucketCloud', () => { const config = new ConfigReader({ @@ -50,17 +49,10 @@ describe('publish:bitbucketCloud', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketCloudAction({ integrations, config }); - const mockContext = { - input: { - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, - }, - workspacePath: 'wsp', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts index 90ae2c08b9..1786ff26e2 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts @@ -14,16 +14,15 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { PassThrough } from 'stream'; import { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; import yaml from 'yaml'; import { examples } from './bitbucketCloudPipelinesRun.examples'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('bitbucket:pipelines:run', () => { const config = new ConfigReader({ @@ -39,14 +38,7 @@ describe('bitbucket:pipelines:run', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createBitbucketPipelinesRunAction({ integrations }); - const mockContext = { - input: {}, - workspacePath: 'wsp', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); const responseJson = { repository: { links: { diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts index dc76a79898..64b4e8f7fe 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts @@ -14,14 +14,13 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { PassThrough } from 'stream'; import { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('bitbucket:pipelines:run', () => { const config = new ConfigReader({ @@ -37,14 +36,7 @@ describe('bitbucket:pipelines:run', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createBitbucketPipelinesRunAction({ integrations }); - const mockContext = { - input: {}, - workspacePath: 'wsp', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); const workspace = 'test-workspace'; const repo_slug = 'test-repo-slug'; const responseJson = { diff --git a/plugins/scaffolder-backend-module-bitbucket-server/package.json b/plugins/scaffolder-backend-module-bitbucket-server/package.json index 8f4841180d..dee14897ab 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-server/package.json @@ -50,6 +50,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts index ab7b52996f..9a51fef5e8 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts @@ -32,9 +32,8 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:bitbucketServer', () => { const config = new ConfigReader({ @@ -60,17 +59,10 @@ describe('publish:bitbucketServer', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketServerAction({ integrations, config }); - const mockContext = { - input: { - repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', - repoVisibility: 'private' as const, - }, - workspacePath: 'wsp', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + repoVisibility: 'private' as const, + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts index 3af47853c3..c4ae8b9dde 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts @@ -32,8 +32,7 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:bitbucketServer:pull-request', () => { const config = new ConfigReader({ @@ -62,21 +61,14 @@ describe('publish:bitbucketServer:pull-request', () => { integrations, config, }); - const mockContext = { - input: { - repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', - title: 'Add Scaffolder actions for Bitbucket Server', - description: - 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server', - targetBranch: 'master', - sourceBranch: 'develop', - }, - workspacePath: 'wsp', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + title: 'Add Scaffolder actions for Bitbucket Server', + description: + 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server', + targetBranch: 'master', + sourceBranch: 'develop', + }); const responseOfBranches = { size: 3, limit: 25, diff --git a/plugins/scaffolder-backend-module-bitbucket/package.json b/plugins/scaffolder-backend-module-bitbucket/package.json index c34c07c008..2a88ff8525 100644 --- a/plugins/scaffolder-backend-module-bitbucket/package.json +++ b/plugins/scaffolder-backend-module-bitbucket/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts index 73325ab5bf..b89508e1ca 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts @@ -32,12 +32,11 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import yaml from 'yaml'; import { sep } from 'path'; import { examples } from './bitbucket.examples'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:bitbucket', () => { const config = new ConfigReader({ @@ -61,17 +60,10 @@ describe('publish:bitbucket', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketAction({ integrations, config }); - const mockContext = { - input: { - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, - }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts index d6e068c039..80279afec6 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts @@ -31,9 +31,8 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:bitbucket', () => { const config = new ConfigReader({ @@ -57,17 +56,10 @@ describe('publish:bitbucket', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketAction({ integrations, config }); - const mockContext = { - input: { - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, - }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json index 07d72d73a6..67b899991e 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json @@ -54,6 +54,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts index f3bf1fef35..bdbd341660 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PassThrough } from 'stream'; import { createConfluenceToMarkdownAction } from './confluenceToMarkdown'; import { getVoidLogger } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; @@ -28,6 +27,7 @@ import { setupServer } from 'msw/node'; import { examples } from './confluenceToMarkdown.examples'; import yaml from 'yaml'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('confluence:transform:markdown examples', () => { const baseUrl = `https://confluence.example.com`; @@ -71,14 +71,11 @@ describe('confluence:transform:markdown examples', () => { }), search: jest.fn(), }; - mockContext = { - input: yaml.parse(examples[0].example).steps[0].input, + mockContext = createMockActionContext( + yaml.parse(examples[0].example).steps[0].input, workspacePath, logger, - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + ); mockDir.setContent({ 'workspace/mkdocs.yml': 'File contents' }); }); diff --git a/yarn.lock b/yarn.lock index f6a737a69f..6bbb18da35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8227,6 +8227,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" fs-extra: ^11.2.0 msw: ^1.0.0 node-fetch: ^2.6.7 @@ -8246,6 +8247,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" fs-extra: ^11.2.0 msw: ^1.0.0 node-fetch: ^2.6.7 @@ -8267,6 +8269,7 @@ __metadata: "@backstage/plugin-scaffolder-backend-module-bitbucket-cloud": "workspace:^" "@backstage/plugin-scaffolder-backend-module-bitbucket-server": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" fs-extra: ^11.2.0 msw: ^1.0.0 node-fetch: ^2.6.7 @@ -8286,6 +8289,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" fs-extra: ^11.2.0 git-url-parse: ^14.0.0 msw: ^1.0.0 @@ -9877,6 +9881,7 @@ __metadata: "@backstage/types": "workspace:^" "@testing-library/jest-dom": ^6.0.0 "@types/react": "*" + winston: ^3.2.1 peerDependencies: "@types/jest": "*" languageName: unknown From c94a2f946c578570d7a2e167b46b336db6fdd6b1 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Sun, 18 Feb 2024 15:02:15 +0100 Subject: [PATCH 154/176] wip Signed-off-by: bnechyporenko --- packages/scaffolder-test-utils/package.json | 4 +- .../src/actions/mockActionConext.ts | 46 +++++--- .../src/actions/azure.test.ts | 2 +- .../src/actions/bitbucketCloud.test.ts | 6 +- .../bitbucketServerPullRequest.test.ts | 14 ++- .../src/actions/bitbucket.examples.test.ts | 6 +- .../src/actions/bitbucket.test.ts | 6 +- .../confluenceToMarkdown.examples.test.ts | 6 +- .../confluence/confluenceToMarkdown.test.ts | 10 +- .../package.json | 1 + .../src/actions/fetch/cookiecutter.test.ts | 21 +--- .../package.json | 1 + .../src/actions/gerrit.test.ts | 12 +- .../src/actions/gerritReview.test.ts | 12 +- .../package.json | 1 + .../src/actions/gitea.test.ts | 12 +- .../package.json | 1 + .../src/actions/github.examples.test.ts | 12 +- .../src/actions/github.test.ts | 12 +- .../githubActionsDispatch.examples.test.ts | 12 +- .../src/actions/githubActionsDispatch.test.ts | 12 +- .../actions/githubAutolinks.examples.test.ts | 16 +-- .../src/actions/githubAutolinks.test.ts | 50 ++++----- .../actions/githubDeployKey.examples.test.ts | 11 +- .../src/actions/githubDeployKey.test.ts | 12 +- .../githubEnvironment.examples.test.ts | 11 +- .../src/actions/githubEnvironment.test.ts | 12 +- .../githubIssuesLabel.examples.test.ts | 11 +- .../src/actions/githubIssuesLabel.test.ts | 12 +- .../githubPullRequest.examples.test.ts | 11 +- .../src/actions/githubPullRequest.test.ts | 103 +++--------------- .../actions/githubRepoCreate.examples.test.ts | 12 +- .../src/actions/githubRepoCreate.test.ts | 12 +- .../actions/githubRepoPush.examples.test.ts | 11 +- .../src/actions/githubRepoPush.test.ts | 12 +- .../actions/githubWebhook.examples.test.ts | 11 +- .../src/actions/githubWebhook.test.ts | 12 +- yarn.lock | 5 + 38 files changed, 173 insertions(+), 360 deletions(-) diff --git a/packages/scaffolder-test-utils/package.json b/packages/scaffolder-test-utils/package.json index 9db983d828..0dbb2e5943 100644 --- a/packages/scaffolder-test-utils/package.json +++ b/packages/scaffolder-test-utils/package.json @@ -38,9 +38,11 @@ "dependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/test-utils": "workspace:^", - "@backstage/types": "workspace:^" + "@backstage/types": "workspace:^", + "winston": "^3.2.1" }, "peerDependencies": { "@types/jest": "*" diff --git a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts index 6b2e992487..b9c79b68bf 100644 --- a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts +++ b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts @@ -20,32 +20,50 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { JsonObject } from '@backstage/types'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; import * as winston from 'winston'; +import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; /** * A utility method to create a mock action context for scaffolder actions. * - * @param input - a schema for user input parameters * - * @param workspacePath - * @param logger + * * @public + * @param options */ export const createMockActionContext = < TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, ->( - input?: TActionInput, - workspacePath?: string, - logger?: winston.Logger, -): ActionContext => { - return { - workspacePath: workspacePath - ? workspacePath - : createMockDirectory().resolve('workspace'), - logger: logger ? logger : getVoidLogger(), +>(options?: { + input?: TActionInput; + workspacePath?: string; + logger?: winston.Logger; + templateInfo?: TemplateInfo; +}): ActionContext => { + const defaultContext = { + logger: getVoidLogger(), logStream: new PassThrough(), output: jest.fn(), createTemporaryDirectory: jest.fn(), - input: (input ? input : {}) as TActionInput, + input: {} as TActionInput, + }; + + const createDefaultWorkspace = () => ({ + workspacePath: createMockDirectory().resolve('workspace'), + }); + + if (!options) { + return { + ...defaultContext, + ...createDefaultWorkspace(), + }; + } + + const { input, workspacePath, logger, templateInfo } = options; + return { + ...defaultContext, + ...(workspacePath ? { workspacePath } : createDefaultWorkspace()), + ...(logger && { logger }), + ...(input && { input }), + templateInfo, }; }; diff --git a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts index 0dbacaf699..6b076b57d7 100644 --- a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts +++ b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts @@ -55,7 +55,7 @@ describe('publish:azure', () => { const action = createPublishAzureAction({ integrations, config }); const mockContext = createMockActionContext({ - repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org', + input: { repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org' }, }); const mockGitClient = { diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts index 0b7bf95a33..bc56bde073 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts @@ -50,8 +50,10 @@ describe('publish:bitbucketCloud', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketCloudAction({ integrations, config }); const mockContext = createMockActionContext({ - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }, }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts index c4ae8b9dde..00d6db384a 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts @@ -62,12 +62,14 @@ describe('publish:bitbucketServer:pull-request', () => { config, }); const mockContext = createMockActionContext({ - repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', - title: 'Add Scaffolder actions for Bitbucket Server', - description: - 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server', - targetBranch: 'master', - sourceBranch: 'develop', + input: { + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + title: 'Add Scaffolder actions for Bitbucket Server', + description: + 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server', + targetBranch: 'master', + sourceBranch: 'develop', + }, }); const responseOfBranches = { size: 3, diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts index b89508e1ca..17ac8ff522 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts @@ -61,8 +61,10 @@ describe('publish:bitbucket', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketAction({ integrations, config }); const mockContext = createMockActionContext({ - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }, }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts index 80279afec6..0a3ddd8c9d 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts @@ -57,8 +57,10 @@ describe('publish:bitbucket', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketAction({ integrations, config }); const mockContext = createMockActionContext({ - repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', - repoVisibility: 'private' as const, + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + repoVisibility: 'private' as const, + }, }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts index bdbd341660..47befca7ac 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts @@ -71,11 +71,11 @@ describe('confluence:transform:markdown examples', () => { }), search: jest.fn(), }; - mockContext = createMockActionContext( - yaml.parse(examples[0].example).steps[0].input, + mockContext = createMockActionContext({ + input: yaml.parse(examples[0].example).steps[0].input, workspacePath, logger, - ); + }); mockDir.setContent({ 'workspace/mkdocs.yml': 'File contents' }); }); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts index 39c9c98544..889d59c8bf 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PassThrough } from 'stream'; + import { createConfluenceToMarkdownAction } from './confluenceToMarkdown'; import { getVoidLogger } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; @@ -26,6 +26,7 @@ import { import type { ActionContext } from '@backstage/plugin-scaffolder-node'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('confluence:transform:markdown', () => { const baseUrl = `https://nodomain.confluence.com`; @@ -69,7 +70,7 @@ describe('confluence:transform:markdown', () => { }), search: jest.fn(), }; - mockContext = { + mockContext = createMockActionContext({ input: { confluenceUrls: [ 'https://nodomain.confluence.com/display/testing/mkdocs', @@ -79,10 +80,7 @@ describe('confluence:transform:markdown', () => { }, workspacePath, logger, - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); mockDir.setContent({ 'workspace/mkdocs.yml': 'File contents' }); }); diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 236ebd6c88..a512c2ea36 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^11.0.0" }, diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts index af63ca0e48..abf94c9e0a 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts @@ -14,19 +14,15 @@ * limitations under the License. */ -import { - getVoidLogger, - UrlReader, - ContainerRunner, -} from '@backstage/backend-common'; +import { UrlReader, ContainerRunner } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { ScmIntegrations } from '@backstage/integration'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { PassThrough } from 'stream'; import { createFetchCookiecutterAction } from './cookiecutter'; import { join } from 'path'; import type { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; const executeShellCommand = jest.fn(); const commandExists = jest.fn(); @@ -88,7 +84,7 @@ describe('fetch:cookiecutter', () => { beforeEach(() => { jest.resetAllMocks(); - mockContext = { + mockContext = createMockActionContext({ input: { url: 'https://google.com/cookie/cutter', targetPath: 'something', @@ -96,16 +92,7 @@ describe('fetch:cookiecutter', () => { help: 'me', }, }, - templateInfo: { - entityRef: 'template:default/cookiecutter', - baseUrl: 'somebase', - }, - workspacePath: mockTmpDir, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), - }; + }); mockDir.setContent({ template: {} }); commandExists.mockResolvedValue(null); diff --git a/plugins/scaffolder-backend-module-gerrit/package.json b/plugins/scaffolder-backend-module-gerrit/package.json index dad19dc06b..36f7c613a7 100644 --- a/plugins/scaffolder-backend-module-gerrit/package.json +++ b/plugins/scaffolder-backend-module-gerrit/package.json @@ -49,6 +49,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts b/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts index 2bd7783cb6..20c8b74de6 100644 --- a/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts +++ b/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts @@ -33,9 +33,8 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:gerrit', () => { const config = new ConfigReader({ @@ -53,18 +52,13 @@ describe('publish:gerrit', () => { const description = 'for the lols'; const integrations = ScmIntegrations.fromConfig(config); const action = createPublishGerritAction({ integrations, config }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gerrithost.org?owner=owner&workspace=parent&project=project&repo=repo', description, }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts b/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts index eeae3871ee..cee4b8344c 100644 --- a/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts +++ b/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts @@ -24,9 +24,8 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { import { createPublishGerritReviewAction } from './gerritReview'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { commitAndPushRepo } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('publish:gerrit:review', () => { const config = new ConfigReader({ @@ -43,18 +42,13 @@ describe('publish:gerrit:review', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishGerritReviewAction({ integrations, config }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gerrithost.org?owner=owner&workspace=parent&project=project&repo=repo', gitCommitMessage: 'Review from backstage', }, - workspacePath: 'workspace', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitea/package.json b/plugins/scaffolder-backend-module-gitea/package.json index bc4912d76c..2b1b4b6176 100644 --- a/plugins/scaffolder-backend-module-gitea/package.json +++ b/plugins/scaffolder-backend-module-gitea/package.json @@ -49,6 +49,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts index 5e1e94b1b4..a3f7d80e88 100644 --- a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts +++ b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PassThrough } from 'stream'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { createPublishGiteaAction } from './gitea'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { rest } from 'msw'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { setupServer } from 'msw/node'; jest.mock('@backstage/plugin-scaffolder-node', () => { @@ -48,17 +47,12 @@ describe('publish:gitea', () => { const description = 'for the lols'; const integrations = ScmIntegrations.fromConfig(config); const action = createPublishGiteaAction({ integrations, config }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitea.com?repo=repo&owner=owner', description, }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-github/package.json b/plugins/scaffolder-backend-module-github/package.json index 58651b3c07..8e1a05134f 100644 --- a/plugins/scaffolder-backend-module-github/package.json +++ b/plugins/scaffolder-backend-module-github/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@types/libsodium-wrappers": "^0.7.10", "fs-extra": "^11.2.0", "jest-when": "^3.1.0", diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts index 7ea5e025ce..61b5787d46 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts @@ -36,14 +36,13 @@ import { TemplateAction, initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { createPublishGithubAction } from './github'; import { examples } from './github.examples'; import yaml from 'yaml'; @@ -101,19 +100,14 @@ describe('publish:github', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', description: 'description', repoVisibility: 'private' as const, access: 'owner/blam', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { initRepoAndPushMocked.mockResolvedValue({ diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.test.ts b/plugins/scaffolder-backend-module-github/src/actions/github.test.ts index 75d9b74a8b..cdf2fb552a 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.test.ts @@ -34,15 +34,14 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { }); import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; import { when } from 'jest-when'; -import { PassThrough } from 'stream'; import { createPublishGithubAction } from './github'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { @@ -103,19 +102,14 @@ describe('publish:github', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', description: 'description', repoVisibility: 'private' as const, access: 'owner/blam', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { initRepoAndPushMocked.mockResolvedValue({ diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts index fe04142a98..9496fdd1bb 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts @@ -20,10 +20,9 @@ import { GithubCredentialsProvider, } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; import { createGithubActionsDispatchAction } from './githubActionsDispatch'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import yaml from 'yaml'; import { examples } from './githubActionsDispatch.examples'; @@ -56,18 +55,13 @@ describe('github:actions:dispatch', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', workflowId: 'a-workflow-id', branchOrTagName: 'main', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts index 3d59e43695..e69a92a0a2 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts @@ -20,9 +20,8 @@ import { GithubCredentialsProvider, } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createGithubActionsDispatchAction } from './githubActionsDispatch'; const mockOctokit = { @@ -54,18 +53,13 @@ describe('github:actions:dispatch', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', workflowId: 'a-workflow-id', branchOrTagName: 'main', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts index 612f68dea2..2df283bf44 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, @@ -22,8 +21,8 @@ import { ScmIntegrations, } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; import { createGithubAutolinksAction } from './githubAutolinks'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { examples } from './githubAutolinks.examples'; import yaml from 'yaml'; @@ -70,14 +69,11 @@ describe('github:autolinks:create', () => { id: '1', }, }); - await action.handler({ - input, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }); + await action.handler( + createMockActionContext({ + input, + }), + ); expect(mockOctokit.rest.repos.createAutolink).toHaveBeenCalledWith({ owner: 'owner', diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts index 95c0226104..0a529e0377 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts @@ -14,15 +14,15 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; import { createGithubAutolinksAction } from './githubAutolinks'; const mockOctokit = { @@ -53,13 +53,7 @@ describe('github:autolinks:create', () => { const integrations = ScmIntegrations.fromConfig(config); let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const workspacePath = createMockDirectory().resolve('workspace'); it('should call the githubApis for creating alphanumeric autolink reference', async () => { githubCredentialsProvider = @@ -74,14 +68,16 @@ describe('github:autolinks:create', () => { id: '1', }, }); - await action.handler({ - input: { - repoUrl: 'github.com?repo=repo&owner=owner', - keyPrefix: 'TICKET-', - urlTemplate: 'https://example.com/TICKET?query=', - }, - ...mockContext, - }); + await action.handler( + createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + keyPrefix: 'TICKET-', + urlTemplate: 'https://example.com/TICKET?query=', + }, + workspacePath, + }), + ); expect(mockOctokit.rest.repos.createAutolink).toHaveBeenCalledWith({ owner: 'owner', @@ -104,15 +100,17 @@ describe('github:autolinks:create', () => { id: '1', }, }); - await action.handler({ - input: { - repoUrl: 'github.com?repo=repo&owner=owner', - keyPrefix: 'TICKET-', - urlTemplate: 'https://example.com/TICKET?query=', - isAlphanumeric: false, - }, - ...mockContext, - }); + await action.handler( + createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + keyPrefix: 'TICKET-', + urlTemplate: 'https://example.com/TICKET?query=', + isAlphanumeric: false, + }, + workspacePath, + }), + ); expect(mockOctokit.rest.repos.createAutolink).toHaveBeenCalledWith({ owner: 'owner', diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts index c8e2a5f102..1fb3facb83 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts @@ -14,11 +14,10 @@ * limitations under the License. */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createGithubDeployKeyAction } from './githubDeployKey'; import yaml from 'yaml'; import { examples } from './githubDeployKey.examples'; -import { PassThrough } from 'stream'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -55,13 +54,7 @@ describe('Usage examples', () => { const integrations = ScmIntegrations.fromConfig(config); let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts index 4df9294ff5..cb8f84e563 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts @@ -14,10 +14,9 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; import { createGithubDeployKeyAction } from './githubDeployKey'; -import { getVoidLogger } from '@backstage/backend-common'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -55,19 +54,14 @@ describe('github:deployKey:create', () => { const integrations = ScmIntegrations.fromConfig(config); let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repository&owner=owner', publicKey: 'pubkey', privateKey: 'privkey', deployKeyName: 'Push Tags', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts index 9d5dbfd35c..1130f41a68 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PassThrough } from 'stream'; import { createGithubEnvironmentAction } from './githubEnvironment'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -59,13 +58,7 @@ describe('github:environment:create examples', () => { const integrations = ScmIntegrations.fromConfig(config); let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { mockOctokit.rest.actions.getEnvironmentPublicKey.mockResolvedValue({ diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts index 8ec3d84e36..a590257a2e 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; import { createGithubEnvironmentAction } from './githubEnvironment'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -58,17 +57,12 @@ describe('github:environment:create', () => { const integrations = ScmIntegrations.fromConfig(config); let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repository&owner=owner', name: 'envname', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { mockOctokit.rest.actions.getEnvironmentPublicKey.mockResolvedValue({ diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts index 9d74d53c6d..5afee9e9ed 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts @@ -15,14 +15,13 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { createGithubIssuesLabelAction } from './githubIssuesLabel'; import yaml from 'yaml'; import { examples } from './githubIssuesLabel.examples'; @@ -64,13 +63,7 @@ describe('github:issues:label examples', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts index 3da16e677a..72200f0ea0 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts @@ -20,10 +20,9 @@ import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; import { getOctokitOptions } from './helpers'; jest.mock('./helpers', () => { @@ -62,18 +61,13 @@ describe('github:issues:label', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', number: '1', labels: ['label1', 'label2'], }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts index 7e5d107026..083eb78066 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts @@ -15,14 +15,13 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createPublishGithubPullRequestAction } from './githubPullRequest'; import yaml from 'yaml'; import { examples } from './githubPullRequest.examples'; @@ -57,13 +56,7 @@ describe('publish:github:pull-request examples', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); let fakeClient: { createPullRequest: jest.Mock; rest: { diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts index 3fe4f62e04..5cbaac6de6 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createRootLogger, getRootLogger } from '@backstage/backend-common'; +import { createRootLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { GithubCredentialsProvider, @@ -25,9 +25,9 @@ import { TemplateAction, } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; -import { Writable } from 'stream'; import { createPublishGithubPullRequestAction } from './githubPullRequest'; import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); @@ -131,14 +131,7 @@ describe('createPublishGithubPullRequestAction', () => { [workspacePath]: { 'file.txt': 'Hello there!' }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { @@ -196,14 +189,7 @@ describe('createPublishGithubPullRequestAction', () => { [workspacePath]: { 'file.txt': 'Hello there!' }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { @@ -263,14 +249,7 @@ describe('createPublishGithubPullRequestAction', () => { }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request with only relevant files', async () => { @@ -322,14 +301,7 @@ describe('createPublishGithubPullRequestAction', () => { [workspacePath]: { 'file.txt': 'Hello there!' }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { await instance.handler(ctx); @@ -382,14 +354,7 @@ describe('createPublishGithubPullRequestAction', () => { mockDir.setContent({ [workspacePath]: {} }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request and requests a review from the given reviewers', async () => { @@ -434,14 +399,7 @@ describe('createPublishGithubPullRequestAction', () => { mockDir.setContent({ [workspacePath]: {} }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('does not call the API endpoint for requesting reviewers', async () => { @@ -470,14 +428,7 @@ describe('createPublishGithubPullRequestAction', () => { }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { await instance.handler(ctx); @@ -526,14 +477,7 @@ describe('createPublishGithubPullRequestAction', () => { }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { await instance.handler(ctx); @@ -592,14 +536,7 @@ describe('createPublishGithubPullRequestAction', () => { }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { await instance.handler(ctx); @@ -653,14 +590,7 @@ describe('createPublishGithubPullRequestAction', () => { [workspacePath]: { 'file.txt': 'Hello there!' }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { @@ -705,14 +635,7 @@ describe('createPublishGithubPullRequestAction', () => { [workspacePath]: { 'file.txt': 'Hello there!' }, }); - ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + ctx = createMockActionContext({ input, workspacePath }); }); it('creates a pull request', async () => { diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts index 2377e9a7eb..3dd8a44e07 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts @@ -23,14 +23,13 @@ jest.mock('./gitHelpers', () => { }; }); -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createGithubRepoCreateAction } from './githubRepoCreate'; import { entityRefToName } from './gitHelpers'; import yaml from 'yaml'; @@ -82,16 +81,11 @@ describe('github:repo:create examples', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { githubCredentialsProvider = diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts index c72a969e96..ce29de7a1e 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts @@ -15,6 +15,7 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; jest.mock('./gitHelpers', () => { return { @@ -23,7 +24,6 @@ jest.mock('./gitHelpers', () => { }; }); -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, @@ -31,7 +31,6 @@ import { ScmIntegrations, } from '@backstage/integration'; import { when } from 'jest-when'; -import { PassThrough } from 'stream'; import { createGithubRepoCreateAction } from './githubRepoCreate'; import { entityRefToName } from './gitHelpers'; @@ -82,19 +81,14 @@ describe('github:repo:create', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', description: 'description', repoVisibility: 'private' as const, access: 'owner/blam', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { githubCredentialsProvider = diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts index 8f8c9bf8af..8c48811eb1 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts @@ -29,14 +29,13 @@ import { TemplateAction, initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { createGithubRepoPushAction } from './githubRepoPush'; import { examples } from './githubRepoPush.examples'; import yaml from 'yaml'; @@ -102,13 +101,7 @@ describe('github:repo:push examples', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts index e16da0981d..31b3a7c95e 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts @@ -59,14 +59,13 @@ import { TemplateAction, initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { enableBranchProtectionOnDefaultRepoBranch } from './gitHelpers'; import { createGithubRepoPushAction } from './githubRepoPush'; @@ -103,19 +102,14 @@ describe('github:repo:push', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repository&owner=owner', description: 'description', repoVisibility: 'private' as const, access: 'owner/blam', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts index ce1fe76c53..17dd371a21 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts @@ -14,14 +14,13 @@ * limitations under the License. */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { createGithubWebhookAction } from './githubWebhook'; import yaml from 'yaml'; import { examples } from './githubWebhook.examples'; @@ -56,13 +55,7 @@ describe('github:webhook examples', () => { let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts index 7901210a6e..8c5a57542f 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts @@ -20,10 +20,9 @@ import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { PassThrough } from 'stream'; const mockOctokit = { rest: { @@ -66,17 +65,12 @@ describe('github:repository:webhook:create', () => { }); }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'github.com?repo=repo&owner=owner', webhookUrl: 'https://example.com/payload', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); it('should call the githubApi for creating repository Webhook', async () => { const repoUrl = 'github.com?repo=repo&owner=owner'; diff --git a/yarn.lock b/yarn.lock index 6bbb18da35..c8edc34ae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8311,6 +8311,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@backstage/types": "workspace:^" "@types/command-exists": ^1.2.0 "@types/fs-extra": ^11.0.0 @@ -8333,6 +8334,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" msw: ^1.0.0 node-fetch: ^2.6.7 yaml: ^2.0.0 @@ -8351,6 +8353,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" msw: ^1.0.0 node-fetch: ^2.6.7 yaml: ^2.0.0 @@ -8369,6 +8372,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@octokit/webhooks": ^10.0.0 "@types/libsodium-wrappers": ^0.7.10 fs-extra: ^11.2.0 @@ -9876,6 +9880,7 @@ __metadata: "@backstage/backend-common": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" From 85a9bba49d6771f0ca9c09c78c996a41c96b26e0 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Mon, 19 Feb 2024 20:55:09 +0100 Subject: [PATCH 155/176] wip Signed-off-by: bnechyporenko --- packages/scaffolder-test-utils/api-report.md | 17 +- .../src/actions/mockActionConext.ts | 21 ++- .../src/actions/bitbucketServer.test.ts | 6 +- .../package.json | 1 + ...reateGitlabGroupEnsureExistsAction.test.ts | 11 +- .../actions/createGitlabIssueAction.test.ts | 27 +--- ...bProjectAccessTokenAction.examples.test.ts | 12 +- ...eateGitlabProjectDeployTokenAction.test.ts | 12 +- .../src/actions/gitlab.examples.test.ts | 12 +- .../src/actions/gitlab.test.ts | 40 ++--- .../src/actions/gitlabMergeRequest.test.ts | 148 +++--------------- .../src/actions/gitlabRepoPush.test.ts | 76 ++------- .../package.json | 1 + .../src/actions/fetch/rails/index.test.ts | 25 +-- .../package.json | 1 + .../src/actions/createProject.test.ts | 25 ++- .../package.json | 1 + .../src/actions/run/yeoman.test.ts | 11 +- plugins/scaffolder-backend/package.json | 1 + .../builtin/catalog/fetch.examples.test.ts | 13 +- .../actions/builtin/catalog/fetch.test.ts | 14 +- .../builtin/catalog/register.examples.test.ts | 12 +- .../actions/builtin/catalog/register.test.ts | 13 +- .../builtin/catalog/write.examples.test.ts | 14 +- .../actions/builtin/catalog/write.test.ts | 15 +- .../builtin/debug/log.examples.test.ts | 25 +-- .../actions/builtin/debug/log.test.ts | 12 +- .../builtin/debug/wait.examples.test.ts | 16 +- .../actions/builtin/debug/wait.test.ts | 16 +- .../builtin/fetch/plain.examples.test.ts | 23 ++- .../actions/builtin/fetch/plain.test.ts | 13 +- .../builtin/fetch/plainFile.examples.test.ts | 14 +- .../actions/builtin/fetch/plainFile.test.ts | 13 +- .../builtin/fetch/template.examples.test.ts | 34 ++-- .../actions/builtin/fetch/template.test.ts | 45 ++---- .../filesystem/delete.examples.test.ts | 11 +- .../actions/builtin/filesystem/delete.test.ts | 11 +- .../filesystem/rename.examples.test.ts | 11 +- .../actions/builtin/filesystem/rename.test.ts | 11 +- yarn.lock | 5 + 40 files changed, 218 insertions(+), 571 deletions(-) diff --git a/packages/scaffolder-test-utils/api-report.md b/packages/scaffolder-test-utils/api-report.md index b95b020f1c..4e50a55f5e 100644 --- a/packages/scaffolder-test-utils/api-report.md +++ b/packages/scaffolder-test-utils/api-report.md @@ -3,15 +3,30 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { ActionContext } from '@backstage/plugin-scaffolder-node'; import { JsonObject } from '@backstage/types'; +import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; +import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import * as winston from 'winston'; +import { Writable } from 'stream'; // @public export const createMockActionContext: < TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, >( - input?: TActionInput | undefined, + options?: + | { + input?: TActionInput | undefined; + logger?: winston.Logger | undefined; + logStream?: Writable | undefined; + secrets?: TaskSecrets | undefined; + templateInfo?: TemplateInfo | undefined; + workspacePath?: string | undefined; + } + | undefined, ) => ActionContext; // (No @packageDocumentation comment for this package) diff --git a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts index b9c79b68bf..1b9f6200f9 100644 --- a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts +++ b/packages/scaffolder-test-utils/src/actions/mockActionConext.ts @@ -14,30 +14,30 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; +import { PassThrough, Writable } from 'stream'; import { getVoidLogger } from '@backstage/backend-common'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { JsonObject } from '@backstage/types'; -import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { ActionContext, TaskSecrets } from '@backstage/plugin-scaffolder-node'; import * as winston from 'winston'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; /** * A utility method to create a mock action context for scaffolder actions. * - * - * * @public - * @param options + * @param options - optional parameters to override default mock context */ export const createMockActionContext = < TActionInput extends JsonObject = JsonObject, TActionOutput extends JsonObject = JsonObject, >(options?: { input?: TActionInput; - workspacePath?: string; logger?: winston.Logger; + logStream?: Writable; + secrets?: TaskSecrets; templateInfo?: TemplateInfo; + workspacePath?: string; }): ActionContext => { const defaultContext = { logger: getVoidLogger(), @@ -58,12 +58,19 @@ export const createMockActionContext = < }; } - const { input, workspacePath, logger, templateInfo } = options; + const { input, logger, logStream, secrets, templateInfo, workspacePath } = + options; + return { ...defaultContext, ...(workspacePath ? { workspacePath } : createDefaultWorkspace()), + ...(workspacePath && { + createTemporaryDirectory: jest.fn().mockResolvedValue(workspacePath), + }), ...(logger && { logger }), + ...(logStream && { logStream }), ...(input && { input }), + ...(secrets && { secrets }), templateInfo, }; }; diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts index 9a51fef5e8..1c1ec09368 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts @@ -60,8 +60,10 @@ describe('publish:bitbucketServer', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishBitbucketServerAction({ integrations, config }); const mockContext = createMockActionContext({ - repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', - repoVisibility: 'private' as const, + input: { + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + repoVisibility: 'private' as const, + }, }); const server = setupServer(); setupRequestMockHandlers(server); diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index dc96a0f356..72514db885 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -57,6 +57,7 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "jest-date-mock": "^1.0.8" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts index 72dcddc83d..a94a844d4c 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; import { createGitlabGroupEnsureExistsAction } from './createGitlabGroupEnsureExistsAction'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; @@ -35,13 +34,7 @@ jest.mock('@gitbeaker/node', () => ({ })); describe('gitlab:group:ensureExists', () => { - const mockContext = { - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); afterEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts index fe9e556656..d33cbb37dc 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createGitlabIssueAction, IssueType } from './createGitlabIssueAction'; import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; @@ -60,18 +59,14 @@ describe('gitlab:issues:create', () => { const action = createGitlabIssueAction({ integrations }); it('should return a Gitlab issue when called with minimal input params', async () => { - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', projectId: 123, title: 'Computer banks to rule the world', }, workspacePath: 'seen2much', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); mockGitlabClient.Issues.create.mockResolvedValue({ id: 42, @@ -109,7 +104,7 @@ describe('gitlab:issues:create', () => { }); it('should return a Gitlab issue when called with oAuth Token', async () => { - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', projectId: 123, @@ -117,11 +112,7 @@ describe('gitlab:issues:create', () => { token: 'myAwesomeToken', }, workspacePath: 'seen2much', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); mockGitlabClient.Issues.create.mockResolvedValue({ id: 42, @@ -159,7 +150,7 @@ describe('gitlab:issues:create', () => { }); it('should return a Gitlab issue when called with several input params', async () => { - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', projectId: 123, @@ -173,11 +164,7 @@ describe('gitlab:issues:create', () => { labels: 'operation:mindcrime', }, workspacePath: 'seen2much', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); mockGitlabClient.Issues.create.mockResolvedValue({ id: 42, diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts index ab4fe0e637..90f4fca283 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; -import { PassThrough } from 'stream'; import yaml from 'yaml'; import { createGitlabProjectAccessTokenAction } from './createGitlabProjectAccessTokenAction'; // Adjust the import based on your project structure import { examples } from './createGitlabProjectAccessTokenAction.examples'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { DateTime } from 'luxon'; @@ -59,16 +58,11 @@ describe('gitlab:projectAccessToken:create examples', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createGitlabProjectAccessTokenAction({ integrations }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts index 1d3ae804c1..c626a20124 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts @@ -15,10 +15,9 @@ */ import { createGitlabProjectDeployTokenAction } from './createGitlabProjectDeployTokenAction'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; const mockGitlabClient = { ProjectDeployTokens: { @@ -52,7 +51,7 @@ describe('gitlab:create-deploy-token', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createGitlabProjectDeployTokenAction({ integrations }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', projectId: '123', @@ -60,12 +59,7 @@ describe('gitlab:create-deploy-token', () => { username: 'tokenuser', scopes: ['read_repository'], }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts index e5aa610ebc..3bd6b0e5ff 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import yaml from 'yaml'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; jest.mock('@backstage/plugin-scaffolder-node', () => { return { @@ -31,8 +32,6 @@ import { createPublishGitlabAction } from './gitlab'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { examples } from './gitlab.examples'; const mockGitlabClient = { @@ -76,16 +75,11 @@ describe('publish:gitlab', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishGitlabAction({ integrations, config }); - const mockContext = { + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts index 5aca0a73e5..40712966ba 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts @@ -29,9 +29,8 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { import { createPublishGitlabAction } from './gitlab'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; const mockGitlabClient = { Namespaces: { @@ -83,7 +82,8 @@ describe('publish:gitlab', () => { const integrations = ScmIntegrations.fromConfig(config); const action = createPublishGitlabAction({ integrations, config }); - const mockContext = { + + const mockContext = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', repoVisibility: 'private' as const, @@ -91,13 +91,8 @@ describe('publish:gitlab', () => { ci_config_path: '.gitlab-ci.yml', }, }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; - const mockContextWithSettings = { + }); + const mockContextWithSettings = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', repoVisibility: 'private' as const, @@ -108,13 +103,8 @@ describe('publish:gitlab', () => { topics: ['topic1', 'topic2'], }, }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; - const mockContextWithBranches = { + }); + const mockContextWithBranches = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', repoVisibility: 'private' as const, @@ -135,13 +125,8 @@ describe('publish:gitlab', () => { }, ], }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; - const mockContextWithVariables = { + }); + const mockContextWithVariables = createMockActionContext({ input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', repoVisibility: 'private' as const, @@ -155,12 +140,7 @@ describe('publish:gitlab', () => { }, ], }, - workspacePath: 'lol', - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts index 0883a5e9f0..4f6585e7b3 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRootLogger, getRootLogger } from '@backstage/backend-common'; +import { createRootLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { Writable } from 'stream'; import { createPublishGitlabMergeRequestAction } from './gitlabMergeRequest'; import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); @@ -118,14 +118,7 @@ describe('createGitLabMergeRequest', () => { irrelevant: { 'bar.txt': 'Nothing to see here' }, }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Projects.show).not.toHaveBeenCalled(); @@ -160,14 +153,7 @@ describe('createGitLabMergeRequest', () => { irrelevant: { 'bar.txt': 'Nothing to see here' }, }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith('owner/repo'); @@ -205,14 +191,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -240,14 +219,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -281,14 +253,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -321,14 +286,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -361,14 +319,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -400,14 +351,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( @@ -435,14 +379,7 @@ describe('createGitLabMergeRequest', () => { irrelevant: { 'bar.txt': 'Nothing to see here' }, }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( @@ -484,14 +421,7 @@ describe('createGitLabMergeRequest', () => { irrelevant: { 'bar.txt': 'Nothing to see here' }, }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( @@ -528,14 +458,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( @@ -570,14 +493,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( @@ -612,14 +528,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( @@ -657,14 +566,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); @@ -702,14 +604,7 @@ describe('createGitLabMergeRequest', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); @@ -739,14 +634,7 @@ describe('createGitLabMergeRequest', () => { commitAction: 'create', }; - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await expect(instance.handler(ctx)).rejects.toThrow( 'Relative path is not allowed to refer to a directory outside its parent', diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts index 840f506540..24ba3abcd3 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createRootLogger, getRootLogger } from '@backstage/backend-common'; +import { createRootLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { Writable } from 'stream'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { createGitlabRepoPushAction } from './gitlabRepoPush'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); @@ -93,14 +93,7 @@ describe('createGitLabCommit', () => { 'foo.txt': 'Hello there!', }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); @@ -139,14 +132,7 @@ describe('createGitLabCommit', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); @@ -183,14 +169,7 @@ describe('createGitLabCommit', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); @@ -227,14 +206,7 @@ describe('createGitLabCommit', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); @@ -276,14 +248,7 @@ describe('createGitLabCommit', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); @@ -325,14 +290,7 @@ describe('createGitLabCommit', () => { }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); @@ -366,14 +324,7 @@ describe('createGitLabCommit', () => { commitAction: 'create', }; - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await expect(instance.handler(ctx)).rejects.toThrow( 'Relative path is not allowed to refer to a directory outside its parent', @@ -398,14 +349,7 @@ describe('createGitLabCommit', () => { 'foo.txt': 'Hello there!', }, }); - const ctx = { - createTemporaryDirectory: jest.fn(), - output: jest.fn(), - logger: getRootLogger(), - logStream: new Writable(), - input, - workspacePath, - }; + const ctx = createMockActionContext({ input, workspacePath }); await instance.handler(ctx); expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index f67f3fbb22..5426049996 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^11.0.0", "@types/node": "^18.17.8", diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts index 7548ddd21d..0f56bd55ec 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts @@ -27,18 +27,14 @@ jest.mock('./railsNewRunner', () => { }; }); -import { - ContainerRunner, - getVoidLogger, - UrlReader, -} from '@backstage/backend-common'; +import { ContainerRunner, UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { resolve as resolvePath } from 'path'; -import { PassThrough } from 'stream'; import { createFetchRailsAction } from './index'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('fetch:rails', () => { const mockDir = createMockDirectory(); @@ -53,8 +49,7 @@ describe('fetch:rails', () => { }), ); - const mockTmpDir = mockDir.path; - const mockContext = { + const mockContext = createMockActionContext({ input: { url: 'https://rubyonrails.org/generator', targetPath: 'something', @@ -66,12 +61,8 @@ describe('fetch:rails', () => { baseUrl: 'somebase', entityRef: 'template:default/myTemplate', }, - workspacePath: mockTmpDir, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), - }; + workspacePath: mockDir.path, + }); const mockReader: UrlReader = { readUrl: jest.fn(), @@ -102,7 +93,7 @@ describe('fetch:rails', () => { expect(fetchContents).toHaveBeenCalledWith({ reader: mockReader, integrations, - baseUrl: mockContext.templateInfo.baseUrl, + baseUrl: mockContext.templateInfo?.baseUrl, fetchUrl: mockContext.input.url, outputPath: resolvePath(mockContext.workspacePath), }); @@ -112,7 +103,7 @@ describe('fetch:rails', () => { await action.handler(mockContext); expect(mockRailsTemplater.run).toHaveBeenCalledWith({ - workspacePath: mockTmpDir, + workspacePath: mockContext.workspacePath, logStream: mockContext.logStream, values: mockContext.input.values, }); @@ -128,7 +119,7 @@ describe('fetch:rails', () => { }); expect(mockRailsTemplater.run).toHaveBeenCalledWith({ - workspacePath: mockTmpDir, + workspacePath: mockContext.workspacePath, logStream: mockContext.logStream, values: { ...mockContext.input.values, diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index f1aab87354..fb942617f3 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -46,6 +46,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@backstage/types": "workspace:^", "msw": "^2.0.0" }, diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index 972b0f1e8e..715f21d944 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -15,6 +15,7 @@ */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ConfigReader } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; @@ -42,19 +43,17 @@ describe('sentry:project:create action', () => { name: string; slug?: string; authToken?: string; - }> => ({ - workspacePath: './dev/proj', - createTemporaryDirectory: jest.fn(), - logger: jest.createMockFromModule('winston'), - logStream: jest.createMockFromModule('stream'), - input: { - organizationSlug: 'org', - teamSlug: 'team', - name: 'test project', - authToken: randomBytes(5).toString('hex'), - }, - output: jest.fn(), - }); + }> => + createMockActionContext({ + workspacePath: './dev/proj', + logger: jest.createMockFromModule('winston'), + input: { + organizationSlug: 'org', + teamSlug: 'team', + name: 'test project', + authToken: randomBytes(5).toString('hex'), + }, + }); it('should request sentry project create with specified parameters.', async () => { expect.assertions(3); diff --git a/plugins/scaffolder-backend-module-yeoman/package.json b/plugins/scaffolder-backend-module-yeoman/package.json index 2ea82dd5d2..9abfe95ac0 100644 --- a/plugins/scaffolder-backend-module-yeoman/package.json +++ b/plugins/scaffolder-backend-module-yeoman/package.json @@ -39,6 +39,7 @@ "dependencies": { "@backstage/backend-plugin-api": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@backstage/types": "workspace:^", "winston": "^3.2.1", "yeoman-environment": "^3.9.1" diff --git a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts index 146c29f4ac..ae18a25365 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts @@ -18,9 +18,8 @@ import { yeomanRun } from './yeomanRun'; jest.mock('./yeomanRun'); -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import os from 'os'; -import { PassThrough } from 'stream'; import { createRunYeomanAction } from './yeoman'; import type { ActionContext } from '@backstage/plugin-scaffolder-node'; import { JsonObject } from '@backstage/types'; @@ -46,18 +45,14 @@ describe('run:yeoman', () => { const options = { code: 'owner', }; - mockContext = { + mockContext = createMockActionContext({ input: { namespace, args, options, }, workspacePath: mockTmpDir, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir), - }; + }); await action.handler(mockContext); expect(yeomanRun).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 4fec5bdfaf..5884450a89 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -95,6 +95,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/scaffolder-test-utils": "workspace:^", "@types/fs-extra": "^11.0.0", "@types/nunjucks": "^3.1.4", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts index 161ff9d1de..62e0cef0dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; -import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; @@ -36,14 +34,9 @@ describe('catalog:fetch examples', () => { catalogClient: catalogClient as unknown as CatalogApi, }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), + const mockContext = createMockActionContext({ secrets: { backstageToken: 'secret' }, - }; + }); beforeEach(() => { jest.resetAllMocks(); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts index bed15f2e39..43d7660a9a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; -import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; @@ -34,14 +32,10 @@ describe('catalog:fetch', () => { catalogClient: catalogClient as unknown as CatalogApi, }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), + const mockContext = createMockActionContext({ secrets: { backstageToken: 'secret' }, - }; + }); + beforeEach(() => { jest.resetAllMocks(); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts index eb5aa88c0f..e9900221e3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; -import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -44,13 +42,7 @@ describe('catalog:register', () => { catalogClient: catalogClient as unknown as CatalogApi, }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); beforeEach(() => { jest.resetAllMocks(); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts index bae9f04575..b035a8c94d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { PassThrough } from 'stream'; -import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; @@ -42,13 +40,8 @@ describe('catalog:register', () => { catalogClient: catalogClient as unknown as CatalogApi, }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); + beforeEach(() => { jest.resetAllMocks(); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts index daa8f4f6b1..6f410db07e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts @@ -20,24 +20,16 @@ jest.mock('fs-extra'); const fsMock = fs as jest.Mocked; -import { PassThrough } from 'stream'; -import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createCatalogWriteAction } from './write'; import { resolve as resolvePath } from 'path'; import * as yaml from 'yaml'; import { examples } from './write.examples'; +import os from 'os'; describe('catalog:write', () => { const action = createCatalogWriteAction(); - - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ workspacePath: os.tmpdir() }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts index dd6f29f99b..1b06c930f9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts @@ -20,9 +20,8 @@ jest.mock('fs-extra'); const fsMock = fs as jest.Mocked; -import { PassThrough } from 'stream'; import os from 'os'; -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model'; import { createCatalogWriteAction } from './write'; import { resolve as resolvePath } from 'path'; @@ -31,18 +30,14 @@ import * as yaml from 'yaml'; describe('catalog:write', () => { const action = createCatalogWriteAction(); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; - beforeEach(() => { jest.resetAllMocks(); }); + const mockContext = createMockActionContext({ + workspacePath: os.tmpdir(), + }); + it('should write the catalog-info.yml in the workspace', async () => { const entity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts index 901f3e5011..de008433f0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { Writable } from 'stream'; import { createDebugLogAction } from './log'; import { join } from 'path'; @@ -30,15 +30,10 @@ describe('debug:log examples', () => { const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); - const mockContext = { - input: {}, - baseUrl: 'somebase', - workspacePath, - logger: getVoidLogger(), + const mockContext = createMockActionContext({ logStream, - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + workspacePath, + }); const action = createDebugLogAction(); @@ -51,12 +46,10 @@ describe('debug:log examples', () => { }); it('should log message', async () => { - const context = { + await action.handler({ ...mockContext, input: yaml.parse(examples[0].example).steps[0].input, - }; - - await action.handler(context); + }); expect(logStream.write).toHaveBeenCalledTimes(1); expect(logStream.write).toHaveBeenCalledWith( @@ -65,12 +58,10 @@ describe('debug:log examples', () => { }); it('should log the workspace content, if active', async () => { - const context = { + await action.handler({ ...mockContext, input: yaml.parse(examples[1].example).steps[0].input, - }; - - await action.handler(context); + }); expect(logStream.write).toHaveBeenCalledTimes(1); expect(logStream.write).toHaveBeenCalledWith( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts index d6fc5174b3..c9bd0f8cbe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { Writable } from 'stream'; import { createDebugLogAction } from './log'; import { join } from 'path'; @@ -29,15 +29,7 @@ describe('debug:log', () => { const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); - const mockContext = { - input: {}, - baseUrl: 'somebase', - workspacePath, - logger: getVoidLogger(), - logStream, - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext({ workspacePath, logStream }); const action = createDebugLogAction(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts index 9b755d86dd..00574dedfd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { createWaitAction } from './wait'; import { Writable } from 'stream'; import { examples } from './wait.examples'; import yaml from 'yaml'; -import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('debug:wait examples', () => { const action = createWaitAction(); @@ -28,18 +27,9 @@ describe('debug:wait examples', () => { write: jest.fn(), } as jest.Mocked> as jest.Mocked; - const mockDir = createMockDirectory(); - const workspacePath = mockDir.resolve('workspace'); - - const mockContext = { - input: {}, - baseUrl: 'somebase', - workspacePath, - logger: getVoidLogger(), + const mockContext = createMockActionContext({ logStream, - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts index 6f80604a6d..1424ca3012 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts @@ -14,10 +14,9 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import { createWaitAction } from './wait'; import { Writable } from 'stream'; -import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; describe('debug:wait', () => { const action = createWaitAction(); @@ -26,18 +25,9 @@ describe('debug:wait', () => { write: jest.fn(), } as jest.Mocked> as jest.Mocked; - const mockDir = createMockDirectory(); - const workspacePath = mockDir.resolve('workspace'); - - const mockContext = { - input: {}, - baseUrl: 'somebase', - workspacePath, - logger: getVoidLogger(), + const mockContext = createMockActionContext({ logStream, - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts index 6b308a97eb..7ce945a08f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts @@ -16,14 +16,13 @@ import yaml from 'yaml'; -import os from 'os'; import { resolve as resolvePath } from 'path'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { createFetchPlainAction } from './plain'; -import { PassThrough } from 'stream'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { examples } from './plain.examples'; jest.mock('@backstage/plugin-scaffolder-node', () => ({ @@ -50,19 +49,15 @@ describe('fetch:plain examples', () => { }); const action = createFetchPlainAction({ integrations, reader }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); it('should fetch plain', async () => { - await action.handler({ - ...mockContext, - input: yaml.parse(examples[0].example).steps[0].input, - }); + await action.handler( + createMockActionContext({ + ...mockContext, + input: yaml.parse(examples[0].example).steps[0].input, + }), + ); expect(fetchContents).toHaveBeenCalledWith( expect.objectContaining({ outputPath: resolvePath(mockContext.workspacePath), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 917624acf4..7468779f3a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -19,14 +19,13 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { return { ...actual, fetchContents: jest.fn() }; }); -import os from 'os'; import { resolve as resolvePath } from 'path'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; import { createFetchPlainAction } from './plain'; -import { PassThrough } from 'stream'; describe('fetch:plain', () => { const integrations = ScmIntegrations.fromConfig( @@ -47,13 +46,7 @@ describe('fetch:plain', () => { }); const action = createFetchPlainAction({ integrations, reader }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); it('should disallow a target path outside working directory', async () => { await expect( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts index 2ee17c29fe..25993caf96 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts @@ -14,19 +14,19 @@ * limitations under the License. */ +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; + jest.mock('@backstage/plugin-scaffolder-node', () => { const actual = jest.requireActual('@backstage/plugin-scaffolder-node'); return { ...actual, fetchFile: jest.fn() }; }); import yaml from 'yaml'; -import os from 'os'; import { resolve as resolvePath } from 'path'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { createFetchPlainFileAction } from './plainFile'; -import { PassThrough } from 'stream'; import { fetchFile } from '@backstage/plugin-scaffolder-node'; import { examples } from './plainFile.examples'; @@ -49,13 +49,7 @@ describe('fetch:plain:file examples', () => { }); const action = createFetchPlainFileAction({ integrations, reader }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); it('should fetch plain', async () => { await action.handler({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts index 7ea74a809b..8f889bef4b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts @@ -19,14 +19,13 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { return { ...actual, fetchFile: jest.fn() }; }); -import os from 'os'; import { resolve as resolvePath } from 'path'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { fetchFile } from '@backstage/plugin-scaffolder-node'; import { createFetchPlainFileAction } from './plainFile'; -import { PassThrough } from 'stream'; describe('fetch:plain:file', () => { const integrations = ScmIntegrations.fromConfig( @@ -47,13 +46,7 @@ describe('fetch:plain:file', () => { }); const action = createFetchPlainFileAction({ integrations, reader }); - const mockContext = { - workspacePath: os.tmpdir(), - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + const mockContext = createMockActionContext(); it('should disallow a target path outside working directory', async () => { await expect( diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts index a1c8381178..29a267ceab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts @@ -16,13 +16,9 @@ import { join as joinPath, sep as pathSep } from 'path'; import fs from 'fs-extra'; -import { - getVoidLogger, - resolvePackagePath, - UrlReader, -} from '@backstage/backend-common'; +import { resolvePackagePath, UrlReader } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { createFetchTemplateAction } from './template'; import { ActionContext, @@ -61,23 +57,15 @@ describe('fetch:template examples', () => { const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); - const logger = getVoidLogger(); - - const mockContext = (input: any) => ({ - templateInfo: { - baseUrl: 'base-url', - entityRef: 'template:default/test-template', - }, - input: input, - output: jest.fn(), - logStream: new PassThrough(), - logger, - workspacePath, - - async createTemporaryDirectory() { - return fs.mkdtemp(mockDir.resolve('tmp-')); - }, - }); + const mockContext = (input: any) => + createMockActionContext({ + templateInfo: { + baseUrl: 'base-url', + entityRef: 'template:default/test-template', + }, + input, + workspacePath, + }); beforeEach(() => { mockDir.clear(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 25b65462e6..3cd8f61e77 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -21,13 +21,8 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { import { join as joinPath, sep as pathSep } from 'path'; import fs from 'fs-extra'; -import { - getVoidLogger, - resolvePackagePath, - UrlReader, -} from '@backstage/backend-common'; +import { resolvePackagePath, UrlReader } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; -import { PassThrough } from 'stream'; import { createFetchTemplateAction } from './template'; import { fetchContents, @@ -35,6 +30,7 @@ import { TemplateAction, } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; type FetchTemplateInput = ReturnType< typeof createFetchTemplateAction @@ -59,29 +55,22 @@ describe('fetch:template', () => { const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); - const logger = getVoidLogger(); - - const mockContext = (inputPatch: Partial = {}) => ({ - templateInfo: { - baseUrl: 'base-url', - entityRef: 'template:default/test-template', - }, - input: { - url: './skeleton', - targetPath: './target', - values: { - test: 'value', + const mockContext = (inputPatch: Partial = {}) => + createMockActionContext({ + templateInfo: { + baseUrl: 'base-url', + entityRef: 'template:default/test-template', }, - ...inputPatch, - }, - output: jest.fn(), - logStream: new PassThrough(), - logger, - workspacePath, - async createTemporaryDirectory() { - return fs.mkdtemp(mockDir.resolve('tmp-')); - }, - }); + input: { + url: './skeleton', + targetPath: './target', + values: { + test: 'value', + }, + ...inputPatch, + }, + workspacePath, + }); beforeEach(() => { mockDir.setContent({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts index 023f049ebc..8611486dac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts @@ -15,8 +15,7 @@ */ import { createFilesystemDeleteAction } from './delete'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import { resolve as resolvePath } from 'path'; import fs from 'fs-extra'; import yaml from 'yaml'; @@ -31,16 +30,12 @@ describe('fs:delete examples', () => { const files: string[] = yaml.parse(examples[0].example).steps[0].input.files; - const mockContext = { + const mockContext = createMockActionContext({ input: { files: files, }, workspacePath, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.restoreAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts index 2a4f45b862..2cddb9f5a6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts @@ -16,8 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemDeleteAction } from './delete'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import fs from 'fs-extra'; import { createMockDirectory } from '@backstage/backend-test-utils'; @@ -27,16 +26,12 @@ describe('fs:delete', () => { const mockDir = createMockDirectory(); const workspacePath = resolvePath(mockDir.path, 'workspace'); - const mockContext = { + const mockContext = createMockActionContext({ input: { files: ['unit-test-a.js', 'unit-test-b.js'], }, workspacePath, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.restoreAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts index cbb0fa0d0b..5e9ba84465 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts @@ -16,8 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemRenameAction } from './rename'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import fs from 'fs-extra'; import yaml from 'yaml'; import { examples } from './rename.examples'; @@ -31,16 +30,12 @@ describe('fs:rename examples', () => { const mockDir = createMockDirectory(); const workspacePath = resolvePath(mockDir.path, 'workspace'); - const mockContext = { + const mockContext = createMockActionContext({ input: { files: files, }, workspacePath, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.restoreAllMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts index d37e967f43..b081200b71 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts @@ -16,8 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemRenameAction } from './rename'; -import { getVoidLogger } from '@backstage/backend-common'; -import { PassThrough } from 'stream'; +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; import fs from 'fs-extra'; import { createMockDirectory } from '@backstage/backend-test-utils'; @@ -41,16 +40,12 @@ describe('fs:rename', () => { to: 'brand-new-folder', }, ]; - const mockContext = { + const mockContext = createMockActionContext({ input: { files: mockInputFiles, }, workspacePath, - logger: getVoidLogger(), - logStream: new PassThrough(), - output: jest.fn(), - createTemporaryDirectory: jest.fn(), - }; + }); beforeEach(() => { jest.restoreAllMocks(); diff --git a/yarn.lock b/yarn.lock index c8edc34ae3..2d9d22d1ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8399,6 +8399,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@gitbeaker/core": ^35.8.0 "@gitbeaker/node": ^35.8.0 "@gitbeaker/rest": ^39.25.0 @@ -8421,6 +8422,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@backstage/types": "workspace:^" "@types/command-exists": ^1.2.0 "@types/fs-extra": ^11.0.0 @@ -8441,6 +8443,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@backstage/types": "workspace:^" msw: ^2.0.0 yaml: ^2.3.3 @@ -8455,6 +8458,7 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@backstage/types": "workspace:^" winston: ^3.2.1 yeoman-environment: ^3.9.1 @@ -8490,6 +8494,7 @@ __metadata: "@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/scaffolder-test-utils": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/fs-extra": ^11.0.0 From f44589ddeccb3d12404b5e38ba85c1a0d7afa34b Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Mon, 19 Feb 2024 20:59:08 +0100 Subject: [PATCH 156/176] wip Signed-off-by: bnechyporenko --- .changeset/kind-pants-speak.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/kind-pants-speak.md diff --git a/.changeset/kind-pants-speak.md b/.changeset/kind-pants-speak.md new file mode 100644 index 0000000000..be600c44d2 --- /dev/null +++ b/.changeset/kind-pants-speak.md @@ -0,0 +1,23 @@ +--- +'@backstage/plugin-scaffolder-backend-module-confluence-to-markdown': patch +'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch +'@backstage/plugin-scaffolder-backend-module-bitbucket': patch +'@backstage/plugin-scaffolder-backend-module-gerrit': patch +'@backstage/plugin-scaffolder-backend-module-github': patch +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +'@backstage/plugin-scaffolder-backend-module-sentry': patch +'@backstage/plugin-scaffolder-backend-module-yeoman': patch +'@backstage/plugin-scaffolder-backend-module-azure': patch +'@backstage/plugin-scaffolder-backend-module-gitea': patch +'@backstage/plugin-scaffolder-backend-module-rails': patch +'@backstage/plugin-catalog-backend-module-azure': patch +'@backstage/scaffolder-test-utils': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/backend-app-api': patch +'@backstage/backend-common': patch +--- + +Introduced createMockActionContext to unify the way of creating scaffolder mock context. +It will help to maintain tests in a long run during structural changes of action context. From ab123770de83792d355d8696b4478f64c82c8163 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Mon, 19 Feb 2024 21:08:16 +0100 Subject: [PATCH 157/176] Updated changeset Signed-off-by: bnechyporenko --- .changeset/kind-pants-speak.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/kind-pants-speak.md b/.changeset/kind-pants-speak.md index be600c44d2..8af9a7e890 100644 --- a/.changeset/kind-pants-speak.md +++ b/.changeset/kind-pants-speak.md @@ -12,11 +12,8 @@ '@backstage/plugin-scaffolder-backend-module-azure': patch '@backstage/plugin-scaffolder-backend-module-gitea': patch '@backstage/plugin-scaffolder-backend-module-rails': patch -'@backstage/plugin-catalog-backend-module-azure': patch '@backstage/scaffolder-test-utils': patch '@backstage/plugin-scaffolder-backend': patch -'@backstage/backend-app-api': patch -'@backstage/backend-common': patch --- Introduced createMockActionContext to unify the way of creating scaffolder mock context. From d1cd9605e94a7fb601d9a295b9fefff02d9a3d46 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Tue, 20 Feb 2024 08:40:55 +0100 Subject: [PATCH 158/176] wip Signed-off-by: bnechyporenko --- .../src/actions/fetch/cookiecutter.test.ts | 5 +++++ .../src/actions/githubAutolinks.examples.test.ts | 11 +++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts index abf94c9e0a..80d7681e94 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts @@ -92,6 +92,11 @@ describe('fetch:cookiecutter', () => { help: 'me', }, }, + templateInfo: { + entityRef: 'template:default/cookiecutter', + baseUrl: 'somebase', + }, + workspacePath: mockTmpDir, }); mockDir.setContent({ template: {} }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts index 2df283bf44..c62cee8c00 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts @@ -54,9 +54,12 @@ describe('github:autolinks:create', () => { const integrations = ScmIntegrations.fromConfig(config); let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; + const input = yaml.parse(examples[0].example).steps[0].input; + const mockContext = createMockActionContext({ + input, + }); it('should call the githubApis for creating autolink reference', async () => { - const input = yaml.parse(examples[0].example).steps[0].input; githubCredentialsProvider = DefaultGithubCredentialsProvider.fromIntegrations(integrations); action = createGithubAutolinksAction({ @@ -69,11 +72,7 @@ describe('github:autolinks:create', () => { id: '1', }, }); - await action.handler( - createMockActionContext({ - input, - }), - ); + await action.handler(mockContext); expect(mockOctokit.rest.repos.createAutolink).toHaveBeenCalledWith({ owner: 'owner', From b3e6c7777740fe2019bf756c9ef17270a4dd7d82 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Tue, 20 Feb 2024 09:13:15 +0100 Subject: [PATCH 159/176] wip Signed-off-by: bnechyporenko --- packages/scaffolder-test-utils/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/scaffolder-test-utils/package.json b/packages/scaffolder-test-utils/package.json index 0dbb2e5943..604eff388f 100644 --- a/packages/scaffolder-test-utils/package.json +++ b/packages/scaffolder-test-utils/package.json @@ -40,7 +40,6 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", - "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^", "winston": "^3.2.1" }, diff --git a/yarn.lock b/yarn.lock index 2d9d22d1ec..341f32e976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9887,7 +9887,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" - "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@testing-library/jest-dom": ^6.0.0 "@types/react": "*" From 813d6dbbb2605160881a94a9400aaa4b9e182035 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Tue, 20 Feb 2024 09:36:51 +0100 Subject: [PATCH 160/176] wip Signed-off-by: bnechyporenko --- packages/scaffolder-test-utils/package.json | 3 --- yarn.lock | 2 -- 2 files changed, 5 deletions(-) diff --git a/packages/scaffolder-test-utils/package.json b/packages/scaffolder-test-utils/package.json index 604eff388f..b1aaad001e 100644 --- a/packages/scaffolder-test-utils/package.json +++ b/packages/scaffolder-test-utils/package.json @@ -42,8 +42,5 @@ "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "winston": "^3.2.1" - }, - "peerDependencies": { - "@types/jest": "*" } } diff --git a/yarn.lock b/yarn.lock index 341f32e976..aa9ab30dab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9891,8 +9891,6 @@ __metadata: "@testing-library/jest-dom": ^6.0.0 "@types/react": "*" winston: ^3.2.1 - peerDependencies: - "@types/jest": "*" languageName: unknown linkType: soft From 04585753738b8468c5afb3364921d3adbf763da1 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Fri, 23 Feb 2024 21:56:11 +0100 Subject: [PATCH 161/176] wip Signed-off-by: bnechyporenko --- .../writing-tests-for-actions.md | 57 +++++++ .../src/actions/github.ts | 152 ++++++++++-------- 2 files changed, 142 insertions(+), 67 deletions(-) create mode 100644 docs/features/software-templates/writing-tests-for-actions.md diff --git a/docs/features/software-templates/writing-tests-for-actions.md b/docs/features/software-templates/writing-tests-for-actions.md new file mode 100644 index 0000000000..7a75250479 --- /dev/null +++ b/docs/features/software-templates/writing-tests-for-actions.md @@ -0,0 +1,57 @@ +--- +id: writing-tests-for-actions +title: Writing Tests For Actions +description: How to write tests for actions +--- + +Once you created a new action, your own custom one, or you would like to contribute new actions, you have to cover it with +Unit tests to be sure that your actions do what they suppose to do. + +Make sure that you cover the most of scenario's, which could happen with the action. +One of indispensable part of the test is to supply the context to a handler of action for the execution. +We encourage you to use a utility method for that, so your tests are immune to structural changes of context. +What is inevitably going to happen during the time. + +Example how to use it: + +```typescript +import { createMockActionContext } from '@backstage/scaffolder-test-utils'; + +const mockContext = createMockActionContext({ + input: { repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org' }, +}); + +await action.handler(mockContext); + +expect(mockContext.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://dev.azure.com/organization/project/_git/repo', +); +``` + +One thing to be aware about: if you would like to call `createMockActionContext` inside `it`, +you have to provide a `workspacePath`. By default, `createMockActionContext` uses +`import { createMockDirectory } from '@backstage/backend-test-utils';` to create it for you. +This implementation contains a hook inside which creates this limitation. So in this case you can do then: + +```typescript +describe('github:autolinks:create', async () => { + const workspacePath = createMockDirectory().resolve('workspace'); + // ... + + it('should call the githubApis for creating alphanumeric autolink reference', async () => { + // ... + await action.handler( + createMockActionContext({ + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + keyPrefix: 'TICKET-', + urlTemplate: 'https://example.com/TICKET?query=', + }, + workspacePath, + }), + ); + //... + }); +}); +``` diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.ts b/plugins/scaffolder-backend-module-github/src/actions/github.ts index f32b65bc9a..3a1beae8e8 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.ts @@ -213,79 +213,97 @@ export function createPublishGithubAction(options: { requiredCommitSigning = false, } = ctx.input; - const octokitOptions = await getOctokitOptions({ - integrations, - credentialsProvider: githubCredentialsProvider, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); + const { _commitHash, _remoteUrl, _repoContentsUrl } = + await ctx.checkpoint?.( + 'repo.create', + async (): Promise<{ + _commitHash: string; + _remoteUrl: string; + _repoContentsUrl: string; + }> => { + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + token: providedToken, + repoUrl: repoUrl, + }); + const client = new Octokit(octokitOptions); - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { owner, repo } = parseRepoUrl(repoUrl, integrations); - if (!owner) { - throw new InputError('Invalid repository owner provided in repoUrl'); - } + if (!owner) { + throw new InputError( + 'Invalid repository owner provided in repoUrl', + ); + } - const newRepo = await createGithubRepoWithCollaboratorsAndTopics( - client, - repo, - owner, - repoVisibility, - description, - homepage, - deleteBranchOnMerge, - allowMergeCommit, - allowSquashMerge, - squashMergeCommitTitle, - squashMergeCommitMessage, - allowRebaseMerge, - allowAutoMerge, - access, - collaborators, - hasProjects, - hasWiki, - hasIssues, - topics, - repoVariables, - secrets, - oidcCustomization, - ctx.logger, - ); + const newRepo = await createGithubRepoWithCollaboratorsAndTopics( + client, + repo, + owner, + repoVisibility, + description, + homepage, + deleteBranchOnMerge, + allowMergeCommit, + allowSquashMerge, + squashMergeCommitTitle, + squashMergeCommitMessage, + allowRebaseMerge, + allowAutoMerge, + access, + collaborators, + hasProjects, + hasWiki, + hasIssues, + topics, + repoVariables, + secrets, + oidcCustomization, + ctx.logger, + ); - const remoteUrl = newRepo.clone_url; - const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`; + const remoteUrl = newRepo.clone_url; + const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`; - const commitResult = await initRepoPushAndProtect( - remoteUrl, - octokitOptions.auth, - ctx.workspacePath, - ctx.input.sourcePath, - defaultBranch, - protectDefaultBranch, - protectEnforceAdmins, - owner, - client, - repo, - requireCodeOwnerReviews, - bypassPullRequestAllowances, - requiredApprovingReviewCount, - restrictions, - requiredStatusCheckContexts, - requireBranchesToBeUpToDate, - requiredConversationResolution, - config, - ctx.logger, - gitCommitMessage, - gitAuthorName, - gitAuthorEmail, - dismissStaleReviews, - requiredCommitSigning, - ); + const commitResult = await initRepoPushAndProtect( + remoteUrl, + octokitOptions.auth, + ctx.workspacePath, + ctx.input.sourcePath, + defaultBranch, + protectDefaultBranch, + protectEnforceAdmins, + owner, + client, + repo, + requireCodeOwnerReviews, + bypassPullRequestAllowances, + requiredApprovingReviewCount, + restrictions, + requiredStatusCheckContexts, + requireBranchesToBeUpToDate, + requiredConversationResolution, + config, + ctx.logger, + gitCommitMessage, + gitAuthorName, + gitAuthorEmail, + dismissStaleReviews, + requiredCommitSigning, + ); - ctx.output('commitHash', commitResult?.commitHash); - ctx.output('remoteUrl', remoteUrl); - ctx.output('repoContentsUrl', repoContentsUrl); + return { + _commitHash: commitResult?.commitHash, + _remoteUrl: remoteUrl, + _repoContentsUrl: repoContentsUrl, + }; + }, + )!!; + + ctx.output('commitHash', _commitHash); + ctx.output('remoteUrl', _remoteUrl); + ctx.output('repoContentsUrl', _repoContentsUrl); }, }); } From 6766c4e7438b13644bcf21ed6be19470887e7cd0 Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Sat, 24 Feb 2024 10:55:12 +0100 Subject: [PATCH 162/176] wip Signed-off-by: bnechyporenko --- .../src/actions/github.ts | 152 ++++++++---------- 1 file changed, 67 insertions(+), 85 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.ts b/plugins/scaffolder-backend-module-github/src/actions/github.ts index 3a1beae8e8..f32b65bc9a 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.ts @@ -213,97 +213,79 @@ export function createPublishGithubAction(options: { requiredCommitSigning = false, } = ctx.input; - const { _commitHash, _remoteUrl, _repoContentsUrl } = - await ctx.checkpoint?.( - 'repo.create', - async (): Promise<{ - _commitHash: string; - _remoteUrl: string; - _repoContentsUrl: string; - }> => { - const octokitOptions = await getOctokitOptions({ - integrations, - credentialsProvider: githubCredentialsProvider, - token: providedToken, - repoUrl: repoUrl, - }); - const client = new Octokit(octokitOptions); + const octokitOptions = await getOctokitOptions({ + integrations, + credentialsProvider: githubCredentialsProvider, + token: providedToken, + repoUrl: repoUrl, + }); + const client = new Octokit(octokitOptions); - const { owner, repo } = parseRepoUrl(repoUrl, integrations); + const { owner, repo } = parseRepoUrl(repoUrl, integrations); - if (!owner) { - throw new InputError( - 'Invalid repository owner provided in repoUrl', - ); - } + if (!owner) { + throw new InputError('Invalid repository owner provided in repoUrl'); + } - const newRepo = await createGithubRepoWithCollaboratorsAndTopics( - client, - repo, - owner, - repoVisibility, - description, - homepage, - deleteBranchOnMerge, - allowMergeCommit, - allowSquashMerge, - squashMergeCommitTitle, - squashMergeCommitMessage, - allowRebaseMerge, - allowAutoMerge, - access, - collaborators, - hasProjects, - hasWiki, - hasIssues, - topics, - repoVariables, - secrets, - oidcCustomization, - ctx.logger, - ); + const newRepo = await createGithubRepoWithCollaboratorsAndTopics( + client, + repo, + owner, + repoVisibility, + description, + homepage, + deleteBranchOnMerge, + allowMergeCommit, + allowSquashMerge, + squashMergeCommitTitle, + squashMergeCommitMessage, + allowRebaseMerge, + allowAutoMerge, + access, + collaborators, + hasProjects, + hasWiki, + hasIssues, + topics, + repoVariables, + secrets, + oidcCustomization, + ctx.logger, + ); - const remoteUrl = newRepo.clone_url; - const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`; + const remoteUrl = newRepo.clone_url; + const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`; - const commitResult = await initRepoPushAndProtect( - remoteUrl, - octokitOptions.auth, - ctx.workspacePath, - ctx.input.sourcePath, - defaultBranch, - protectDefaultBranch, - protectEnforceAdmins, - owner, - client, - repo, - requireCodeOwnerReviews, - bypassPullRequestAllowances, - requiredApprovingReviewCount, - restrictions, - requiredStatusCheckContexts, - requireBranchesToBeUpToDate, - requiredConversationResolution, - config, - ctx.logger, - gitCommitMessage, - gitAuthorName, - gitAuthorEmail, - dismissStaleReviews, - requiredCommitSigning, - ); + const commitResult = await initRepoPushAndProtect( + remoteUrl, + octokitOptions.auth, + ctx.workspacePath, + ctx.input.sourcePath, + defaultBranch, + protectDefaultBranch, + protectEnforceAdmins, + owner, + client, + repo, + requireCodeOwnerReviews, + bypassPullRequestAllowances, + requiredApprovingReviewCount, + restrictions, + requiredStatusCheckContexts, + requireBranchesToBeUpToDate, + requiredConversationResolution, + config, + ctx.logger, + gitCommitMessage, + gitAuthorName, + gitAuthorEmail, + dismissStaleReviews, + requiredCommitSigning, + ); - return { - _commitHash: commitResult?.commitHash, - _remoteUrl: remoteUrl, - _repoContentsUrl: repoContentsUrl, - }; - }, - )!!; - - ctx.output('commitHash', _commitHash); - ctx.output('remoteUrl', _remoteUrl); - ctx.output('repoContentsUrl', _repoContentsUrl); + ctx.output('commitHash', commitResult?.commitHash); + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); }, }); } From 4f25522da96a08cb0e13b6acbb243ddcbb49024a Mon Sep 17 00:00:00 2001 From: bnechyporenko Date: Mon, 26 Feb 2024 22:08:17 +0100 Subject: [PATCH 163/176] wip Signed-off-by: bnechyporenko --- .changeset/kind-pants-speak.md | 2 +- .../writing-tests-for-actions.md | 2 +- packages/scaffolder-test-utils/CHANGELOG.md | 1 - .../package.json | 2 +- .../src/actions/azure.examples.test.ts | 2 +- .../src/actions/azure.test.ts | 2 +- .../package.json | 2 +- .../src/actions/bitbucketCloud.test.ts | 2 +- ...itbucketCloudPipelinesRun.examples.test.ts | 2 +- .../bitbucketCloudPipelinesRun.test.ts | 2 +- .../package.json | 2 +- .../src/actions/bitbucketServer.test.ts | 2 +- .../bitbucketServerPullRequest.test.ts | 2 +- .../package.json | 2 +- .../src/actions/bitbucket.examples.test.ts | 2 +- .../src/actions/bitbucket.test.ts | 2 +- .../package.json | 2 +- .../confluenceToMarkdown.examples.test.ts | 2 +- .../confluence/confluenceToMarkdown.test.ts | 2 +- .../package.json | 2 +- .../src/actions/fetch/cookiecutter.test.ts | 2 +- .../package.json | 2 +- .../src/actions/gerrit.test.ts | 2 +- .../src/actions/gerritReview.test.ts | 2 +- .../package.json | 2 +- .../src/actions/gitea.test.ts | 2 +- .../package.json | 2 +- .../src/actions/github.examples.test.ts | 2 +- .../src/actions/github.test.ts | 2 +- .../githubActionsDispatch.examples.test.ts | 2 +- .../src/actions/githubActionsDispatch.test.ts | 2 +- .../actions/githubAutolinks.examples.test.ts | 2 +- .../src/actions/githubAutolinks.test.ts | 2 +- .../actions/githubDeployKey.examples.test.ts | 2 +- .../src/actions/githubDeployKey.test.ts | 2 +- .../githubEnvironment.examples.test.ts | 2 +- .../src/actions/githubEnvironment.test.ts | 2 +- .../githubIssuesLabel.examples.test.ts | 2 +- .../src/actions/githubIssuesLabel.test.ts | 2 +- .../githubPullRequest.examples.test.ts | 2 +- .../src/actions/githubPullRequest.test.ts | 2 +- .../actions/githubRepoCreate.examples.test.ts | 2 +- .../src/actions/githubRepoCreate.test.ts | 2 +- .../actions/githubRepoPush.examples.test.ts | 2 +- .../src/actions/githubRepoPush.test.ts | 2 +- .../actions/githubWebhook.examples.test.ts | 2 +- .../src/actions/githubWebhook.test.ts | 2 +- .../package.json | 2 +- ...reateGitlabGroupEnsureExistsAction.test.ts | 2 +- .../actions/createGitlabIssueAction.test.ts | 2 +- ...bProjectAccessTokenAction.examples.test.ts | 2 +- ...eateGitlabProjectDeployTokenAction.test.ts | 2 +- .../src/actions/gitlab.examples.test.ts | 2 +- .../src/actions/gitlab.test.ts | 2 +- .../src/actions/gitlabMergeRequest.test.ts | 2 +- .../src/actions/gitlabRepoPush.test.ts | 2 +- .../package.json | 2 +- .../src/actions/fetch/rails/index.test.ts | 2 +- .../package.json | 2 +- .../src/actions/createProject.test.ts | 2 +- .../package.json | 2 +- .../src/actions/run/yeoman.test.ts | 2 +- plugins/scaffolder-backend/package.json | 2 +- .../builtin/catalog/fetch.examples.test.ts | 2 +- .../actions/builtin/catalog/fetch.test.ts | 2 +- .../builtin/catalog/register.examples.test.ts | 2 +- .../actions/builtin/catalog/register.test.ts | 2 +- .../builtin/catalog/write.examples.test.ts | 2 +- .../actions/builtin/catalog/write.test.ts | 2 +- .../builtin/debug/log.examples.test.ts | 2 +- .../actions/builtin/debug/log.test.ts | 2 +- .../builtin/debug/wait.examples.test.ts | 2 +- .../actions/builtin/debug/wait.test.ts | 2 +- .../builtin/fetch/plain.examples.test.ts | 2 +- .../actions/builtin/fetch/plain.test.ts | 2 +- .../builtin/fetch/plainFile.examples.test.ts | 2 +- .../actions/builtin/fetch/plainFile.test.ts | 2 +- .../builtin/fetch/template.examples.test.ts | 2 +- .../actions/builtin/fetch/template.test.ts | 2 +- .../filesystem/delete.examples.test.ts | 2 +- .../actions/builtin/filesystem/delete.test.ts | 2 +- .../filesystem/rename.examples.test.ts | 4 +- .../actions/builtin/filesystem/rename.test.ts | 2 +- .../scaffolder-node-test-utils}/.eslintrc.js | 0 .../scaffolder-node-test-utils/CHANGELOG.md | 1 + .../scaffolder-node-test-utils}/README.md | 4 +- .../scaffolder-node-test-utils}/api-report.md | 24 ++++---- .../catalog-info.yaml | 4 +- .../knip-report.md | 0 .../scaffolder-node-test-utils}/package.json | 2 +- .../src/actions/index.ts | 0 .../src/actions/mockActionConext.ts | 0 .../scaffolder-node-test-utils}/src/index.ts | 0 .../src/next/components/Stepper/Stepper.tsx | 37 +++++++----- yarn.lock | 60 +++++++++---------- 95 files changed, 152 insertions(+), 147 deletions(-) delete mode 100644 packages/scaffolder-test-utils/CHANGELOG.md rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/.eslintrc.js (100%) create mode 100644 plugins/scaffolder-node-test-utils/CHANGELOG.md rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/README.md (64%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/api-report.md (55%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/catalog-info.yaml (57%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/knip-report.md (100%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/package.json (95%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/src/actions/index.ts (100%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/src/actions/mockActionConext.ts (100%) rename {packages/scaffolder-test-utils => plugins/scaffolder-node-test-utils}/src/index.ts (100%) diff --git a/.changeset/kind-pants-speak.md b/.changeset/kind-pants-speak.md index 8af9a7e890..84b287eb00 100644 --- a/.changeset/kind-pants-speak.md +++ b/.changeset/kind-pants-speak.md @@ -12,7 +12,7 @@ '@backstage/plugin-scaffolder-backend-module-azure': patch '@backstage/plugin-scaffolder-backend-module-gitea': patch '@backstage/plugin-scaffolder-backend-module-rails': patch -'@backstage/scaffolder-test-utils': patch +'@backstage/plugin-scaffolder-node-test-utils': patch '@backstage/plugin-scaffolder-backend': patch --- diff --git a/docs/features/software-templates/writing-tests-for-actions.md b/docs/features/software-templates/writing-tests-for-actions.md index 7a75250479..0c92811b6f 100644 --- a/docs/features/software-templates/writing-tests-for-actions.md +++ b/docs/features/software-templates/writing-tests-for-actions.md @@ -15,7 +15,7 @@ What is inevitably going to happen during the time. Example how to use it: ```typescript -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; const mockContext = createMockActionContext({ input: { repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org' }, diff --git a/packages/scaffolder-test-utils/CHANGELOG.md b/packages/scaffolder-test-utils/CHANGELOG.md deleted file mode 100644 index e290a56ced..0000000000 --- a/packages/scaffolder-test-utils/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -# @backstage/scaffolder-test-utils diff --git a/plugins/scaffolder-backend-module-azure/package.json b/plugins/scaffolder-backend-module-azure/package.json index 2bbcedf897..b7902cc68a 100644 --- a/plugins/scaffolder-backend-module-azure/package.json +++ b/plugins/scaffolder-backend-module-azure/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^" + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts b/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts index 7634480bc5..e00362c457 100644 --- a/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts +++ b/plugins/scaffolder-backend-module-azure/src/actions/azure.examples.test.ts @@ -21,7 +21,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { WebApi } from 'azure-devops-node-api'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { examples } from './azure.examples'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; jest.mock('azure-devops-node-api', () => ({ WebApi: jest.fn(), diff --git a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts index 6b076b57d7..a23eebb93a 100644 --- a/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts +++ b/plugins/scaffolder-backend-module-azure/src/actions/azure.test.ts @@ -36,7 +36,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { WebApi } from 'azure-devops-node-api'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:azure', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index 1ec9c4083c..b6aa2acca4 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts index bc56bde073..6a079a3f9b 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.test.ts @@ -33,7 +33,7 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:bitbucketCloud', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts index 1786ff26e2..472c7a6a03 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.examples.test.ts @@ -22,7 +22,7 @@ import { examples } from './bitbucketCloudPipelinesRun.examples'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('bitbucket:pipelines:run', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts index 64b4e8f7fe..9386246868 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.test.ts @@ -20,7 +20,7 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('bitbucket:pipelines:run', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/package.json b/plugins/scaffolder-backend-module-bitbucket-server/package.json index dee14897ab..1f3abc2a8e 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-server/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts index 1c1ec09368..1d42e7368c 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServer.test.ts @@ -33,7 +33,7 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:bitbucketServer', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts index 00d6db384a..9d2dd915f8 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.test.ts @@ -32,7 +32,7 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:bitbucketServer:pull-request', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket/package.json b/plugins/scaffolder-backend-module-bitbucket/package.json index 2a88ff8525..d05b85a9de 100644 --- a/plugins/scaffolder-backend-module-bitbucket/package.json +++ b/plugins/scaffolder-backend-module-bitbucket/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts index 17ac8ff522..c8f35fa0bf 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.examples.test.ts @@ -36,7 +36,7 @@ import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import yaml from 'yaml'; import { sep } from 'path'; import { examples } from './bitbucket.examples'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:bitbucket', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts index 0a3ddd8c9d..a63d657904 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucket.test.ts @@ -32,7 +32,7 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:bitbucket', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json index 67b899991e..d674cea8c4 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json @@ -54,7 +54,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts index 47befca7ac..18ff0ad3ec 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.examples.test.ts @@ -27,7 +27,7 @@ import { setupServer } from 'msw/node'; import { examples } from './confluenceToMarkdown.examples'; import yaml from 'yaml'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('confluence:transform:markdown examples', () => { const baseUrl = `https://confluence.example.com`; diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts index 889d59c8bf..eccb66f79e 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.test.ts @@ -26,7 +26,7 @@ import { import type { ActionContext } from '@backstage/plugin-scaffolder-node'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('confluence:transform:markdown', () => { const baseUrl = `https://nodomain.confluence.com`; diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index a512c2ea36..6d4223a565 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^11.0.0" }, diff --git a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts index 80d7681e94..92a636e58b 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend-module-cookiecutter/src/actions/fetch/cookiecutter.test.ts @@ -22,7 +22,7 @@ import { createMockDirectory } from '@backstage/backend-test-utils'; import { createFetchCookiecutterAction } from './cookiecutter'; import { join } from 'path'; import type { ActionContext } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; const executeShellCommand = jest.fn(); const commandExists = jest.fn(); diff --git a/plugins/scaffolder-backend-module-gerrit/package.json b/plugins/scaffolder-backend-module-gerrit/package.json index 36f7c613a7..be7abf15e1 100644 --- a/plugins/scaffolder-backend-module-gerrit/package.json +++ b/plugins/scaffolder-backend-module-gerrit/package.json @@ -49,7 +49,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts b/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts index 20c8b74de6..c08544843f 100644 --- a/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts +++ b/plugins/scaffolder-backend-module-gerrit/src/actions/gerrit.test.ts @@ -34,7 +34,7 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:gerrit', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts b/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts index cee4b8344c..91236d4cc0 100644 --- a/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts +++ b/plugins/scaffolder-backend-module-gerrit/src/actions/gerritReview.test.ts @@ -25,7 +25,7 @@ import { createPublishGerritReviewAction } from './gerritReview'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { commitAndPushRepo } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('publish:gerrit:review', () => { const config = new ConfigReader({ diff --git a/plugins/scaffolder-backend-module-gitea/package.json b/plugins/scaffolder-backend-module-gitea/package.json index 2b1b4b6176..976f445ea5 100644 --- a/plugins/scaffolder-backend-module-gitea/package.json +++ b/plugins/scaffolder-backend-module-gitea/package.json @@ -49,7 +49,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts index a3f7d80e88..8675b1ff97 100644 --- a/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts +++ b/plugins/scaffolder-backend-module-gitea/src/actions/gitea.test.ts @@ -19,7 +19,7 @@ import { createPublishGiteaAction } from './gitea'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; import { rest } from 'msw'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { setupServer } from 'msw/node'; jest.mock('@backstage/plugin-scaffolder-node', () => { diff --git a/plugins/scaffolder-backend-module-github/package.json b/plugins/scaffolder-backend-module-github/package.json index 8e1a05134f..15e148b49c 100644 --- a/plugins/scaffolder-backend-module-github/package.json +++ b/plugins/scaffolder-backend-module-github/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@types/libsodium-wrappers": "^0.7.10", "fs-extra": "^11.2.0", "jest-when": "^3.1.0", diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts index 61b5787d46..6a6c6daf13 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.examples.test.ts @@ -37,7 +37,7 @@ import { initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/github.test.ts b/plugins/scaffolder-backend-module-github/src/actions/github.test.ts index cdf2fb552a..02d6a2afaf 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/github.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/github.test.ts @@ -35,7 +35,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts index 9496fdd1bb..1e59037c04 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts @@ -22,7 +22,7 @@ import { import { ConfigReader } from '@backstage/config'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createGithubActionsDispatchAction } from './githubActionsDispatch'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import yaml from 'yaml'; import { examples } from './githubActionsDispatch.examples'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts index e69a92a0a2..7f0d9b0490 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts @@ -21,7 +21,7 @@ import { } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createGithubActionsDispatchAction } from './githubActionsDispatch'; const mockOctokit = { diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts index c62cee8c00..a686ea7f6d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.examples.test.ts @@ -22,7 +22,7 @@ import { } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createGithubAutolinksAction } from './githubAutolinks'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { examples } from './githubAutolinks.examples'; import yaml from 'yaml'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts index 0a529e0377..56115c156c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubAutolinks.test.ts @@ -21,7 +21,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createGithubAutolinksAction } from './githubAutolinks'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts index 1fb3facb83..62e79dcd68 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createGithubDeployKeyAction } from './githubDeployKey'; import yaml from 'yaml'; import { examples } from './githubDeployKey.examples'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts index cb8f84e563..798aa7705e 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubDeployKey.test.ts @@ -16,7 +16,7 @@ import { createGithubDeployKeyAction } from './githubDeployKey'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts index 1130f41a68..ecb4e9f092 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createGithubEnvironmentAction } from './githubEnvironment'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts index a590257a2e..16872525b4 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts @@ -15,7 +15,7 @@ */ import { createGithubEnvironmentAction } from './githubEnvironment'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts index 5afee9e9ed..75ac6025ce 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.examples.test.ts @@ -15,7 +15,7 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts index 72200f0ea0..13c7df68cb 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubIssuesLabel.test.ts @@ -20,7 +20,7 @@ import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { getOctokitOptions } from './helpers'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts index 083eb78066..dd07c88a70 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.examples.test.ts @@ -21,7 +21,7 @@ import { GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createPublishGithubPullRequestAction } from './githubPullRequest'; import yaml from 'yaml'; import { examples } from './githubPullRequest.examples'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts index 5cbaac6de6..c9d1498187 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts @@ -27,7 +27,7 @@ import { import fs from 'fs-extra'; import { createPublishGithubPullRequestAction } from './githubPullRequest'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts index 3dd8a44e07..843a32f758 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.examples.test.ts @@ -29,7 +29,7 @@ import { GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createGithubRepoCreateAction } from './githubRepoCreate'; import { entityRefToName } from './gitHelpers'; import yaml from 'yaml'; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts index ce29de7a1e..25ddd176c1 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoCreate.test.ts @@ -15,7 +15,7 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; jest.mock('./gitHelpers', () => { return { diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts index 8c48811eb1..691cebd367 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.examples.test.ts @@ -29,7 +29,7 @@ import { TemplateAction, initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts index 31b3a7c95e..206d4f067d 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubRepoPush.test.ts @@ -60,7 +60,7 @@ import { initRepoAndPush, } from '@backstage/plugin-scaffolder-node'; import { ConfigReader } from '@backstage/config'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts index 17dd371a21..d58d737f93 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { DefaultGithubCredentialsProvider, diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts index 8c5a57542f..cbdcee6af1 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubWebhook.test.ts @@ -20,7 +20,7 @@ import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index 72514db885..f24a967637 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -57,7 +57,7 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "jest-date-mock": "^1.0.8" }, "files": [ diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts index a94a844d4c..4dc0305c2e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabGroupEnsureExistsAction.test.ts @@ -15,7 +15,7 @@ */ import { createGitlabGroupEnsureExistsAction } from './createGitlabGroupEnsureExistsAction'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts index d33cbb37dc..820ef8902e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createGitlabIssueAction, IssueType } from './createGitlabIssueAction'; import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts index 90f4fca283..5afeaba8ae 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts @@ -18,7 +18,7 @@ import { ScmIntegrations } from '@backstage/integration'; import yaml from 'yaml'; import { createGitlabProjectAccessTokenAction } from './createGitlabProjectAccessTokenAction'; // Adjust the import based on your project structure import { examples } from './createGitlabProjectAccessTokenAction.examples'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { DateTime } from 'luxon'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts index c626a20124..cab72c9a6d 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectDeployTokenAction.test.ts @@ -15,7 +15,7 @@ */ import { createGitlabProjectDeployTokenAction } from './createGitlabProjectDeployTokenAction'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts index 3bd6b0e5ff..4294e5f137 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import yaml from 'yaml'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; jest.mock('@backstage/plugin-scaffolder-node', () => { return { diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts index 40712966ba..bbcae08063 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts @@ -30,7 +30,7 @@ import { createPublishGitlabAction } from './gitlab'; import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { initRepoAndPush } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; const mockGitlabClient = { Namespaces: { diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts index 4f6585e7b3..befc2c01c9 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts @@ -19,7 +19,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createPublishGitlabMergeRequestAction } from './gitlabMergeRequest'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts index 24ba3abcd3..569d3c0c56 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts @@ -19,7 +19,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { createGitlabRepoPushAction } from './gitlabRepoPush'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; // Make sure root logger is initialized ahead of FS mock createRootLogger(); diff --git a/plugins/scaffolder-backend-module-rails/package.json b/plugins/scaffolder-backend-module-rails/package.json index 5426049996..59950702c6 100644 --- a/plugins/scaffolder-backend-module-rails/package.json +++ b/plugins/scaffolder-backend-module-rails/package.json @@ -51,7 +51,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@types/command-exists": "^1.2.0", "@types/fs-extra": "^11.0.0", "@types/node": "^18.17.8", diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts index 0f56bd55ec..b43ffcc95c 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.test.ts @@ -34,7 +34,7 @@ import { resolve as resolvePath } from 'path'; import { createFetchRailsAction } from './index'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('fetch:rails', () => { const mockDir = createMockDirectory(); diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index fb942617f3..3b1cfcc276 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@backstage/types": "workspace:^", "msw": "^2.0.0" }, diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index 715f21d944..158b7db4ae 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -15,7 +15,7 @@ */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ConfigReader } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; diff --git a/plugins/scaffolder-backend-module-yeoman/package.json b/plugins/scaffolder-backend-module-yeoman/package.json index 9abfe95ac0..6631d7833c 100644 --- a/plugins/scaffolder-backend-module-yeoman/package.json +++ b/plugins/scaffolder-backend-module-yeoman/package.json @@ -39,7 +39,7 @@ "dependencies": { "@backstage/backend-plugin-api": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@backstage/types": "workspace:^", "winston": "^3.2.1", "yeoman-environment": "^3.9.1" diff --git a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts index ae18a25365..52bd95a3f9 100644 --- a/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts +++ b/plugins/scaffolder-backend-module-yeoman/src/actions/run/yeoman.test.ts @@ -18,7 +18,7 @@ import { yeomanRun } from './yeomanRun'; jest.mock('./yeomanRun'); -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import os from 'os'; import { createRunYeomanAction } from './yeoman'; import type { ActionContext } from '@backstage/plugin-scaffolder-node'; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 5884450a89..61bd5ef49b 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -95,7 +95,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/scaffolder-test-utils": "workspace:^", + "@backstage/plugin-scaffolder-node-test-utils": "workspace:^", "@types/fs-extra": "^11.0.0", "@types/nunjucks": "^3.1.4", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts index 62e0cef0dc..a368144784 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts index 43d7660a9a..d3fd398a90 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/fetch.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { createFetchCatalogEntityAction } from './fetch'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts index e9900221e3..db3d7151d8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts index b035a8c94d..8f6219b64c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts index 6f410db07e..e6b4bcde47 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.examples.test.ts @@ -20,7 +20,7 @@ jest.mock('fs-extra'); const fsMock = fs as jest.Mocked; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createCatalogWriteAction } from './write'; import { resolve as resolvePath } from 'path'; import * as yaml from 'yaml'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts index 1b06c930f9..cc22b75da2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts @@ -21,7 +21,7 @@ jest.mock('fs-extra'); const fsMock = fs as jest.Mocked; import os from 'os'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model'; import { createCatalogWriteAction } from './write'; import { resolve as resolvePath } from 'path'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts index de008433f0..06addba98d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { Writable } from 'stream'; import { createDebugLogAction } from './log'; import { join } from 'path'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts index c9bd0f8cbe..09c090582a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { Writable } from 'stream'; import { createDebugLogAction } from './log'; import { join } from 'path'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts index 00574dedfd..8bc29a74c9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.examples.test.ts @@ -18,7 +18,7 @@ import { createWaitAction } from './wait'; import { Writable } from 'stream'; import { examples } from './wait.examples'; import yaml from 'yaml'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('debug:wait examples', () => { const action = createWaitAction(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts index 1424ca3012..0dcbd10f0f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/wait.test.ts @@ -16,7 +16,7 @@ import { createWaitAction } from './wait'; import { Writable } from 'stream'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; describe('debug:wait', () => { const action = createWaitAction(); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts index 7ce945a08f..82a32fcbac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.examples.test.ts @@ -22,7 +22,7 @@ import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import { createFetchPlainAction } from './plain'; import { fetchContents } from '@backstage/plugin-scaffolder-node'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { examples } from './plain.examples'; jest.mock('@backstage/plugin-scaffolder-node', () => ({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts index 7468779f3a..bc20fafe19 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.test.ts @@ -20,7 +20,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { }); import { resolve as resolvePath } from 'path'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts index 25993caf96..9978d6d8f5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.examples.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; jest.mock('@backstage/plugin-scaffolder-node', () => { const actual = jest.requireActual('@backstage/plugin-scaffolder-node'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts index 8f889bef4b..bf40c7ad0e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plainFile.test.ts @@ -20,7 +20,7 @@ jest.mock('@backstage/plugin-scaffolder-node', () => { }); import { resolve as resolvePath } from 'path'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts index 29a267ceab..36f1f00b50 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.examples.test.ts @@ -18,7 +18,7 @@ import { join as joinPath, sep as pathSep } from 'path'; import fs from 'fs-extra'; import { resolvePackagePath, UrlReader } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { createFetchTemplateAction } from './template'; import { ActionContext, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 3cd8f61e77..fd0ef7d757 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -30,7 +30,7 @@ import { TemplateAction, } from '@backstage/plugin-scaffolder-node'; import { createMockDirectory } from '@backstage/backend-test-utils'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; type FetchTemplateInput = ReturnType< typeof createFetchTemplateAction diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts index 8611486dac..d11b1e4087 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.examples.test.ts @@ -15,7 +15,7 @@ */ import { createFilesystemDeleteAction } from './delete'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import { resolve as resolvePath } from 'path'; import fs from 'fs-extra'; import yaml from 'yaml'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts index 2cddb9f5a6..8226133376 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.test.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemDeleteAction } from './delete'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import fs from 'fs-extra'; import { createMockDirectory } from '@backstage/backend-test-utils'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts index 5e9ba84465..0881bb8260 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.examples.test.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemRenameAction } from './rename'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import fs from 'fs-extra'; import yaml from 'yaml'; import { examples } from './rename.examples'; @@ -32,7 +32,7 @@ describe('fs:rename examples', () => { const mockContext = createMockActionContext({ input: { - files: files, + files, }, workspacePath, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts index b081200b71..6696fda693 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.test.ts @@ -16,7 +16,7 @@ import { resolve as resolvePath } from 'path'; import { createFilesystemRenameAction } from './rename'; -import { createMockActionContext } from '@backstage/scaffolder-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; import fs from 'fs-extra'; import { createMockDirectory } from '@backstage/backend-test-utils'; diff --git a/packages/scaffolder-test-utils/.eslintrc.js b/plugins/scaffolder-node-test-utils/.eslintrc.js similarity index 100% rename from packages/scaffolder-test-utils/.eslintrc.js rename to plugins/scaffolder-node-test-utils/.eslintrc.js diff --git a/plugins/scaffolder-node-test-utils/CHANGELOG.md b/plugins/scaffolder-node-test-utils/CHANGELOG.md new file mode 100644 index 0000000000..2943a2a755 --- /dev/null +++ b/plugins/scaffolder-node-test-utils/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/plugin-scaffolder-node-test-utils diff --git a/packages/scaffolder-test-utils/README.md b/plugins/scaffolder-node-test-utils/README.md similarity index 64% rename from packages/scaffolder-test-utils/README.md rename to plugins/scaffolder-node-test-utils/README.md index e5810058b3..851ddf808d 100644 --- a/packages/scaffolder-test-utils/README.md +++ b/plugins/scaffolder-node-test-utils/README.md @@ -1,4 +1,4 @@ -# @backstage/scaffolder-test-utils +# @backstage/plugin-scaffolder-node-test-utils Contains utilities that can be used when testing scaffolder features. @@ -8,5 +8,5 @@ Install the package via Yarn into your own packages: ```sh cd # if within a monorepo -yarn add --dev @backstage/scaffolder-test-utils +yarn add --dev @backstage/plugin-scaffolder-node-test-utils ``` diff --git a/packages/scaffolder-test-utils/api-report.md b/plugins/scaffolder-node-test-utils/api-report.md similarity index 55% rename from packages/scaffolder-test-utils/api-report.md rename to plugins/scaffolder-node-test-utils/api-report.md index 4e50a55f5e..fcaaadfd67 100644 --- a/packages/scaffolder-test-utils/api-report.md +++ b/plugins/scaffolder-node-test-utils/api-report.md @@ -1,32 +1,32 @@ -## API Report File for "@backstage/scaffolder-test-utils" +## API Report File for "@backstage/plugin-scaffolder-node-test-utils" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts /// -import { ActionContext } from '@backstage/plugin-scaffolder-node'; -import { JsonObject } from '@backstage/types'; -import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; -import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; +import {ActionContext} from './index'; +import {JsonObject} from '@backstage/types'; +import {TaskSecrets} from './index'; +import {TemplateInfo} from './index'; import * as winston from 'winston'; -import { Writable } from 'stream'; +import {Writable} from 'stream'; // @public export const createMockActionContext: < - TActionInput extends JsonObject = JsonObject, - TActionOutput extends JsonObject = JsonObject, + TActionInput extends JsonObject = JsonObject, + TActionOutput extends JsonObject = JsonObject, >( - options?: - | { + options?: + | { input?: TActionInput | undefined; logger?: winston.Logger | undefined; logStream?: Writable | undefined; secrets?: TaskSecrets | undefined; templateInfo?: TemplateInfo | undefined; workspacePath?: string | undefined; - } - | undefined, + } + | undefined, ) => ActionContext; // (No @packageDocumentation comment for this package) diff --git a/packages/scaffolder-test-utils/catalog-info.yaml b/plugins/scaffolder-node-test-utils/catalog-info.yaml similarity index 57% rename from packages/scaffolder-test-utils/catalog-info.yaml rename to plugins/scaffolder-node-test-utils/catalog-info.yaml index 596e9b1f64..980bd07e34 100644 --- a/packages/scaffolder-test-utils/catalog-info.yaml +++ b/plugins/scaffolder-node-test-utils/catalog-info.yaml @@ -1,8 +1,8 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: backstage-scaffolder-test-utils - title: '@backstage/scaffolder-test-utils' + name: backstage-plugin-scaffolder-node-test-utils + title: '@backstage/plugin-scaffolder-node-test-utils' spec: lifecycle: experimental type: backstage-node-library diff --git a/packages/scaffolder-test-utils/knip-report.md b/plugins/scaffolder-node-test-utils/knip-report.md similarity index 100% rename from packages/scaffolder-test-utils/knip-report.md rename to plugins/scaffolder-node-test-utils/knip-report.md diff --git a/packages/scaffolder-test-utils/package.json b/plugins/scaffolder-node-test-utils/package.json similarity index 95% rename from packages/scaffolder-test-utils/package.json rename to plugins/scaffolder-node-test-utils/package.json index b1aaad001e..21bbb83a91 100644 --- a/packages/scaffolder-test-utils/package.json +++ b/plugins/scaffolder-node-test-utils/package.json @@ -1,5 +1,5 @@ { - "name": "@backstage/scaffolder-test-utils", + "name": "@backstage/plugin-scaffolder-node-test-utils", "version": "0.0.1", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/scaffolder-test-utils/src/actions/index.ts b/plugins/scaffolder-node-test-utils/src/actions/index.ts similarity index 100% rename from packages/scaffolder-test-utils/src/actions/index.ts rename to plugins/scaffolder-node-test-utils/src/actions/index.ts diff --git a/packages/scaffolder-test-utils/src/actions/mockActionConext.ts b/plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts similarity index 100% rename from packages/scaffolder-test-utils/src/actions/mockActionConext.ts rename to plugins/scaffolder-node-test-utils/src/actions/mockActionConext.ts diff --git a/packages/scaffolder-test-utils/src/index.ts b/plugins/scaffolder-node-test-utils/src/index.ts similarity index 100% rename from packages/scaffolder-test-utils/src/index.ts rename to plugins/scaffolder-node-test-utils/src/index.ts diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 9b475870d2..9fdc0270e7 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -37,9 +37,8 @@ import { type FormValidation, } from './createAsyncValidators'; import { ReviewState, type ReviewStateProps } from '../ReviewState'; -import { useTemplateSchema } from '../../hooks/useTemplateSchema'; +import { useTemplateSchema, useFormDataFromQuery } from '../../hooks'; import validator from '@rjsf/validator-ajv8'; -import { useFormDataFromQuery } from '../../hooks'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; import { hasErrors } from './utils'; import * as FieldOverrides from './FieldOverrides'; @@ -112,6 +111,18 @@ export const Stepper = (stepperProps: StepperProps) => { const [errors, setErrors] = useState(); const styles = useStyles(); + const templateName = + typeof formState.name === 'string' + ? formState.name + : props.templateName ?? 'unknown'; + + const backLabel = + presentation?.buttonLabels?.backButtonText ?? backButtonText; + const createLabel = + presentation?.buttonLabels?.createButtonText ?? createButtonText; + const reviewLabel = + presentation?.buttonLabels?.reviewButtonText ?? reviewButtonText; + const extensions = useMemo(() => { return Object.fromEntries( props.extensions.map(({ name, component }) => [name, component]), @@ -147,10 +158,8 @@ export const Stepper = (stepperProps: StepperProps) => { const handleCreate = useCallback(() => { props.onCreate(formState); - const name = - typeof formState.name === 'string' ? formState.name : undefined; - analytics.captureEvent('create', name ?? props.templateName ?? 'unknown'); - }, [props, formState, analytics]); + analytics.captureEvent('click', `[${templateName}]: ${createLabel}`); + }, [props, formState, analytics, templateName, createLabel]); const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts }); @@ -174,20 +183,16 @@ export const Stepper = (stepperProps: StepperProps) => { setErrors(undefined); setActiveStep(prevActiveStep => { const stepNum = prevActiveStep + 1; - analytics.captureEvent('click', `Next Step (${stepNum})`); + analytics.captureEvent( + 'click', + `[${templateName}]: Next Step (${stepNum})`, + ); return stepNum; }); } setFormState(current => ({ ...current, ...formData })); }; - const backLabel = - presentation?.buttonLabels?.backButtonText ?? backButtonText; - const createLabel = - presentation?.buttonLabels?.createButtonText ?? createButtonText; - const reviewLabel = - presentation?.buttonLabels?.reviewButtonText ?? reviewButtonText; - return ( <> {isValidating && } @@ -214,7 +219,7 @@ export const Stepper = (stepperProps: StepperProps) => { ); })} - Review + ${reviewLabel}
@@ -274,7 +279,7 @@ export const Stepper = (stepperProps: StepperProps) => { className={styles.backButton} disabled={activeStep < 1} > - Back + {backLabel} - - + diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 8328da1c6d..6bcb6badc7 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -13,296 +13,162 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useState } from 'react'; -import { - Box, - Button, - IconButton, - makeStyles, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - Typography, -} from '@material-ui/core'; -import { - Notification, - NotificationType, -} from '@backstage/plugin-notifications-common'; -import { useNavigate } from 'react-router-dom'; -import Checkbox from '@material-ui/core/Checkbox'; -import Check from '@material-ui/icons/Check'; -import Bookmark from '@material-ui/icons/Bookmark'; +import React, { useMemo } from 'react'; +import throttle from 'lodash/throttle'; +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; +import { Notification } from '@backstage/plugin-notifications-common'; import { notificationsApiRef } from '../../api'; import { useApi } from '@backstage/core-plugin-api'; -import Inbox from '@material-ui/icons/Inbox'; -import CloseIcon from '@material-ui/icons/Close'; +import MarkAsUnreadIcon from '@material-ui/icons/Markunread'; +import MarkAsReadIcon from '@material-ui/icons/CheckCircle'; + // @ts-ignore import RelativeTime from 'react-relative-time'; -import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; +import { Link, Table, TableColumn } from '@backstage/core-components'; -const useStyles = makeStyles(theme => ({ - table: { - border: `1px solid ${theme.palette.divider}`, - }, - header: { - borderBottom: `1px solid ${theme.palette.divider}`, - }, - - notificationRow: { - cursor: 'pointer', - '&.unread': { - border: '1px solid rgba(255, 255, 255, .3)', - }, - '& .hideOnHover': { - display: 'initial', - }, - '& .showOnHover': { - display: 'none', - }, - '&:hover': { - '& .hideOnHover': { - display: 'none', - }, - '& .showOnHover': { - display: 'initial', - }, - }, - }, - actionButton: { - padding: '9px', - }, - checkBox: { - padding: '0 10px 10px 0', - }, -})); +const ThrottleDelayMs = 1000; /** @public */ -export const NotificationsTable = (props: { - onUpdate: () => void; - type: NotificationType; +export type NotificationsTableProps = { + isLoading?: boolean; notifications?: Notification[]; -}) => { - const { notifications, type } = props; - const navigate = useNavigate(); - const styles = useStyles(); - const [selected, setSelected] = useState([]); + onUpdate: () => void; + setContainsText: (search: string) => void; +}; + +/** @public */ +export const NotificationsTable = ({ + isLoading, + notifications = [], + onUpdate, + setContainsText, +}: NotificationsTableProps) => { const notificationsApi = useApi(notificationsApiRef); - const onCheckBoxClick = (id: string) => { - const index = selected.indexOf(id); - if (index !== -1) { - setSelected(selected.filter(s => s !== id)); - } else { - setSelected([...selected, id]); - } - }; + const onSwitchReadStatus = React.useCallback( + (notification: Notification) => { + notificationsApi + .updateNotifications({ + ids: [notification.id], + read: !notification.read, + }) + .then(() => onUpdate()); + }, + [notificationsApi, onUpdate], + ); - useEffect(() => { - setSelected([]); - }, [type]); + const throttledContainsTextHandler = useMemo( + () => throttle(setContainsText, ThrottleDelayMs), + [setContainsText], + ); - const isChecked = (id: string) => { - return selected.indexOf(id) !== -1; - }; - - const isAllSelected = () => { - return ( - selected.length === notifications?.length && notifications.length > 0 - ); - }; - - return ( - - - - - {type !== 'saved' && !notifications?.length && 'No notifications'} - {type !== 'saved' && !!notifications?.length && ( - { - if (isAllSelected()) { - setSelected([]); - } else { - setSelected( - notifications ? notifications.map(n => n.id) : [], - ); - } - }} - /> - )} - {type === 'saved' && - `${notifications?.length ?? 0} saved notifications`} - {selected.length === 0 && - !!notifications?.length && - type !== 'saved' && - 'Select all'} - {selected.length > 0 && `${selected.length} selected`} - {type === 'done' && selected.length > 0 && ( - - )} - - {type === 'undone' && selected.length > 0 && ( - - )} - - - - - {props.notifications?.map(notification => { + const compactColumns = React.useMemo( + (): TableColumn[] => [ + { + customFilterAndSearch: () => + true /* Keep it on backend due to pagination. If recent flickering is an issue, implement search here as well. */, + render: (notification: Notification) => { + // Compact content return ( - - - onCheckBoxClick(notification.id)} - /> - - - notificationsApi - .updateNotifications({ ids: [notification.id], read: true }) - .then(() => navigate(notification.payload.link)) - } - style={{ paddingLeft: 0 }} - > + <> + - {notification.payload.title} + {notification.payload.link ? ( + + {notification.payload.title} + + ) : ( + notification.payload.title + )} {notification.payload.description} - - - - - - - - - notificationsApi - .updateNotifications({ - ids: [notification.id], - read: true, - }) - .then(() => navigate(notification.payload.link)) - } - > - - - - - { - if (notification.read) { - notificationsApi - .updateNotifications({ - ids: [notification.id], - done: false, - }) - .then(() => { - props.onUpdate(); - }); - } else { - notificationsApi - .updateNotifications({ - ids: [notification.id], - done: true, - }) - .then(() => { - props.onUpdate(); - }); - } - }} - > - {notification.read ? ( - - ) : ( - - )} - - - - { - if (notification.saved) { - notificationsApi - .updateNotifications({ - ids: [notification.id], - saved: false, - }) - .then(() => { - props.onUpdate(); - }); - } else { - notificationsApi - .updateNotifications({ - ids: [notification.id], - saved: true, - }) - .then(() => { - props.onUpdate(); - }); - } - }} - > - {notification.saved ? ( - - ) : ( - - )} - - - - - + + {notification.origin && ( + <>{notification.origin} •  + )} + {notification.payload.topic && ( + <>{notification.payload.topic} •  + )} + {notification.created && ( + + )} + + + ); - })} - -
+ }, + }, + // { + // // TODO: additional action links + // width: '25%', + // render: (notification: Notification) => { + // return ( + // notification.payload.link && ( + // + // {/* TODO: render additionalLinks of different titles */} + // + // + //  More info + // + // + // + // ) + // ); + // }, + // }, + { + // TODO: action for saving notifications + // actions + width: '1rem', + render: (notification: Notification) => { + const markAsReadText = !!notification.read + ? 'Return among unread' + : 'Mark as read'; + const IconComponent = !!notification.read + ? MarkAsUnreadIcon + : MarkAsReadIcon; + + return ( + + { + onSwitchReadStatus(notification); + }} + > + + + + ); + }, + }, + ], + [onSwitchReadStatus], + ); + + // TODO: render "Saved notifications" as "Pinned" + return ( + + isLoading={isLoading} + options={{ + search: true, + // TODO: add pagination + // paging: true, + // pageSize, + header: false, + sorting: false, + }} + // onPageChange={setPageNumber} + // onRowsPerPageChange={setPageSize} + // page={offset} + // totalCount={value?.totalCount} + onSearchChange={throttledContainsTextHandler} + data={notifications} + columns={compactColumns} + /> ); }; diff --git a/yarn.lock b/yarn.lock index 5bfd5cac1a..2e603fba32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7714,6 +7714,7 @@ __metadata: "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 + lodash: ^4.17.21 msw: ^1.0.0 react-relative-time: ^0.0.9 react-use: ^17.2.4 From 9823e313c31d233c17c397f268cf3d84f506bf1e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 23 Feb 2024 17:19:50 +0100 Subject: [PATCH 168/176] backend-plugin-api: add support for limited user tokens Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.ts | 22 ++++++++ .../src/auth/createLegacyAuthAdapters.ts | 21 +++++++ packages/backend-plugin-api/api-report.md | 14 ++++- .../src/services/definitions/AuthService.ts | 11 +++- packages/backend-test-utils/api-report.md | 11 ++++ .../src/next/services/MockAuthService.test.ts | 44 +++++++++++++++ .../src/next/services/MockAuthService.ts | 46 ++++++++++++++- .../src/next/services/mockCredentials.test.ts | 32 +++++++++++ .../src/next/services/mockCredentials.ts | 56 +++++++++++++++++++ .../src/next/services/mockServices.ts | 1 + 10 files changed, 254 insertions(+), 4 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 19dfbe1299..4473acd94b 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -111,6 +111,7 @@ class DefaultAuthService implements AuthService { private readonly disableDefaultAuthPolicy: boolean, ) {} + // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { const { sub, aud } = decodeJwt(token); @@ -193,6 +194,27 @@ class DefaultAuthService implements AuthService { ); } } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + const internalCredentials = toInternalBackstageCredentials(credentials); + + const { token } = internalCredentials; + + if (!token) { + throw new AuthenticationError( + 'User credentials is unexpectedly missing token', + ); + } + + const { exp } = decodeJwt(token); + if (!exp) { + throw new AuthenticationError('User token is missing expiration'); + } + + return { token, expiresAt: new Date(exp * 1000) }; + } } /** @public */ diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index d12dd8b9fc..29fad9b579 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -123,6 +123,27 @@ class AuthCompat implements AuthService { ); } } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + const internalCredentials = toInternalBackstageCredentials(credentials); + + const { token } = internalCredentials; + + if (!token) { + throw new AuthenticationError( + 'User credentials is unexpectedly missing token', + ); + } + + const { exp } = decodeJwt(token); + if (!exp) { + throw new AuthenticationError('User token is missing expiration'); + } + + return { token, expiresAt: new Date(exp * 1000) }; + } } function getTokenFromRequest(req: Request) { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index cb44ba158d..ec63ea9062 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -24,7 +24,19 @@ import { Response as Response_2 } from 'express'; // @public (undocumented) export interface AuthService { // (undocumented) - authenticate(token: string): Promise; + authenticate( + token: string, + options?: { + allowLimitedAccess?: boolean; + }, + ): Promise; + // (undocumented) + getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ + token: string; + expiresAt: Date; + }>; // (undocumented) getOwnServiceCredentials(): Promise< BackstageCredentials diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index eab0a7fac7..391087cf2f 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -63,7 +63,12 @@ export type BackstagePrincipalTypes = { * @public */ export interface AuthService { - authenticate(token: string): Promise; + authenticate( + token: string, + options?: { + allowLimitedAccess?: boolean; + }, + ): Promise; isPrincipal( credentials: BackstageCredentials, @@ -78,4 +83,8 @@ export interface AuthService { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string }>; + + getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }>; } diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index d0e37a476d..4991164013 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -50,6 +50,17 @@ export function isDockerDisabledForTests(): boolean; // @public (undocumented) export namespace mockCredentials { + export function limitedUser( + userEntityRef?: string, + ): BackstageCredentials; + export namespace limitedUser { + export function header(userEntityRef?: string): string; + // (undocumented) + export function invalidHeader(): string; + // (undocumented) + export function invalidToken(): string; + export function token(userEntityRef?: string): string; + } export function none(): BackstageCredentials; export namespace none { export function header(): string; diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index dfb54b9762..81e56d592c 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -53,6 +53,12 @@ describe('MockAuthService', () => { auth.authenticate(mockCredentials.user.token()), ).resolves.toEqual(mockCredentials.user()); + await expect( + auth.authenticate(mockCredentials.user.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + await expect( auth.authenticate(mockCredentials.user.token()), ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF)); @@ -66,6 +72,44 @@ describe('MockAuthService', () => { ).rejects.toThrow('User token is invalid'); }); + it('should authenticate mock limited user tokens', async () => { + await expect( + auth.authenticate(mockCredentials.limitedUser.token()), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), {}), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: false, + }), + ).rejects.toThrow('Limited user token is not allowed'); + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + auth.authenticate(mockCredentials.limitedUser.token(), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF)); + + await expect( + auth.authenticate( + mockCredentials.limitedUser.token('user:default/other'), + { + allowLimitedAccess: true, + }, + ), + ).resolves.toEqual(mockCredentials.user('user:default/other')); + + await expect( + auth.authenticate(mockCredentials.limitedUser.invalidToken()), + ).rejects.toThrow('Limited user token is invalid'); + }); + it('should authenticate mock service tokens', async () => { await expect( auth.authenticate(mockCredentials.service.token()), diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 6a18711798..0d8945cd9d 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -27,9 +27,12 @@ import { mockCredentials, MOCK_USER_TOKEN, MOCK_USER_TOKEN_PREFIX, + MOCK_INVALID_USER_TOKEN, + MOCK_USER_LIMITED_TOKEN, + MOCK_USER_LIMITED_TOKEN_PREFIX, + MOCK_INVALID_USER_LIMITED_TOKEN, MOCK_SERVICE_TOKEN, MOCK_SERVICE_TOKEN_PREFIX, - MOCK_INVALID_USER_TOKEN, MOCK_INVALID_SERVICE_TOKEN, UserTokenPayload, ServiceTokenPayload, @@ -48,14 +51,24 @@ export class MockAuthService implements AuthService { this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy; } - async authenticate(token: string): Promise { + async authenticate( + token: string, + options?: { allowLimitedAccess?: boolean }, + ): Promise { switch (token) { case MOCK_USER_TOKEN: return mockCredentials.user(); + case MOCK_USER_LIMITED_TOKEN: + if (!options?.allowLimitedAccess) { + throw new AuthenticationError('Limited user token is not allowed'); + } + return mockCredentials.user(); case MOCK_SERVICE_TOKEN: return mockCredentials.service(); case MOCK_INVALID_USER_TOKEN: throw new AuthenticationError('User token is invalid'); + case MOCK_INVALID_USER_LIMITED_TOKEN: + throw new AuthenticationError('Limited user token is invalid'); case MOCK_INVALID_SERVICE_TOKEN: throw new AuthenticationError('Service token is invalid'); case '': @@ -72,6 +85,18 @@ export class MockAuthService implements AuthService { return mockCredentials.user(userEntityRef); } + if (token.startsWith(MOCK_USER_LIMITED_TOKEN_PREFIX)) { + if (!options?.allowLimitedAccess) { + throw new AuthenticationError('Limited user token is not allowed'); + } + + const { sub: userEntityRef }: UserTokenPayload = JSON.parse( + token.slice(MOCK_USER_LIMITED_TOKEN_PREFIX.length), + ); + + return mockCredentials.user(userEntityRef); + } + if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) { const { sub, target, obo }: ServiceTokenPayload = JSON.parse( token.slice(MOCK_SERVICE_TOKEN_PREFIX.length), @@ -144,4 +169,21 @@ export class MockAuthService implements AuthService { }), }; } + + async getLimitedUserToken( + credentials: BackstageCredentials, + ): Promise<{ token: string; expiresAt: Date }> { + if (credentials.principal.type !== 'user') { + throw new AuthenticationError( + `Refused to issue limited user token for credential type '${credentials.principal.type}'`, + ); + } + + return { + token: mockCredentials.limitedUser.token( + credentials.principal.userEntityRef, + ), + expiresAt: new Date(Date.now() + 3600), + }; + } } diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index 67cda92be8..3d72f362cd 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -36,6 +36,18 @@ describe('mockCredentials', () => { }); }); + it('creates a mocked credentials object for a limited user principal', () => { + expect(mockCredentials.limitedUser()).toEqual({ + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/mock' }, + }); + + expect(mockCredentials.limitedUser('user:default/other')).toEqual({ + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/other' }, + }); + }); + it('creates a mocked credentials object for a service principal', () => { expect(mockCredentials.service()).toEqual({ $$type: '@backstage/BackstageCredentials', @@ -68,6 +80,26 @@ describe('mockCredentials', () => { ); }); + it('creates limited user tokens and headers', () => { + expect(mockCredentials.limitedUser.token()).toBe('mock-limited-user-token'); + expect(mockCredentials.limitedUser.token('user:default/other')).toBe( + 'mock-limited-user-token:{"sub":"user:default/other"}', + ); + expect(mockCredentials.limitedUser.invalidToken()).toBe( + 'mock-invalid-limited-user-token', + ); + + expect(mockCredentials.limitedUser.header()).toBe( + 'Bearer mock-limited-user-token', + ); + expect(mockCredentials.limitedUser.header('user:default/other')).toBe( + 'Bearer mock-limited-user-token:{"sub":"user:default/other"}', + ); + expect(mockCredentials.limitedUser.invalidHeader()).toBe( + 'Bearer mock-invalid-limited-user-token', + ); + }); + it('creates service tokens and headers', () => { expect(mockCredentials.service.token()).toBe('mock-service-token'); expect( diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 8dac9c1a4b..72ffbc7af2 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -25,9 +25,16 @@ export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock'; export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service'; export const MOCK_NONE_TOKEN = 'mock-none-token'; + export const MOCK_USER_TOKEN = 'mock-user-token'; export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:'; export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token'; + +export const MOCK_USER_LIMITED_TOKEN = 'mock-limited-user-token'; +export const MOCK_USER_LIMITED_TOKEN_PREFIX = 'mock-limited-user-token:'; +export const MOCK_INVALID_USER_LIMITED_TOKEN = + 'mock-invalid-limited-user-token'; + export const MOCK_SERVICE_TOKEN = 'mock-service-token'; export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:'; export const MOCK_INVALID_SERVICE_TOKEN = 'mock-invalid-service-token'; @@ -143,6 +150,55 @@ export namespace mockCredentials { } } + /** + * Creates a mocked credentials object for a user principal with limited + * access. + * + * The default user entity reference is 'user:default/mock'. + */ + export function limitedUser( + userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF, + ): BackstageCredentials { + return user(userEntityRef); + } + + /** + * Utilities related to limited user credentials. + */ + export namespace limitedUser { + /** + * Creates a mocked limited user token. If a payload is provided it will be + * encoded into the token and forwarded to the credentials object when + * authenticated by the mock auth service. + */ + export function token(userEntityRef?: string): string { + if (userEntityRef) { + validateUserEntityRef(userEntityRef); + return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({ + sub: userEntityRef, + } satisfies UserTokenPayload)}`; + } + return MOCK_USER_LIMITED_TOKEN; + } + + /** + * Returns an authorization header with a mocked limited user token. If a + * payload is provided it will be encoded into the token and forwarded to + * the credentials object when authenticated by the mock auth service. + */ + export function header(userEntityRef?: string): string { + return `Bearer ${token(userEntityRef)}`; + } + + export function invalidToken(): string { + return MOCK_INVALID_USER_LIMITED_TOKEN; + } + + export function invalidHeader(): string { + return `Bearer ${invalidToken()}`; + } + } + /** * Creates a mocked credentials object for a service principal. * diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 7300b34105..f6d2d39167 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -205,6 +205,7 @@ export namespace mockServices { getOwnServiceCredentials: jest.fn(), isPrincipal: jest.fn() as any, getPluginRequestToken: jest.fn(), + getLimitedUserToken: jest.fn(), })); } From 982fc43d68d7122c65d816f9d2486b2b46238502 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 25 Feb 2024 12:09:51 +0100 Subject: [PATCH 169/176] backend-plugin-api: add AuthService.getNoneCredentials Signed-off-by: Patrik Oldsberg --- .../services/implementations/auth/authServiceFactory.ts | 6 ++++++ .../backend-common/src/auth/createLegacyAuthAdapters.ts | 7 +++++++ .../src/services/definitions/AuthService.ts | 2 ++ .../src/next/services/MockAuthService.test.ts | 6 ++++++ .../src/next/services/MockAuthService.ts | 4 ++++ 5 files changed, 25 insertions(+) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 4473acd94b..93a889654a 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -157,6 +157,12 @@ class DefaultAuthService implements AuthService { return true; } + async getNoneCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithNonePrincipal(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 29fad9b579..fd44ca5b91 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -17,6 +17,7 @@ import { AuthService, BackstageCredentials, + BackstageNonePrincipal, BackstagePrincipalTypes, BackstageServicePrincipal, BackstageUserInfo, @@ -65,6 +66,12 @@ class AuthCompat implements AuthService { return true; } + async getNoneCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithNonePrincipal(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 391087cf2f..f9ff7edadc 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -75,6 +75,8 @@ export interface AuthService { type: TType, ): credentials is BackstageCredentials; + getNoneCredentials(): Promise>; + getOwnServiceCredentials(): Promise< BackstageCredentials >; diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 81e56d592c..4343027aea 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -157,6 +157,12 @@ describe('MockAuthService', () => { ).rejects.toThrow('Service token is invalid'); }); + it('should return none credentials', async () => { + await expect(auth.getNoneCredentials()).resolves.toEqual( + mockCredentials.none(), + ); + }); + it('should return own service credentials', async () => { await expect(auth.getOwnServiceCredentials()).resolves.toEqual( mockCredentials.service('plugin:test'), diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 0d8945cd9d..4977c4df0e 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -117,6 +117,10 @@ export class MockAuthService implements AuthService { throw new AuthenticationError(`Unknown mock token '${token}'`); } + async getNoneCredentials() { + return mockCredentials.none(); + } + async getOwnServiceCredentials(): Promise< BackstageCredentials > { From e2108005452c44bc464499ee57dca65d78874b2e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 01:25:27 +0100 Subject: [PATCH 170/176] backend-test-utils: update mockCredentials for cookie auth Signed-off-by: Patrik Oldsberg --- .../src/next/services/mockCredentials.test.ts | 12 +++------ .../src/next/services/mockCredentials.ts | 26 +++++++++---------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index 3d72f362cd..ed0071d4b8 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -81,7 +81,6 @@ describe('mockCredentials', () => { }); it('creates limited user tokens and headers', () => { - expect(mockCredentials.limitedUser.token()).toBe('mock-limited-user-token'); expect(mockCredentials.limitedUser.token('user:default/other')).toBe( 'mock-limited-user-token:{"sub":"user:default/other"}', ); @@ -89,14 +88,11 @@ describe('mockCredentials', () => { 'mock-invalid-limited-user-token', ); - expect(mockCredentials.limitedUser.header()).toBe( - 'Bearer mock-limited-user-token', + expect(mockCredentials.limitedUser.cookie('user:default/other')).toBe( + 'backstage-auth=mock-limited-user-token:{"sub":"user:default/other"}', ); - expect(mockCredentials.limitedUser.header('user:default/other')).toBe( - 'Bearer mock-limited-user-token:{"sub":"user:default/other"}', - ); - expect(mockCredentials.limitedUser.invalidHeader()).toBe( - 'Bearer mock-invalid-limited-user-token', + expect(mockCredentials.limitedUser.invalidCookie()).toBe( + 'backstage-auth=mock-invalid-limited-user-token', ); }); diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 72ffbc7af2..16d2381c73 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -24,13 +24,14 @@ import { export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock'; export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service'; +export const MOCK_AUTH_COOKIE = 'backstage-auth'; + export const MOCK_NONE_TOKEN = 'mock-none-token'; export const MOCK_USER_TOKEN = 'mock-user-token'; export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:'; export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token'; -export const MOCK_USER_LIMITED_TOKEN = 'mock-limited-user-token'; export const MOCK_USER_LIMITED_TOKEN_PREFIX = 'mock-limited-user-token:'; export const MOCK_INVALID_USER_LIMITED_TOKEN = 'mock-invalid-limited-user-token'; @@ -171,14 +172,13 @@ export namespace mockCredentials { * encoded into the token and forwarded to the credentials object when * authenticated by the mock auth service. */ - export function token(userEntityRef?: string): string { - if (userEntityRef) { - validateUserEntityRef(userEntityRef); - return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({ - sub: userEntityRef, - } satisfies UserTokenPayload)}`; - } - return MOCK_USER_LIMITED_TOKEN; + export function token( + userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF, + ): string { + validateUserEntityRef(userEntityRef); + return `${MOCK_USER_LIMITED_TOKEN_PREFIX}${JSON.stringify({ + sub: userEntityRef, + } satisfies UserTokenPayload)}`; } /** @@ -186,16 +186,16 @@ export namespace mockCredentials { * payload is provided it will be encoded into the token and forwarded to * the credentials object when authenticated by the mock auth service. */ - export function header(userEntityRef?: string): string { - return `Bearer ${token(userEntityRef)}`; + export function cookie(userEntityRef?: string): string { + return `${MOCK_AUTH_COOKIE}=${token(userEntityRef)}`; } export function invalidToken(): string { return MOCK_INVALID_USER_LIMITED_TOKEN; } - export function invalidHeader(): string { - return `Bearer ${invalidToken()}`; + export function invalidCookie(): string { + return `${MOCK_AUTH_COOKIE}=${invalidToken()}`; } } From d455112cbf59f680202c7dc8d4354da1ff85e379 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Feb 2024 01:28:27 +0100 Subject: [PATCH 171/176] backend-plugin-api: updated cookie auth implementation Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.ts | 15 +- .../httpAuth/httpAuthServiceFactory.ts | 197 ++++++++++++------ .../createCredentialsBarrier.test.ts | 59 +++++- .../httpRouter/createCredentialsBarrier.ts | 2 +- .../src/auth/createLegacyAuthAdapters.ts | 67 +++--- packages/backend-plugin-api/api-report.md | 14 +- .../src/services/definitions/AuthService.ts | 2 + .../services/definitions/HttpAuthService.ts | 15 +- packages/backend-test-utils/api-report.md | 4 +- packages/backend-test-utils/package.json | 51 ++--- .../src/next/services/MockAuthService.test.ts | 28 +++ .../src/next/services/MockAuthService.ts | 6 - .../next/services/MockHttpAuthService.test.ts | 99 ++++++++- .../src/next/services/MockHttpAuthService.ts | 71 +++++-- .../src/next/services/mockServices.ts | 1 + yarn.lock | 1 + 16 files changed, 474 insertions(+), 158 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 93a889654a..78ff97a139 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -35,7 +35,6 @@ export type InternalBackstageCredentials = BackstageCredentials & { version: string; token?: string; - authMethod: 'token' | 'cookie' | 'none'; }; export function createCredentialsWithServicePrincipal( @@ -48,24 +47,23 @@ export function createCredentialsWithServicePrincipal( type: 'service', subject: sub, }, - authMethod: 'token', }; } export function createCredentialsWithUserPrincipal( sub: string, token: string, - authMethod: 'token' | 'cookie' = 'token', + expiresAt?: Date, ): InternalBackstageCredentials { return { $$type: '@backstage/BackstageCredentials', version: 'v1', token, + expiresAt, principal: { type: 'user', userEntityRef: sub, }, - authMethod, }; } @@ -76,7 +74,6 @@ export function createCredentialsWithNonePrincipal(): InternalBackstageCredentia principal: { type: 'none', }, - authMethod: 'none', }; } @@ -135,6 +132,7 @@ class DefaultAuthService implements AuthService { return createCredentialsWithUserPrincipal( identity.identity.userEntityRef, token, + this.#getJwtExpiration(token), ); } @@ -214,12 +212,15 @@ class DefaultAuthService implements AuthService { ); } + return { token, expiresAt: this.#getJwtExpiration(token) }; + } + + #getJwtExpiration(token: string) { const { exp } = decodeJwt(token); if (!exp) { throw new AuthenticationError('User token is missing expiration'); } - - return { token, expiresAt: new Date(exp * 1000) }; + return new Date(exp * 1000); } } diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 1bd9d3cf2a..c261553685 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -18,6 +18,7 @@ import { AuthService, BackstageCredentials, BackstagePrincipalTypes, + BackstageUserPrincipal, DiscoveryService, HttpAuthService, coreServices, @@ -26,11 +27,8 @@ import { import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { parse as parseCookie } from 'cookie'; import { Request, Response } from 'express'; -import { decodeJwt } from 'jose'; -import { - createCredentialsWithNonePrincipal, - toInternalBackstageCredentials, -} from '../auth/authServiceFactory'; + +const FIVE_MINUTES_MS = 5 * 60 * 1000; const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; @@ -41,54 +39,74 @@ function getTokenFromRequest(req: Request) { const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); const token = matches?.[1]; if (token) { - return { token, isCookie: false }; + return token; } } + return undefined; +} + +function getCookieFromRequest(req: Request) { const cookieHeader = req.headers.cookie; if (cookieHeader) { const cookies = parseCookie(cookieHeader); const token = cookies[BACKSTAGE_AUTH_COOKIE]; if (token) { - return { token, isCookie: true }; + return token; } } - return { token: undefined, isCookie: false }; + return undefined; } const credentialsSymbol = Symbol('backstage-credentials'); +const limitedCredentialsSymbol = Symbol('backstage-limited-credentials'); type RequestWithCredentials = Request & { [credentialsSymbol]?: Promise; + [limitedCredentialsSymbol]?: Promise; }; class DefaultHttpAuthService implements HttpAuthService { + readonly #auth: AuthService; + readonly #discovery: DiscoveryService; + readonly #pluginId: string; + constructor( - private readonly auth: AuthService, - private readonly discovery: DiscoveryService, - private readonly pluginId: string, - ) {} + auth: AuthService, + discovery: DiscoveryService, + pluginId: string, + ) { + this.#auth = auth; + this.#discovery = discovery; + this.#pluginId = pluginId; + } async #extractCredentialsFromRequest(req: Request) { - const { token, isCookie } = getTokenFromRequest(req); + const token = getTokenFromRequest(req); if (!token) { - return createCredentialsWithNonePrincipal(); + return await this.#auth.getNoneCredentials(); } - const credentials = toInternalBackstageCredentials( - await this.auth.authenticate(token), - ); - if (isCookie) { - if (credentials.principal.type !== 'user') { - throw new AuthenticationError( - 'Refusing to authenticate non-user principal with cookie auth', - ); - } - credentials.authMethod = 'cookie'; + return await this.#auth.authenticate(token); + } + + async #extractLimitedCredentialsFromRequest(req: Request) { + const token = getTokenFromRequest(req); + if (token) { + return await this.#auth.authenticate(token, { + allowLimitedAccess: true, + }); } - return credentials; + const cookie = getCookieFromRequest(req); + if (!cookie) { + return await this.#auth.getNoneCredentials(); + } + + return await this.#auth.authenticate(cookie, { + allowLimitedAccess: true, + }); } async #getCredentials(req: RequestWithCredentials) { @@ -96,73 +114,130 @@ class DefaultHttpAuthService implements HttpAuthService { this.#extractCredentialsFromRequest(req)); } + async #getLimitedCredentials(req: RequestWithCredentials) { + return (req[limitedCredentialsSymbol] ??= + this.#extractLimitedCredentialsFromRequest(req)); + } + async credentials( req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { - const credentials = toInternalBackstageCredentials( - await this.#getCredentials(req), - ); + // Limited and full credentials are treated as two separate cases, this lets + // us avoid internal dependencies between the AuthService and + // HttpAuthService implementations + const credentials = options?.allowLimitedAccess + ? await this.#getLimitedCredentials(req) + : await this.#getCredentials(req); - const allowedPrincipalTypes = options?.allow; - const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> = - options?.allowedAuthMethods ?? ['token']; - - if ( - credentials.authMethod !== 'none' && - !allowedAuthMethods.includes(credentials.authMethod) - ) { - throw new NotAllowedError( - `This endpoint does not allow the '${credentials.authMethod}' auth method`, - ); + const allowed = options?.allow; + if (!allowed) { + return credentials as any; } - if ( - allowedPrincipalTypes && - !allowedPrincipalTypes.includes(credentials.principal.type as TAllowed) - ) { - if (credentials.authMethod === 'none') { - throw new AuthenticationError(); + if (this.#auth.isPrincipal(credentials, 'none')) { + if (allowed.includes('none' as TAllowed)) { + return credentials as any; } + + throw new AuthenticationError('Missing credentials'); + } else if (this.#auth.isPrincipal(credentials, 'user')) { + if (allowed.includes('user' as TAllowed)) { + return credentials as any; + } + throw new NotAllowedError( - `This endpoint does not allow '${credentials.principal.type}' credentials`, + `This endpoint does not allow 'user' credentials`, + ); + } else if (this.#auth.isPrincipal(credentials, 'service')) { + if (allowed.includes('service' as TAllowed)) { + return credentials as any; + } + + throw new NotAllowedError( + `This endpoint does not allow 'service' credentials`, ); } - return credentials as any; + throw new NotAllowedError( + 'Unknown principal type, this should never happen', + ); } - async issueUserCookie(res: Response): Promise { - const credentials = await this.credentials(res.req, { allow: ['user'] }); + async issueUserCookie( + res: Response, + options?: { credentials?: BackstageCredentials }, + ): Promise<{ expiresAt: Date }> { + let credentials: BackstageCredentials; + if (options?.credentials) { + if (!this.#auth.isPrincipal(options.credentials, 'user')) { + throw new AuthenticationError( + 'Refused to issue cookie for non-user principal', + ); + } + credentials = options.credentials; + } else { + credentials = await this.credentials(res.req, { allow: ['user'] }); + } + + const existingExpiresAt = await this.#existingCookieExpiration(res.req); + if ( + existingExpiresAt && + existingExpiresAt.getTime() < Date.now() - FIVE_MINUTES_MS + ) { + return { expiresAt: existingExpiresAt }; + } + + const originHeader = res.req.headers.origin; + const origin = + !originHeader || originHeader === 'null' ? undefined : originHeader; // https://backstage.example.com/api/catalog - const externalBaseUrlStr = await this.discovery.getExternalBaseUrl( - this.pluginId, + const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl( + this.#pluginId, ); - const externalBaseUrl = new URL(externalBaseUrlStr); + const externalBaseUrl = new URL(origin ?? externalBaseUrlStr); - const { token } = toInternalBackstageCredentials(credentials); + const { token, expiresAt } = await this.#auth.getLimitedUserToken( + credentials, + ); if (!token) { throw new Error('User credentials is unexpectedly missing token'); } - // TODO: Proper refresh and expiration handling - const expires = decodeJwt(token).exp!; + const secure = + externalBaseUrl.protocol === 'https:' || + externalBaseUrl.hostname === 'localhost'; - // TODO: refresh this thing res.cookie(BACKSTAGE_AUTH_COOKIE, token, { domain: externalBaseUrl.hostname, httpOnly: true, - expires: new Date(expires * 1000), - path: externalBaseUrl.pathname, + expires: expiresAt, + secure, priority: 'high', - sameSite: 'lax', // TBD + sameSite: secure ? 'none' : 'lax', }); - throw new Error('Method not implemented.'); + return { expiresAt }; + } + + async #existingCookieExpiration(req: Request): Promise { + const existingCookie = getCookieFromRequest(req); + if (!existingCookie) { + return undefined; + } + + const existingCredentials = await this.#auth.authenticate(existingCookie, { + allowLimitedAccess: true, + }); + if (!this.#auth.isPrincipal(existingCredentials, 'user')) { + return undefined; + } + + return existingCredentials.expiresAt; } } diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts index b8430d398e..a5246c91b2 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.test.ts @@ -53,7 +53,10 @@ describe('createCredentialsBarrier', () => { .expect(401) .expect(res => expect(res.body).toMatchObject({ - error: { name: 'AuthenticationError', message: '' }, + error: { + name: 'AuthenticationError', + message: 'Missing credentials', + }, }), ); @@ -98,7 +101,7 @@ describe('createCredentialsBarrier', () => { .expect(200); }); - it('should allow exceptions to the default auth policy to be made', async () => { + it('should allow exceptions for unauthenticated access', async () => { const { app, barrier } = setup(); await request(app).get('/').send().expect(401); @@ -118,5 +121,55 @@ describe('createCredentialsBarrier', () => { await request(app).get('/other').send().expect(200); }); - // TODO: cookie auth + it('should allow exceptions for cookie access', async () => { + const { app, barrier } = setup(); + + await request(app).get('/').send().expect(401); + await request(app).get('/public').send().expect(401); + await request(app).get('/other').send().expect(401); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(401); + await request(app) + .get('/static') + .set('authorization', mockCredentials.user.header()) + .send() + .expect(200); + + barrier.addAuthPolicy({ allow: 'user-cookie', path: '/static' }); + + await request(app).get('/').send().expect(401); + await request(app).get('/static').send().expect(401); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(200); + await request(app) + .get('/static') + .set('authorization', mockCredentials.user.header()) + .send() + .expect(200); + + await request(app).get('/other').send().expect(401); + + // Unauthenticated access should take precedence + barrier.addAuthPolicy({ allow: 'unauthenticated', path: '/' }); + + await request(app).get('/').send().expect(200); + await request(app).get('/static').send().expect(200); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.cookie()) + .send() + .expect(200); + await request(app) + .get('/static') + .set('cookie', mockCredentials.limitedUser.invalidCookie()) + .send() + .expect(200); + await request(app).get('/other').send().expect(200); + }); }); diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts index a512c5ac9e..a69fa5a804 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -76,7 +76,7 @@ export function createCredentialsBarrier(options: { httpAuth .credentials(req, { allow: ['user', 'service'], - allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'], + allowLimitedAccess: allowsCookie, }) .then( () => next(), diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index fd44ca5b91..a46d553c5d 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -96,6 +96,7 @@ class AuthCompat implements AuthService { return createCredentialsWithUserPrincipal( identity.identity.userEntityRef, token, + this.#getJwtExpiration(token), ); } @@ -144,12 +145,15 @@ class AuthCompat implements AuthService { ); } + return { token, expiresAt: this.#getJwtExpiration(token) }; + } + + #getJwtExpiration(token: string) { const { exp } = decodeJwt(token); if (!exp) { throw new AuthenticationError('User token is missing expiration'); } - - return { token, expiresAt: new Date(exp * 1000) }; + return new Date(exp * 1000); } } @@ -174,7 +178,11 @@ type RequestWithCredentials = Request & { }; class HttpAuthCompat implements HttpAuthService { - constructor(private readonly auth: AuthService) {} + #auth: AuthService; + + constructor(auth: AuthService) { + this.#auth = auth; + } async #extractCredentialsFromRequest(req: Request) { const token = getTokenFromRequest(req); @@ -183,7 +191,7 @@ class HttpAuthCompat implements HttpAuthService { } const credentials = toInternalBackstageCredentials( - await this.auth.authenticate(token), + await this.#auth.authenticate(token), ); return credentials; @@ -198,39 +206,50 @@ class HttpAuthCompat implements HttpAuthService { req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { const credentials = toInternalBackstageCredentials( await this.#getCredentials(req), ); - const allowedPrincipalTypes = options?.allow; - const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> = - options?.allowedAuthMethods ?? ['token']; + const allowed = options?.allow; + if (!allowed) { + return credentials as any; + } + + if (this.#auth.isPrincipal(credentials, 'none')) { + if (allowed.includes('none' as TAllowed)) { + return credentials as any; + } + + throw new AuthenticationError('Missing credentials'); + } else if (this.#auth.isPrincipal(credentials, 'user')) { + if (allowed.includes('user' as TAllowed)) { + return credentials as any; + } - if ( - credentials.authMethod !== 'none' && - !allowedAuthMethods.includes(credentials.authMethod) - ) { throw new NotAllowedError( - `This endpoint does not allow the '${credentials.authMethod}' auth method`, + `This endpoint does not allow 'user' credentials`, + ); + } else if (this.#auth.isPrincipal(credentials, 'service')) { + if (allowed.includes('service' as TAllowed)) { + return credentials as any; + } + + throw new NotAllowedError( + `This endpoint does not allow 'service' credentials`, ); } - if ( - allowedPrincipalTypes && - !allowedPrincipalTypes.includes(credentials.principal.type as TAllowed) - ) { - throw new NotAllowedError( - `This endpoint does not allow '${credentials.principal.type}' credentials`, - ); - } - - return credentials as any; + throw new NotAllowedError( + 'Unknown principal type, this should never happen', + ); } - async issueUserCookie(_res: Response): Promise {} + async issueUserCookie(_res: Response): Promise<{ expiresAt: Date }> { + return { expiresAt: new Date(Date.now() + 3600_000) }; + } } export class UserInfoCompat implements UserInfoService { diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index ec63ea9062..73c1d15cd1 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -38,6 +38,8 @@ export interface AuthService { expiresAt: Date; }>; // (undocumented) + getNoneCredentials(): Promise>; + // (undocumented) getOwnServiceCredentials(): Promise< BackstageCredentials >; @@ -119,6 +121,7 @@ export interface BackendPluginRegistrationPoints { // @public (undocumented) export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + expiresAt?: Date; principal: TPrincipal; }; @@ -311,11 +314,18 @@ export interface HttpAuthService { req: Request_2, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise>; // (undocumented) - issueUserCookie(res: Response_2): Promise; + issueUserCookie( + res: Response_2, + options?: { + credentials?: BackstageCredentials; + }, + ): Promise<{ + expiresAt: Date; + }>; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index f9ff7edadc..2bcdc975a0 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -46,6 +46,8 @@ export type BackstageServicePrincipal = { export type BackstageCredentials = { $$type: '@backstage/BackstageCredentials'; + expiresAt?: Date; + principal: TPrincipal; }; diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 44696bd777..637109d1f0 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -15,7 +15,11 @@ */ import { Request, Response } from 'express'; -import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService'; +import { + BackstageCredentials, + BackstagePrincipalTypes, + BackstageUserPrincipal, +} from './AuthService'; /** @public */ export interface HttpAuthService { @@ -23,9 +27,14 @@ export interface HttpAuthService { req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise>; - issueUserCookie(res: Response): Promise; + issueUserCookie( + res: Response, + options?: { + credentials?: BackstageCredentials; + }, + ): Promise<{ expiresAt: Date }>; } diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 4991164013..ff3f50ef8c 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -54,9 +54,9 @@ export namespace mockCredentials { userEntityRef?: string, ): BackstageCredentials; export namespace limitedUser { - export function header(userEntityRef?: string): string; + export function cookie(userEntityRef?: string): string; // (undocumented) - export function invalidHeader(): string; + export function invalidCookie(): string; // (undocumented) export function invalidToken(): string; export function token(userEntityRef?: string): string; diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index e2fc9dd3e6..801b7c125b 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,16 +1,30 @@ { "name": "@backstage/backend-test-utils", - "description": "Test helpers library for Backstage backends", "version": "0.3.0", - "main": "src/index.ts", - "types": "src/index.ts", + "description": "Test helpers library for Backstage backends", + "backstage": { + "role": "node-library" + }, "publishConfig": { "access": "public" }, + "keywords": [ + "backstage", + "test" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-test-utils" + }, + "license": "Apache-2.0", "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "main": "src/index.ts", + "types": "src/index.ts", "typesVersions": { "*": { "package.json": [ @@ -18,28 +32,17 @@ ] } }, - "backstage": { - "role": "node-library" - }, - "homepage": "https://backstage.io", - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "packages/backend-test-utils" - }, - "keywords": [ - "backstage", - "test" + "files": [ + "dist" ], - "license": "Apache-2.0", "scripts": { "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "clean": "backstage-cli package clean", - "start": "backstage-cli package start" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-app-api": "workspace:^", @@ -50,6 +53,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/types": "workspace:^", "better-sqlite3": "^9.0.0", + "cookie": "^0.6.0", "express": "^4.17.1", "fs-extra": "^11.0.0", "knex": "^3.0.0", @@ -60,15 +64,12 @@ "textextensions": "^5.16.0", "uuid": "^9.0.0" }, - "peerDependencies": { - "@types/jest": "*" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, - "files": [ - "dist" - ] + "peerDependencies": { + "@types/jest": "*" + } } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 4343027aea..8741220a68 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -261,4 +261,32 @@ describe('MockAuthService', () => { `Refused to issue service token for credential type 'none'`, ); }); + + it('should issue limited user tokens', async () => { + await expect( + auth.getLimitedUserToken(mockCredentials.user()), + ).resolves.toEqual({ + token: mockCredentials.limitedUser.token(), + expiresAt: expect.any(Date), + }); + + await expect( + auth.getLimitedUserToken(mockCredentials.user('user:default/other')), + ).resolves.toEqual({ + token: mockCredentials.limitedUser.token('user:default/other'), + expiresAt: expect.any(Date), + }); + + await expect( + auth.getLimitedUserToken(mockCredentials.none() as any), + ).rejects.toThrow( + "Refused to issue limited user token for credential type 'none'", + ); + + await expect( + auth.getLimitedUserToken(mockCredentials.service() as any), + ).rejects.toThrow( + "Refused to issue limited user token for credential type 'service'", + ); + }); }); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 4977c4df0e..c0e6461245 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -28,7 +28,6 @@ import { MOCK_USER_TOKEN, MOCK_USER_TOKEN_PREFIX, MOCK_INVALID_USER_TOKEN, - MOCK_USER_LIMITED_TOKEN, MOCK_USER_LIMITED_TOKEN_PREFIX, MOCK_INVALID_USER_LIMITED_TOKEN, MOCK_SERVICE_TOKEN, @@ -58,11 +57,6 @@ export class MockAuthService implements AuthService { switch (token) { case MOCK_USER_TOKEN: return mockCredentials.user(); - case MOCK_USER_LIMITED_TOKEN: - if (!options?.allowLimitedAccess) { - throw new AuthenticationError('Limited user token is not allowed'); - } - return mockCredentials.user(); case MOCK_SERVICE_TOKEN: return mockCredentials.service(); case MOCK_INVALID_USER_TOKEN: diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts index 922b3daaf4..b43f448b44 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -22,8 +22,11 @@ import { AuthenticationError } from '@backstage/errors'; describe('MockHttpAuthService', () => { const httpAuth = new MockHttpAuthService('test', mockCredentials.none()); - function makeAuthReq(header?: string) { - return { headers: { authorization: header } } as Request; + function makeAuthReq(authorization?: string) { + return { headers: { authorization } } as Request; + } + function makeCookieAuthReq(cookie?: string) { + return { headers: { cookie } } as Request; } it('should authenticate unauthenticated requests', async () => { @@ -68,6 +71,59 @@ describe('MockHttpAuthService', () => { ).resolves.toEqual(mockCredentials.user('user:default/other')); }); + it('should authenticate limited user requests', async () => { + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + ), + ).resolves.toEqual(mockCredentials.none()); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { allowLimitedAccess: true }, + ), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), { + allowLimitedAccess: true, + }), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { + allow: ['user'], + }, + ), + ).rejects.toThrow('Missing credentials'); + + await expect( + httpAuth.credentials( + makeCookieAuthReq(mockCredentials.limitedUser.cookie()), + { + allow: ['none', 'service'], + allowLimitedAccess: true, + }, + ), + ).rejects.toThrow("This endpoint does not allow 'user' credentials"); + + await expect( + httpAuth.credentials( + makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`), + { allowLimitedAccess: true }, + ), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials( + makeAuthReq(`Bearer ${mockCredentials.limitedUser.token()}`), + ), + ).rejects.toThrow('Limited user token is not allowed'); + }); + it('should authenticate service requests', async () => { await expect( httpAuth.credentials(makeAuthReq(mockCredentials.service.header())), @@ -161,9 +217,42 @@ describe('MockHttpAuthService', () => { ).rejects.toThrow('Service token is invalid'); }); - it('does not implement .issueUserCookie', async () => { - await expect(httpAuth.issueUserCookie({} as any)).rejects.toThrow( - 'Not implemented', + it('should issue user cookie from request credentials', async () => { + const setHeader = jest.fn(); + + await expect( + httpAuth.issueUserCookie({ + req: makeAuthReq(mockCredentials.user.header()), + setHeader, + } as any), + ).resolves.toEqual({ + expiresAt: expect.any(Date), + }); + + expect(setHeader).toHaveBeenCalledWith( + 'Set-Cookie', + mockCredentials.limitedUser.cookie(), + ); + }); + + it('should issue user cookie from explicit credentials', async () => { + const setHeader = jest.fn(); + + await expect( + httpAuth.issueUserCookie( + { + req: makeAuthReq(mockCredentials.user.header()), + setHeader, + } as any, + { credentials: mockCredentials.user('user:default/other') }, + ), + ).resolves.toEqual({ + expiresAt: expect.any(Date), + }); + + expect(setHeader).toHaveBeenCalledWith( + 'Set-Cookie', + mockCredentials.limitedUser.cookie('user:default/other'), ); }); }); diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 9b133f996b..9a69620473 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -18,16 +18,18 @@ import { AuthService, BackstageCredentials, BackstagePrincipalTypes, + BackstageUserPrincipal, HttpAuthService, } from '@backstage/backend-plugin-api'; import { Request, Response } from 'express'; +import { parse as parseCookie } from 'cookie'; import { MockAuthService } from './MockAuthService'; +import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { - AuthenticationError, - NotAllowedError, - NotImplementedError, -} from '@backstage/errors'; -import { mockCredentials } from './mockCredentials'; + MOCK_NONE_TOKEN, + MOCK_AUTH_COOKIE, + mockCredentials, +} from './mockCredentials'; // TODO: support mock cookie auth? export class MockHttpAuthService implements HttpAuthService { @@ -42,33 +44,52 @@ export class MockHttpAuthService implements HttpAuthService { this.#defaultCredentials = defaultCredentials; } - async #getCredentials(req: Request) { + async #getCredentials(req: Request, allowLimitedAccess: boolean) { const header = req.headers.authorization; - - if (header === mockCredentials.none.header()) { - return mockCredentials.none(); - } - const token = typeof header === 'string' ? header.match(/^Bearer[ ]+(\S+)$/i)?.[1] : undefined; - if (!token) { - return this.#defaultCredentials; + if (token) { + if (token === MOCK_NONE_TOKEN) { + return this.#auth.getNoneCredentials(); + } + + return await this.#auth.authenticate(token, { + allowLimitedAccess, + }); } - return await this.#auth.authenticate(token); + if (allowLimitedAccess) { + const cookieHeader = req.headers.cookie; + + if (cookieHeader) { + const cookies = parseCookie(cookieHeader); + const cookie = cookies[MOCK_AUTH_COOKIE]; + + if (cookie) { + return await this.#auth.authenticate(cookie, { + allowLimitedAccess: true, + }); + } + } + } + + return this.#defaultCredentials; } async credentials( req: Request, options?: { allow?: Array; - allowedAuthMethods?: Array<'token' | 'cookie'>; + allowLimitedAccess?: boolean; }, ): Promise> { - const credentials = await this.#getCredentials(req); + const credentials = await this.#getCredentials( + req, + options?.allowLimitedAccess ?? false, + ); const allowedPrincipalTypes = options?.allow; if (!allowedPrincipalTypes) { @@ -80,7 +101,7 @@ export class MockHttpAuthService implements HttpAuthService { return credentials as any; } - throw new AuthenticationError(); + throw new AuthenticationError('Missing credentials'); } else if (this.#auth.isPrincipal(credentials, 'user')) { if (allowedPrincipalTypes.includes('user' as TAllowed)) { return credentials as any; @@ -104,7 +125,19 @@ export class MockHttpAuthService implements HttpAuthService { ); } - async issueUserCookie(_res: Response): Promise { - throw new NotImplementedError('Not implemented'); + async issueUserCookie( + res: Response, + options?: { credentials?: BackstageCredentials }, + ): Promise<{ expiresAt: Date }> { + const credentials = + options?.credentials ?? + (await this.credentials(res.req, { allow: ['user'] })); + + res.setHeader( + 'Set-Cookie', + mockCredentials.limitedUser.cookie(credentials.principal.userEntityRef), + ); + + return { expiresAt: new Date(Date.now() + 3600_000) }; } } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index f6d2d39167..3ff8a4e017 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -202,6 +202,7 @@ export namespace mockServices { }); export const mock = simpleMock(coreServices.auth, () => ({ authenticate: jest.fn(), + getNoneCredentials: jest.fn(), getOwnServiceCredentials: jest.fn(), isPrincipal: jest.fn() as any, getPluginRequestToken: jest.fn(), diff --git a/yarn.lock b/yarn.lock index eb1db50a2a..05604fd424 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3483,6 +3483,7 @@ __metadata: "@backstage/types": "workspace:^" "@types/supertest": ^2.0.8 better-sqlite3: ^9.0.0 + cookie: ^0.6.0 express: ^4.17.1 fs-extra: ^11.0.0 knex: ^3.0.0 From 7c8727ce057db1661209244b169f68a691c79697 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Feb 2024 12:07:29 +0100 Subject: [PATCH 172/176] backend-app-api: review fixes for cookie auth Signed-off-by: Patrik Oldsberg --- .../httpAuth/httpAuthServiceFactory.ts | 23 +++++++++++-------- .../src/next/services/MockAuthService.ts | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index c261553685..db36e5bf2a 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -59,6 +59,10 @@ function getCookieFromRequest(req: Request) { return undefined; } +function willExpireSoon(expiresAt: Date) { + return Date.now() + FIVE_MINUTES_MS > expiresAt.getTime(); +} + const credentialsSymbol = Symbol('backstage-credentials'); const limitedCredentialsSymbol = Symbol('backstage-limited-credentials'); @@ -100,13 +104,13 @@ class DefaultHttpAuthService implements HttpAuthService { } const cookie = getCookieFromRequest(req); - if (!cookie) { - return await this.#auth.getNoneCredentials(); + if (cookie) { + return await this.#auth.authenticate(cookie, { + allowLimitedAccess: true, + }); } - return await this.#auth.authenticate(cookie, { - allowLimitedAccess: true, - }); + return await this.#auth.getNoneCredentials(); } async #getCredentials(req: RequestWithCredentials) { @@ -171,6 +175,10 @@ class DefaultHttpAuthService implements HttpAuthService { res: Response, options?: { credentials?: BackstageCredentials }, ): Promise<{ expiresAt: Date }> { + if (res.headersSent) { + throw new Error('Failed to issue user cookie, headers were already sent'); + } + let credentials: BackstageCredentials; if (options?.credentials) { if (!this.#auth.isPrincipal(options.credentials, 'user')) { @@ -184,10 +192,7 @@ class DefaultHttpAuthService implements HttpAuthService { } const existingExpiresAt = await this.#existingCookieExpiration(res.req); - if ( - existingExpiresAt && - existingExpiresAt.getTime() < Date.now() - FIVE_MINUTES_MS - ) { + if (existingExpiresAt && !willExpireSoon(existingExpiresAt)) { return { expiresAt: existingExpiresAt }; } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index c0e6461245..64dc92747a 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -181,7 +181,7 @@ export class MockAuthService implements AuthService { token: mockCredentials.limitedUser.token( credentials.principal.userEntityRef, ), - expiresAt: new Date(Date.now() + 3600), + expiresAt: new Date(Date.now() + 3600_000), }; } } From d3008408e8a110613d17d9ec647eeb8446b07171 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 27 Feb 2024 13:49:48 +0100 Subject: [PATCH 173/176] kubernetes-backend: auth test fix Signed-off-by: Patrik Oldsberg --- plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts index 9195551993..cfa89a6bfd 100644 --- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts +++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts @@ -236,7 +236,7 @@ describe('resourcesRoutes', () => { .expect(401, { error: { name: 'AuthenticationError', - message: '', + message: 'Missing credentials', }, request: { method: 'POST', @@ -508,7 +508,7 @@ describe('resourcesRoutes', () => { .expect(401, { error: { name: 'AuthenticationError', - message: '', + message: 'Missing credentials', }, request: { method: 'POST', From 5d9c5ba0a6e7b7b6868fef3e4968afc00c215a0f Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Thu, 22 Feb 2024 12:06:47 +0100 Subject: [PATCH 174/176] feat: add createdAfter filtering to the Notifications Signed-off-by: Marek Libra --- .changeset/five-hats-accept.md | 6 ++ .../database/DatabaseNotificationsStore.ts | 4 ++ .../src/database/NotificationsStore.ts | 1 + .../src/service/router.ts | 7 +++ plugins/notifications/api-report.md | 1 + .../notifications/src/api/NotificationsApi.ts | 1 + .../src/api/NotificationsClient.ts | 4 +- .../NotificationsFilters.tsx | 60 ++++++++++--------- .../NotificationsPage/NotificationsPage.tsx | 23 +++++-- 9 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 .changeset/five-hats-accept.md diff --git a/.changeset/five-hats-accept.md b/.changeset/five-hats-accept.md new file mode 100644 index 0000000000..11ee4ff276 --- /dev/null +++ b/.changeset/five-hats-accept.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications': patch +--- + +The Notifications can be newly filtered based on the Created Date. diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 6870ece4be..8621072834 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -106,6 +106,10 @@ export class DatabaseNotificationsStore implements NotificationsStore { query.orderBy('created', options.sortOrder ?? 'desc'); } + if (options.createdAfter) { + query.where('created', '>=', options.createdAfter.valueOf()); + } + if (options.limit) { query.limit(options.limit); } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index 285f609446..0a7df92f03 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -31,6 +31,7 @@ export type NotificationGetOptions = { sortOrder?: 'asc' | 'desc'; read?: boolean; saved?: boolean; + createdAfter?: Date; }; /** @internal */ diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index d1bf281720..78d44dcd44 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -204,6 +204,13 @@ export async function createRouter( opts.read = false; // or keep undefined } + if (req.query.created_after) { + const sinceEpoch = Date.parse(req.query.created_after.toString()); + if (isNaN(sinceEpoch)) { + throw new InputError('Unexpected date format'); + } + opts.createdAfter = new Date(sinceEpoch); + } const notifications = await store.getNotifications(opts); res.send(notifications); diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 41b82f62ab..666daebdfe 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -21,6 +21,7 @@ export type GetNotificationsOptions = { limit?: number; search?: string; read?: boolean; + createdAfter?: Date; }; // @public (undocumented) diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index 0125cc6a1e..4a1c792012 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -30,6 +30,7 @@ export type GetNotificationsOptions = { limit?: number; search?: string; read?: boolean; + createdAfter?: Date; }; /** @public */ diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 03f9c406a6..1013497b3d 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -54,7 +54,9 @@ export class NotificationsClient implements NotificationsApi { if (options?.read !== undefined) { queryString.append('read', options.read ? 'true' : 'false'); } - + if (options?.createdAfter !== undefined) { + queryString.append('created_after', options.createdAfter.toISOString()); + } const urlSegment = `?${queryString}`; return await this.request(urlSegment); diff --git a/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx b/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx index ac4b02307a..4645f46249 100644 --- a/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx +++ b/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx @@ -28,12 +28,13 @@ import { export type NotificationsFiltersProps = { unreadOnly?: boolean; onUnreadOnlyChanged: (checked: boolean | undefined) => void; - // createdAfter?: string; + createdAfter?: string; + onCreatedAfterChanged: (value: string) => void; + // sorting?: { // orderBy: GetNotificationsOrderByEnum; // orderByDirec: GetNotificationsOrderByDirecEnum; // }; - // onCreatedAfterChanged: (value: string) => void; // setSorting: ({ // orderBy, // orderByDirec, @@ -43,22 +44,22 @@ export type NotificationsFiltersProps = { // }) => void; }; -// export const CreatedAfterOptions: { -// [key: string]: { label: string; getDate: () => Date }; -// } = { -// last24h: { -// label: 'Last 24h', -// getDate: () => new Date(Date.now() - 24 * 3600 * 1000), -// }, -// lastWeek: { -// label: 'Last week', -// getDate: () => new Date(Date.now() - 7 * 24 * 3600 * 1000), -// }, -// all: { -// label: 'Any time', -// getDate: () => new Date(0), -// }, -// }; +export const CreatedAfterOptions: { + [key: string]: { label: string; getDate: () => Date }; +} = { + last24h: { + label: 'Last 24h', + getDate: () => new Date(Date.now() - 24 * 3600 * 1000), + }, + lastWeek: { + label: 'Last week', + getDate: () => new Date(Date.now() - 7 * 24 * 3600 * 1000), + }, + all: { + label: 'Any time', + getDate: () => new Date(0), + }, +}; // export const SortByOptions: { // [key: string]: { @@ -108,20 +109,20 @@ export type NotificationsFiltersProps = { // }; export const NotificationsFilters = ({ - unreadOnly, - // createdAfter, // sorting, - // onCreatedAfterChanged, + // setSorting, + unreadOnly, onUnreadOnlyChanged, -}: // setSorting, -NotificationsFiltersProps) => { + createdAfter, + onCreatedAfterChanged, +}: NotificationsFiltersProps) => { // const sortBy = getSortBy(sorting); - // const handleOnCreatedAfterChanged = ( - // event: React.ChangeEvent<{ name?: string; value: unknown }>, - // ) => { - // onCreatedAfterChanged(event.target.value as string); - // }; + const handleOnCreatedAfterChanged = ( + event: React.ChangeEvent<{ name?: string; value: unknown }>, + ) => { + onCreatedAfterChanged(event.target.value as string); + }; const handleOnUnreadOnlyChanged = ( event: React.ChangeEvent<{ name?: string; value: unknown }>, @@ -169,7 +170,6 @@ NotificationsFiltersProps) => { - {/* TODO: extend BE to support following: @@ -190,6 +190,8 @@ NotificationsFiltersProps) => { + + {/* Sort by diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 43a402ac73..1f8b141608 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -20,11 +20,15 @@ import { PageWithHeader, ResponseErrorPanel, } from '@backstage/core-components'; -import { NotificationsTable } from '../NotificationsTable'; -import { useNotificationsApi } from '../../hooks'; import { Grid } from '@material-ui/core'; import { useSignal } from '@backstage/plugin-signals-react'; -import { NotificationsFilters } from '../NotificationsFilters'; + +import { NotificationsTable } from '../NotificationsTable'; +import { useNotificationsApi } from '../../hooks'; +import { + CreatedAfterOptions, + NotificationsFilters, +} from '../NotificationsFilters'; import { GetNotificationsOptions } from '../../api'; export const NotificationsPage = () => { @@ -32,6 +36,7 @@ export const NotificationsPage = () => { const { lastSignal } = useSignal('notifications'); const [unreadOnly, setUnreadOnly] = React.useState(true); const [containsText, setContainsText] = React.useState(); + const [createdAfter, setCreatedAfter] = React.useState('lastWeek'); const { error, value, retry, loading } = useNotificationsApi( // TODO: add pagination and other filters @@ -40,9 +45,15 @@ export const NotificationsPage = () => { if (unreadOnly !== undefined) { options.read = !unreadOnly; } + + const createdAfterDate = CreatedAfterOptions[createdAfter].getDate(); + if (createdAfterDate.valueOf() > 0) { + options.createdAfter = createdAfterDate; + } + return api.getNotifications(options); }, - [containsText, unreadOnly], + [containsText, unreadOnly, createdAfter], ); useEffect(() => { @@ -72,10 +83,10 @@ export const NotificationsPage = () => { From cacae47b1cd75c8a75e2620515a086bce226e837 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Thu, 22 Feb 2024 13:01:29 +0100 Subject: [PATCH 175/176] chore: add unit tests for the createdAfter filter of notifications Signed-off-by: Marek Libra --- .../DatabaseNotificationsStore.test.ts | 27 +++++++++++++++++++ .../src/api/NotificationsClient.test.ts | 17 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index c3e8ec95ed..e0af93a1d6 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -188,6 +188,33 @@ describe.each(databases.eachSupportedId())( expect(notifications.length).toBe(1); expect(notifications.at(0)?.id).toEqual(id1); }); + + it('should filter notifications based on created date', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + created: new Date(Date.now() - 1 * 60 * 60 * 1000 /* an hour ago */), + }); + await insertNotification({ + id: id2, + ...testNotification, + payload: { + severity: 'normal', + title: 'Please find me', + }, + created: new Date() /* now */, + }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + createdAfter: new Date(Date.now() - 5 * 60 * 1000 /* 5mins */), + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id2); + }); }); describe('getStatus', () => { diff --git a/plugins/notifications/src/api/NotificationsClient.test.ts b/plugins/notifications/src/api/NotificationsClient.test.ts index 09b5e3647c..daf9e71232 100644 --- a/plugins/notifications/src/api/NotificationsClient.test.ts +++ b/plugins/notifications/src/api/NotificationsClient.test.ts @@ -60,7 +60,7 @@ describe('NotificationsClient', () => { server.use( rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { expect(req.url.search).toBe( - '?limit=10&offset=0&search=find+me&read=true', + '?limit=10&offset=0&search=find+me&read=true&created_after=1970-01-01T00%3A00%3A00.005Z', ); return res(ctx.json(expectedResp)); }), @@ -70,6 +70,21 @@ describe('NotificationsClient', () => { offset: 0, search: 'find me', read: true, + createdAfter: new Date(5), + }); + expect(response).toEqual(expectedResp); + }); + + it('should omit unselected fetch options', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe('?limit=10'); + return res(ctx.json(expectedResp)); + }), + ); + const response = await client.getNotifications({ + limit: 10, + // do not put more options here }); expect(response).toEqual(expectedResp); }); From 672f8e3b423814f536e4eaf835a140a3e2074135 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Thu, 22 Feb 2024 15:53:34 +0100 Subject: [PATCH 176/176] chore: filter timestamp on different DB engines Signed-off-by: Marek Libra --- .../src/database/DatabaseNotificationsStore.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 8621072834..9b654cfd04 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -98,6 +98,9 @@ export class DatabaseNotificationsStore implements NotificationsStore { options: NotificationGetOptions | NotificationModifyOptions, ) => { const { user } = options; + const isSQLite = this.db.client.config.client.includes('sqlite3'); + // const isPsql = this.db.client.config.client.includes('pg'); + const query = this.db('notification').where('user', user); if (options.sort !== undefined && options.sort !== null) { @@ -107,7 +110,19 @@ export class DatabaseNotificationsStore implements NotificationsStore { } if (options.createdAfter) { - query.where('created', '>=', options.createdAfter.valueOf()); + if (isSQLite) { + query.where( + 'notification.created', + '>=', + options.createdAfter.valueOf(), + ); + } else { + query.where( + 'notification.created', + '>=', + options.createdAfter.toISOString(), + ); + } } if (options.limit) {