diff --git a/.changeset/breezy-mugs-build.md b/.changeset/breezy-mugs-build.md new file mode 100644 index 0000000000..026932e971 --- /dev/null +++ b/.changeset/breezy-mugs-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Export template logos `TemplateBackstageLogo` and `TemplateBackstageLogoIcon` from package. diff --git a/.changeset/clever-buckets-doubt.md b/.changeset/clever-buckets-doubt.md new file mode 100644 index 0000000000..751db72bf8 --- /dev/null +++ b/.changeset/clever-buckets-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +imports from `@backstage/plugin-search-react` instead of `@backstage/plugin-search` diff --git a/.changeset/clever-pumpkins-tease.md b/.changeset/clever-pumpkins-tease.md new file mode 100644 index 0000000000..d0784361ff --- /dev/null +++ b/.changeset/clever-pumpkins-tease.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Bump the `rushstack` api generator libraries to their latest versions diff --git a/.changeset/cuddly-turtles-sleep.md b/.changeset/cuddly-turtles-sleep.md new file mode 100644 index 0000000000..a63cd44232 --- /dev/null +++ b/.changeset/cuddly-turtles-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Use new `PermissionEvaluator#authorizeConditional` method when retrieving permission conditions. diff --git a/.changeset/dependabot-456f3f4.md b/.changeset/dependabot-456f3f4.md new file mode 100644 index 0000000000..1ccd243715 --- /dev/null +++ b/.changeset/dependabot-456f3f4.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +build(deps): bump `cronstrue` from 1.125.0 to 2.2.0 diff --git a/.changeset/dependabot-699dc23.md b/.changeset/dependabot-699dc23.md new file mode 100644 index 0000000000..b73b0eab46 --- /dev/null +++ b/.changeset/dependabot-699dc23.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kafka-backend': patch +--- + +build(deps-dev): bump `@types/jest-when` from 2.7.2 to 3.5.0 diff --git a/.changeset/dependabot-cccf2f0.md b/.changeset/dependabot-cccf2f0.md new file mode 100644 index 0000000000..6b420f9a48 --- /dev/null +++ b/.changeset/dependabot-cccf2f0.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +build(deps): bump `npm-packlist` from 3.0.0 to 5.0.0 diff --git a/.changeset/eight-cobras-think.md b/.changeset/eight-cobras-think.md new file mode 100644 index 0000000000..af1c7cd113 --- /dev/null +++ b/.changeset/eight-cobras-think.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated. diff --git a/.changeset/few-hotels-approve.md b/.changeset/few-hotels-approve.md new file mode 100644 index 0000000000..49ca040ef7 --- /dev/null +++ b/.changeset/few-hotels-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Changed input label for owner field in GitlabRepoPicker diff --git a/.changeset/few-seas-fail.md b/.changeset/few-seas-fail.md new file mode 100644 index 0000000000..c5d9db4d47 --- /dev/null +++ b/.changeset/few-seas-fail.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-permission-common': patch +--- + +Added `PermissionEvaluator`, which will replace the existing `PermissionAuthorizer` interface. This new interface provides stronger type safety and validation by splitting `PermissionAuthorizer.authorize()` into two methods: + +- `authorize()`: Used when the caller requires a definitive decision. +- `authorizeConditional()`: Used when the caller can optimize the evaluation of any conditional decisions. For example, a plugin backend may want to use conditions in a database query instead of evaluating each resource in memory. diff --git a/.changeset/forty-pumpkins-marry.md b/.changeset/forty-pumpkins-marry.md new file mode 100644 index 0000000000..782fb5ae91 --- /dev/null +++ b/.changeset/forty-pumpkins-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +--- + +Now plugin configuration accept a new optional parameter `groupSelect` which allow the client to fetch defined fields from the ms-graph api. diff --git a/.changeset/four-dolphins-report.md b/.changeset/four-dolphins-report.md new file mode 100644 index 0000000000..fa3f06c532 --- /dev/null +++ b/.changeset/four-dolphins-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-node': minor +--- + +**BREAKING:** `ServerPermissionClient` now implements `PermissionEvaluator`, which moves out the capabilities for evaluating conditional decisions from `authorize()` to `authorizeConditional()` method. diff --git a/.changeset/fresh-boxes-pull.md b/.changeset/fresh-boxes-pull.md new file mode 100644 index 0000000000..e73d7e25c2 --- /dev/null +++ b/.changeset/fresh-boxes-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated. diff --git a/.changeset/friendly-hairs-happen.md b/.changeset/friendly-hairs-happen.md new file mode 100644 index 0000000000..219d5efbcd --- /dev/null +++ b/.changeset/friendly-hairs-happen.md @@ -0,0 +1,76 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +Add a new provider `AwsS3EntityProvider` as replacement for `AwsS3DiscoveryProcessor`. + +In order to migrate from the `AwsS3DiscoveryProcessor` you need to apply +the following changes: + +**Before:** + +```yaml +# app-config.yaml + +catalog: + locations: + - type: s3-discovery + target: https://sample-bucket.s3.us-east-2.amazonaws.com/prefix/ +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + +import { AwsS3DiscoveryProcessor } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors ... */ +builder.addProcessor(new AwsS3DiscoveryProcessor(env.reader)); +``` + +**After:** + +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + yourProviderId: # identifies your dataset / provider independent of config changes + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + +import { AwsS3EntityProvider } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors and/or providers ... */ +builder.addEntityProvider( + ...AwsS3EntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 30 }), + timeout: Duration.fromObject({ minutes: 3 }), + }), + }), +); +``` + +For simple setups, you can omit the provider ID at the config +which has the same effect as using `default` for it. + +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + # uses "default" as provider ID + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` diff --git a/.changeset/gorgeous-donuts-float.md b/.changeset/gorgeous-donuts-float.md new file mode 100644 index 0000000000..72a28ba6f9 --- /dev/null +++ b/.changeset/gorgeous-donuts-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-todo-backend': patch +--- + +Fix method to get source-location. diff --git a/.changeset/heavy-stingrays-talk.md b/.changeset/heavy-stingrays-talk.md new file mode 100644 index 0000000000..d17a85090b --- /dev/null +++ b/.changeset/heavy-stingrays-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +The backend will no longer fail to start up when configured secrets do not match the configuration schema. diff --git a/.changeset/hot-items-smoke.md b/.changeset/hot-items-smoke.md new file mode 100644 index 0000000000..c9b9c8a135 --- /dev/null +++ b/.changeset/hot-items-smoke.md @@ -0,0 +1,61 @@ +--- +'@backstage/plugin-kubernetes-backend': minor +--- + +**BREAKING** Custom cluster suppliers need to cache their getClusters result + +To allow custom `KubernetesClustersSupplier` instances to refresh the list of clusters +the `getClusters` method is now called whenever the list of clusters is needed. + +Existing `KubernetesClustersSupplier` implementations will need to ensure that `getClusters` +can be called frequently and should return a cached result from `getClusters` instead. + +For example, here's a simple example of a custom supplier in `packages/backend/src/plugins/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } ++ ++ async refreshClusters(): Promise { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); ++ const { router } = await builder.build(); +``` diff --git a/.changeset/lemon-ears-build.md b/.changeset/lemon-ears-build.md new file mode 100644 index 0000000000..87a04d150b --- /dev/null +++ b/.changeset/lemon-ears-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +Corrected title and URL to setup documentation in README diff --git a/.changeset/light-dragons-crash.md b/.changeset/light-dragons-crash.md new file mode 100644 index 0000000000..35dea73ae6 --- /dev/null +++ b/.changeset/light-dragons-crash.md @@ -0,0 +1,13 @@ +--- +'@backstage/create-app': patch +--- + +Removed `@octokit/rest` and `@gitbeaker/node` from backend dependencies as these are unused in the default app. + +To apply these changes to your existing app, remove the following lines from the `dependencies` section of `packages/backend/package.json` + +```diff + "@backstage/plugin-techdocs-backend": "^1.0.0", +- "@gitbeaker/node": "^34.6.0", +- "@octokit/rest": "^18.5.3", +``` diff --git a/.changeset/little-moles-pull.md b/.changeset/little-moles-pull.md new file mode 100644 index 0000000000..f52bc24bb2 --- /dev/null +++ b/.changeset/little-moles-pull.md @@ -0,0 +1,25 @@ +--- +'@backstage/create-app': patch +--- + +Add type resolutions for `@types/react` and `types/react-dom`. + +The reason for this is the usage of `"@types/react": "*"` as a dependency which is very common practice in react packages. This recently resolves to react 18 which introduces several breaking changes in both internal and external packages. + +To apply these changes to your existing installation, add a resolutions block to your `package.json` + +```json + "resolutions": { + "@types/react": "^17", + "@types/react-dom": "^17" + }, +``` + +If your existing app depends on react 16, use this resolution block instead. + +```json + "resolutions": { + "@types/react": "^16", + "@types/react-dom": "^16" + }, +``` diff --git a/.changeset/many-cameras-search.md b/.changeset/many-cameras-search.md new file mode 100644 index 0000000000..6998dfd713 --- /dev/null +++ b/.changeset/many-cameras-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added `spec.profile.displayName` to search index for Group kinds diff --git a/.changeset/ninety-eggs-argue.md b/.changeset/ninety-eggs-argue.md new file mode 100644 index 0000000000..6fd221b722 --- /dev/null +++ b/.changeset/ninety-eggs-argue.md @@ -0,0 +1,84 @@ +--- +'@backstage/plugin-search-backend-node': minor +'@backstage/create-app': patch +--- + +**BREAKING**: `IndexBuilder.addCollator()` now requires a `schedule` parameter (replacing `defaultRefreshIntervalSeconds`) which is expected to be a `TaskRunner` that is configured with the desired search indexing schedule for the given collator. + +`Scheduler.addToSchedule()` now takes a new parameter object (`ScheduleTaskParameters`) with two new options `id` and `scheduledRunner` in addition to the migrated `task` argument. + +NOTE: The search backend plugin now creates a dedicated database for coordinating indexing tasks. + +To make this change to an existing app, make the following changes to `packages/backend/src/plugins/search.ts`: + +```diff ++import { Duration } from 'luxon'; + +/* ... */ + ++ const schedule = env.scheduler.createScheduledTaskRunner({ ++ frequency: Duration.fromObject({ minutes: 10 }), ++ timeout: Duration.fromObject({ minutes: 15 }), ++ initialDelay: Duration.fromObject({ seconds: 3 }), ++ }); + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + + const { scheduler } = await indexBuilder.build(); +- setTimeout(() => scheduler.start(), 3000); ++ scheduler.start(); +/* ... */ +``` + +NOTE: For scenarios where the `lunr` search engine is used in a multi-node configuration, a non-distributed `TaskRunner` like the following should be implemented to ensure consistency across nodes (alternatively, you can configure +the search plugin to use a non-distributed DB such as [SQLite](https://backstage.io/docs/tutorials/configuring-plugin-databases#postgresql-and-sqlite-3)): + +```diff ++import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; + +/* ... */ + ++ const schedule: TaskRunner = { ++ run: async (task: TaskInvocationDefinition) => { ++ const startRefresh = async () => { ++ while (!task.signal?.aborted) { ++ try { ++ await task.fn(task.signal); ++ } catch { ++ // ignore intentionally ++ } ++ ++ await new Promise(resolve => setTimeout(resolve, 600 * 1000)); ++ } ++ }; ++ startRefresh(); ++ }, ++ }; + + indexBuilder.addCollator({ +- defaultRefreshIntervalSeconds: 600, ++ schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), + }); + +/* ... */ +``` diff --git a/.changeset/ninety-islands-report.md b/.changeset/ninety-islands-report.md new file mode 100644 index 0000000000..4a515b75cd --- /dev/null +++ b/.changeset/ninety-islands-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Defensively encode URL parameters when fetching ELB keys diff --git a/.changeset/proud-news-perform.md b/.changeset/proud-news-perform.md new file mode 100644 index 0000000000..9d5cbae8ab --- /dev/null +++ b/.changeset/proud-news-perform.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-cicd-statistics-module-gitlab': minor +--- + +Created a module to extract the CI/CD statistics from a Gitlab repository. +Read the `README.md` in the `cicd-statistics-module-gitlab` plugin folder on how to set it up. diff --git a/.changeset/proud-teachers-draw.md b/.changeset/proud-teachers-draw.md new file mode 100644 index 0000000000..dbce78c3c2 --- /dev/null +++ b/.changeset/proud-teachers-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Updated the "unregister location" behavior in `UnregisterEntityDialog`. Removed unnecessary entity deletion requests that were sent after successfully deleting a location. diff --git a/.changeset/quick-avocados-sell.md b/.changeset/quick-avocados-sell.md new file mode 100644 index 0000000000..74014dca66 --- /dev/null +++ b/.changeset/quick-avocados-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed a bug were the `react-hot-loader` transform was being applied to backend development builds. diff --git a/.changeset/rare-emus-agree.md b/.changeset/rare-emus-agree.md new file mode 100644 index 0000000000..423d4c6259 --- /dev/null +++ b/.changeset/rare-emus-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +feature: provide access token to JenkinsInstanceConfig. It can be passed to other backend calls if authentication enabled. DefaultJenkinsInfoProvider sends always this token to catalog api if access token exists. diff --git a/.changeset/rare-parents-pretend.md b/.changeset/rare-parents-pretend.md new file mode 100644 index 0000000000..f211a85efe --- /dev/null +++ b/.changeset/rare-parents-pretend.md @@ -0,0 +1,21 @@ +--- +'@backstage/create-app': patch +--- + +Accept `PermissionEvaluator` instead of the deprecated `PermissionAuthorizer`. + +Apply the following to `packages/backend/src/types.ts`: + +```diff +- import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; ++ import { PermissionEvaluator } from '@backstage/plugin-permission-common'; + + export type PluginEnvironment = { + ... + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + scheduler: PluginTaskScheduler; +- permissions: PermissionAuthorizer; ++ permissions: PermissionEvaluator; + }; +``` diff --git a/.changeset/rude-bees-rest.md b/.changeset/rude-bees-rest.md new file mode 100644 index 0000000000..7ca4e7492c --- /dev/null +++ b/.changeset/rude-bees-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': minor +--- + +New search package to hold things the search plugin itself and other frontend plugins (e.g. techdocs, home) depend on. diff --git a/.changeset/silly-forks-study.md b/.changeset/silly-forks-study.md new file mode 100644 index 0000000000..d337aecec6 --- /dev/null +++ b/.changeset/silly-forks-study.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Updated the dependency on `@backstage/config` to `^1.0.0`. diff --git a/.changeset/slimy-horses-do.md b/.changeset/slimy-horses-do.md new file mode 100644 index 0000000000..196c47c988 --- /dev/null +++ b/.changeset/slimy-horses-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Add dedicated gRPC api definition widget diff --git a/.changeset/soft-rice-remember.md b/.changeset/soft-rice-remember.md new file mode 100644 index 0000000000..bf207a0dd5 --- /dev/null +++ b/.changeset/soft-rice-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-permission-react': patch +--- + +**BREAKING:** Make `IdentityPermissionApi#authorize` typing more strict, using `AuthorizePermissionRequest` and `AuthorizePermissionResponse`. diff --git a/.changeset/thirty-icons-buy.md b/.changeset/thirty-icons-buy.md new file mode 100644 index 0000000000..dd5a9eec19 --- /dev/null +++ b/.changeset/thirty-icons-buy.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Added `ignoreSchemaErrors` to `schema.process`. diff --git a/.changeset/thirty-sloths-knock.md b/.changeset/thirty-sloths-knock.md new file mode 100644 index 0000000000..1ad559e368 --- /dev/null +++ b/.changeset/thirty-sloths-knock.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-search': patch +--- + +The following exports has been moved to `@backstage/plugin-search-react` and will be removed in the next release. import from `@backstage/plugin-search-react` instead. + +- `SearchApi` interface. +- `searchApiRef` +- `SearchContext` +- `SearchContextProvider` +- `useSearch` diff --git a/.changeset/weak-buttons-play.md b/.changeset/weak-buttons-play.md new file mode 100644 index 0000000000..491b510f77 --- /dev/null +++ b/.changeset/weak-buttons-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gocd': patch +--- + +Add DORA metrics insights to GoCD builds page diff --git a/.changeset/wild-emus-film.md b/.changeset/wild-emus-film.md new file mode 100644 index 0000000000..abb77c5ddc --- /dev/null +++ b/.changeset/wild-emus-film.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-insights': minor +--- + +Add checksId option to EntityTechInsightsScorecardContent component diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 4070e5600a..d2257f1b6f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -39,6 +39,7 @@ Changesets chanwit Chanwit ci +CI/CD classname cli cloudbuild diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f0c6e7602..796df14ac7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: continue-on-error: true - name: prettier - run: yarn prettier:check '!ADOPTERS.md' + run: yarn prettier:check - name: lock run: yarn lock:check @@ -137,6 +137,9 @@ jobs: - name: verify doc links run: node scripts/verify-links.js + - name: verify local dependency ranges + run: node scripts/verify-local-dependencies.js + - name: build changed packages if: ${{ steps.yarn-lock.outcome == 'success' }} run: yarn backstage-cli repo build --all --since origin/master diff --git a/.github/workflows/sync_issue-labels.yml b/.github/workflows/sync_issue-labels.yml index 6a1c33348a..91b5f79f5d 100644 --- a/.github/workflows/sync_issue-labels.yml +++ b/.github/workflows/sync_issue-labels.yml @@ -5,6 +5,7 @@ on: jobs: label-issue: runs-on: ubuntu-latest + if: github.repository == 'backstage/backstage' steps: - name: View context attributes uses: actions/github-script@v6 diff --git a/.prettierignore b/.prettierignore index e60d54a466..393c35fdbe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ api-report.md plugins/scaffolder-backend/sample-templates .vscode dist-types + +# reduce the barrier for adopters to add themselves +ADOPTERS.md diff --git a/ADOPTERS.md b/ADOPTERS.md index ce5b2b70ef..1a1c3686d4 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -26,7 +26,7 @@ _If you're using Backstage in your organization, please try to add your company | [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | | [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | | [Trendyol](https://trendyol.com) | [Gamze Senturk](https://github.com/gmzsenturk), [Mert Can Bilgic](https://github.com/mertcb) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [Peloton](https://www.onepeloton.com/) | [Matt Waldron](https://github.com/daftgopher) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | | [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | | [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | | [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | @@ -34,7 +34,7 @@ _If you're using Backstage in your organization, please try to add your company | [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | | [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | | [PagerDuty](https://www.pagerduty.com/) | [Mark Shaw](https://github.com/markshawtoronto) | Developer portal, initially focused on software templates and tech-docs. | -| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process 🌕🚀🧑‍🚀 | +| [MoonShiner](https://moonshiner.at) | [Fabian Hippmann](https://github.com/FabianHippmann) | Developer portal - helps us keep track of our customer projects, onboard new developers & improve our development process 🌕🚀🧑‍🚀 | | [FundApps](https://www.fundapps.co/) | [Elliot Greenwood](https://github.com/egnwd) | Developer Portal - A place for us to keep track of our projects and documentation for all services and processes | | [DAZN](https://dazn.com/) | [Lou Bichard](https://twitter.com/loujaybee), [Marco Crivellaro](https://github.com/crivetechie), [Alex Hollerith](mailto:alex.hollerith@dazn.com) | Ingesting all of DAZN's repos for the catalog, migrating our internal platform apps (pull request boards, release information, inner source marketplace etc) to Backstage plugins (where applicable). | | [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. | @@ -110,3 +110,6 @@ _If you're using Backstage in your organization, please try to add your company | [Beez Innovation Labs Pvt. Ltd](https://www.beezlabs.com/) | [Karthikeyan Venkatesan](https://github.com/karthikeyan23) | Developer portal with software catalog, scaffolding, tech docs, templates, and infra. | | [Agorapulse](https://www.agorapulse.com/) | [@jvdrean](https://github.com/jvdrean) | Developer portal with software catalog, documentation, monitoring, runbooks, tech radar and more. | | [Wistia](https://wistia.com/) | [@qrush](https://github.com/qrush), [@okize](https://github.com/okize) | Internal Developer Portal, service catalog, tech docs and more | +| [SIX](https://www.six-group.com/) | [@jbadeau](https://github.com/jbadeau), [@tomassatka](https://github.com/tomassatka) | Internal DevOps portal hosting our software and dataset catalog, as well as custom plugins for observability, service virtualization, deployments, incident managment and quality metrics. | +| [Raiffeisen Bank International](https://www.rbinternational.com/) | [Daniel Baumgartner](https://github.com/dabarbi) | From developers for developers: software catalog, techdocs and heavy use of scaffolder to drive reuse on engineering level forward. Part of inner source initiative. Multi national setup coming. | +| [Spread Group](https://www.spreadgroup.com/) | [Luna Stadler](https://github.com/heyLu), [Iván González](https://github.com/ivangonzalezacuna) | Internal Developer Portal, an overview of all running software, architecture documentation and more; replacing and unifying a variety of internal tools. | diff --git a/docs/assets/architecture-overview/package-decision.drawio.svg b/docs/assets/architecture-overview/package-decision.drawio.svg new file mode 100644 index 0000000000..e5199adf3f --- /dev/null +++ b/docs/assets/architecture-overview/package-decision.drawio.svg @@ -0,0 +1,269 @@ + + + + + + + + + +
+
+
+ No +
+
+
+
+ + No + +
+
+ + + + + +
+
+
+ Yes +
+
+
+
+ + Yes + +
+
+ + + + +
+
+
+ Is the new addition public API? +
+ i.e. exported from the package +
+
+
+
+ + Is the new addition public API?... + +
+
+ + + + + + +
+
+
+ In what plugin package should I put my code? +
+
+
+
+ + In what plugin package sho... + +
+
+ + + + +
+
+
+ Put it in the package +
+ that uses it +
+
+
+
+ + Put it in the package... + +
+
+ + + + + +
+
+
+ Only app/backend +
+
+
+
+ + Only app/backend + +
+
+ + + + + + +
+
+
+ Is the export supposed +
+ to be used by other plugins or just app/backend packages? +
+
+
+
+ + Is the export supposed... + +
+
+ + + + +
+
+
+ Put it in the frontend or backend plugin package +
+
+
+
+ + Put it in the frontend or... + +
+
+ + + + + +
+
+
+ No +
+
+
+
+ + No + +
+
+ + + + + +
+
+
+ Yes +
+
+
+
+ + Yes + +
+
+ + + + +
+
+
+ Should the export be +
+ usable by both Node.js and browser packages? +
+
+
+
+ + Should the export be... + +
+
+ + + +
+
+
+ Yes, used by other plugins +
+
+
+
+ + Yes, used by other plugins + +
+
+ + + + +
+
+
+ Put frontend exports in <plugin>-react, and backend exports in <plugin>-node +
+
+
+
+ + Put frontend exports in <p... + +
+
+ + + + +
+
+
+ Add it to <plugin>-common, but be sure to support both Node.js and web environments +
+
+
+
+ + Add it to <plugin>-common, but be... + +
+
+ +
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg index d7a8177d1c..709a008b43 100644 --- a/docs/assets/search/architecture.drawio.svg +++ b/docs/assets/search/architecture.drawio.svg @@ -1,20 +1,19 @@ - + - - - - - - - - + + + + + + + - -
-
-
+ +
+
+

@@ -22,18 +21,17 @@
- - + - +
-
-
+
+

@@ -42,20 +40,38 @@
- - - + + + + + +
+
+
+
+ Search +
+ Engine +
+
+
+
+ + Search... + +
+
- +
-
-
+
+
App Package: <Route path="/search" element={<... />} />
@@ -66,51 +82,51 @@ - + - -
-
-
- @backstage/ -
- plugin-search-backend + +
+
+
+ + @backstage/ +
+ plugin-search-backend +
- + @backstage/... - + - -
-
-
- Other Plugins -
- (TechDocs, Catalog, Etc) + +
+
+
+ @backstage/plugin-xyz
- - Other Plugins... + + @backstage/plugin-xyz - + - -
-
-
+ +
+
+

@@ -120,94 +136,90 @@
- + - + - -
-
-
+ +
+
+
@backstage/plugin-search
- + @backstage/plugin-search - - - + - -
-
-
- Other Backend Plugin (TechDocs, Catalog, Etc) + +
+
+
+ @backstage/plugin-xyz-backend
- - Other Backend Plugin (TechDocs, Catalog, Etc) + + @backstage/plugin-xyz-backend - + - -
-
-
- Search Engine (Elastic, Solr, SaaS, etc.) + +
+
+
+ e.g. ElasticSearch, Postgres, Lunr, etc.
- - Search Engine (Elastic, Solr, SaaS, et... + + e.g. ElasticSearch, Postgres, Lunr,... - + - -
-
-
- Search Engine Integration Layer + +
+
+
+ @backstage/plugin-search-backend-module-xyz
- - Search Engine Integration Layer + + @backstage/plugin-search-backend-module-xyz - - - - - + + + - +
-
-
+
+
1 2 3 @@ -223,10 +235,10 @@ - +
-
-
+
+
X number of search results @@ -239,17 +251,17 @@ - - - - - + + + + + - -
-
-
+ +
+
+
Components @@ -257,43 +269,18 @@
- + Components - - - + - -
-
-
- Pass Search -
- Term and Filters -
- and then -
- Return Results -
-
-
-
- - Pass Search... - -
-
- - - - -
-
-
+ +
+
+
Search API @@ -301,20 +288,20 @@
- + Search API - - + + - -
-
-
+ +
+
+
Components @@ -322,232 +309,574 @@
- + Components - - + + - -
-
-
+ +
+
+
+ + IndexBuilder + +
+
+
+
+ + IndexBui... + + + + + + + + + + +
+
+
+ + Query Service +
+
+
+
+
+
+ + Query Se... + +
+
+ + + + +
+
+
+ + + Indexer + +
+
+ Manages Indices +
+ and Writes Documents to a Search Engine +
+
+
+
+
+
+ + Indexer... + +
+
+ + + + + + + +
+
+
+ + + Query Handler + +
+
+ Compiles and Executes Query Against a Search Engine +
+
+
+
+
+ + Query Handler... + +
+
+ + + + +
+
+
+ @backstage/ +
+ plugin-search-backend-node +
+
+
+
+
+ + @backstage/... + +
+
+ + + + +
+
+
+ Backend Package: src/plugins/search.ts +
+
+
+
+ + Backend Package: src/plugins/search.ts + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + +
+
+
+ + + Authorization +
+ (Optional) +
+
+
+
+
+
+
+ + Authorization... + +
+
+ + + + + + + +
+
+
+ IndexBuilder +
+
+
+
+ + IndexBuilder + +
+
+ + + + +
+
+
+ + SearchEngine + +
+
+
+
+ + SearchEngine + +
+
+ + + + +
+
+
+ + Collator(s) + +
+
+
+
+ + Collator(s) + +
+
+ + + + +
+
+
+ + Decorator(s) + +
+
+
+
+ + Decorator(s) + +
+
+ + + + +
+
+
+ + Start Schedule + +
+
+
+
+ + Start Schedule + +
+
+ + + + +
+
+
+ + Create Router + +
+
+
+
+ + Create Router + +
+
+ + + + +
+
+
+ Scheduler
- + Scheduler
- + + + + + - -
-
-
- - Gather Documents From Plugins - + +
+
+
+
+ + Database for + +
+ +
+ + Task Coordination + +
+
+ + Among Nodes + +
+
- - Gather D... + + Database fo... - - - - + + - -
-
-
- - Register Document / Metadata Collation Handler(s) - + +
+
+
+
+ + Install and configure the +
+ search engine, collators, +
+ and decorators that are +
+ appropriate for your +
+ organization! +
+
- - Register Docu... + + Install and con... - + + - -
-
-
- - API Endpoint + +
+
+
+ + Custom Query
+ Translator (Optional)
- - API Endp... + + Custom Quer... - + - -
-
-
+ +
+
+
+ + SearchEngine Implementation + +
+
+
+
+ + SearchEngine Implementati... + + + + + + + +
+
+
+ + XyzCollatorFactory + +
+
+
+
+ + XyzCollatorFactory + +
+
+ + + + + + + +
+
+
+ + XyzDecoratorFactory + +
+
+
+
+ + XyzDecoratorFacto... + +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + Individual backend plugins +
+ define how documents are +
+ retrieved from the plugin's +
+ data store and mapped to +
+ an IndexableDocument. +
+
+
+
+
+
+ + Individual backe... + +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + Individual frontend plugins may +
+ define custom components, +
+ e.g. custom search result items. +
+
+
+
+
+
+ + Individual frontend... + +
+
+ + + + +
+
+
- Query Processing + Search Context
- - Query Processing - -
-
- - - - -
-
-
- - Index Processing - -
-
-
-
- - Index Processi... - -
-
- - - - -
-
-
- - Index Processing - -
-
-
-
- - Index Processi... - -
-
- - - - -
-
-
- - Query Processing - -
-
-
-
- - Query Processing - -
-
- - - - -
-
-
- - - Manage Index - -
- Create, Remove, Replace Documents and Indices -
-
-
-
-
- - Manage Index... - -
-
- - - - - - - -
-
-
- - Compile and Execute Query from Term and Filters - -
-
-
-
- - Compile and Execute... - -
-
- - - - -
-
-
- @backstage/ -
- plugin-search-backend-node -
-
-
-
- - @backstage/... + + Search Cont...
+ + + + + + + + + diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 612f4903d9..dd7e666be4 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -57,6 +57,10 @@ This is an array used to determine where to retrieve cluster configuration from. Valid cluster locator methods are: +- [`config`](#config) +- [`gke`](#gke) +- [custom `KubernetesClustersSupplier`](#custom-kubernetesclusterssupplier) + #### `config` This cluster locator method will read cluster information from your app-config @@ -261,6 +265,12 @@ Kubernetes plugin. Defaults to `false`. +#### Custom `KubernetesClustersSupplier` + +If the configuration-based cluster locators do not work for your use-case, +it is also possible to implement a +[custom `KubernetesClustersSupplier`](installation.md#custom-cluster-discovery). + ### `customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 2698580491..e920bd44ea 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -90,6 +90,63 @@ async function main() { That's it! The Kubernetes frontend and backend have now been added to your Backstage app. +### Custom cluster discovery + +If either existing +[cluster locators](https://backstage.io/docs/features/kubernetes/configuration#clusterlocatormethods) +don't work for your use-case, it is possible to implement a custom +[KubernetesClustersSupplier](https://backstage.io/docs/reference/plugin-kubernetes-backend.kubernetesclusterssupplier). + +Change the following in `packages/backend/src/plugin/kubernetes.ts`: + +```diff +-import { KubernetesBuilder } from '@backstage/plugin-kubernetes-backend'; ++import { ++ ClusterDetails, ++ KubernetesBuilder, ++ KubernetesClustersSupplier, ++} from '@backstage/plugin-kubernetes-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; ++import { Duration } from 'luxon'; ++ ++export class CustomClustersSupplier implements KubernetesClustersSupplier { ++ constructor(private clusterDetails: ClusterDetails[] = []) {} ++ ++ static create(refreshInterval: Duration) { ++ const clusterSupplier = new CustomClustersSupplier(); ++ // setup refresh, e.g. using a copy of https://github.com/backstage/backstage/blob/master/plugins/search-backend-node/src/runPeriodically.ts ++ runPeriodically( ++ () => clusterSupplier.refreshClusters(), ++ refreshInterval.toMillis(), ++ ); ++ return clusterSupplier; ++ } ++ ++ async refreshClusters(): Promise { ++ this.clusterDetails = []; // fetch from somewhere ++ } ++ ++ async getClusters(): Promise { ++ return this.clusterDetails; ++ } ++} + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { +- const { router } = await KubernetesBuilder.createBuilder({ ++ const builder = await KubernetesBuilder.createBuilder({ + logger: env.logger, + config: env.config, +- }).build(); ++ }); ++ builder.setClusterSupplier( ++ CustomClustersSupplier.create(Duration.fromObject({ minutes: 60 })), ++ ); ++ const { router } = await builder.build(); +``` + ## Running Backstage locally Start the frontend and the backend app by diff --git a/docs/features/search/concepts.md b/docs/features/search/concepts.md index 052f71376f..57ab061974 100644 --- a/docs/features/search/concepts.md +++ b/docs/features/search/concepts.md @@ -84,7 +84,9 @@ index-time. There are many ways a search index could be built and maintained, but Backstage Search chooses to completely rebuild indices on a schedule. Different collators can be configured to refresh at different intervals, depending on how often the -source information is updated. +source information is updated. When search indexing is distributed among multiple +backend nodes, coordination to prevent clashes is typically handled by a +distributed `TaskRunner`. ### The Search Page diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index f38d02a3d7..1557a29b53 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -149,6 +149,7 @@ import { import { PluginEnvironment } from '../types'; import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; export default async function createPlugin( env: PluginEnvironment, @@ -161,9 +162,15 @@ export default async function createPlugin( searchEngine, }); + const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), @@ -287,32 +294,87 @@ which are responsible for providing documents number of collators with the `IndexBuilder` like this: ```typescript +import { Duration } from 'luxon'; + const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine }); +const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + +const everyHourSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ hours: 1 }), + timeout: Duration.fromObject({ minutes: 90 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), }); indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 3600, - collator: new MyCustomCollator(), + schedule: everyHourSchedule, + factory: new MyCustomCollatorFactory(), }); ``` Backstage Search builds and maintains its index [on a schedule](./concepts.md#the-scheduler). You can change how often the indexes are rebuilt for a given type of document. You may want to do this if -your documents are updated more or less frequently. You can do so by modifying -its `defaultRefreshIntervalSeconds` value, like this: +your documents are updated more or less frequently. You can do so by configuring +a scheduled `TaskRunner` to pass into the `schedule` value, like this: ```typescript {3} +const every10MinutesSchedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), + initialDelay: Duration.fromObject({ seconds: 3 }), +}); + indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ + schedule: every10MinutesSchedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { + discovery: env.discovery, + tokenManager: env.tokenManager, + }), +}); +``` + +Note: if you are using the in-memory Lunr search engine, you probably want to +implement a non-distributed `TaskRunner` like the following to ensure consistency +if you're running multiple search backend nodes (alternatively, you can configure +the search plugin to use a non-distributed database such as +[SQLite](../../tutorials/configuring-plugin-databases.md#postgresql-and-sqlite-3)): + +```typescript +import { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; + +const schedule: TaskRunner = { + run: async (task: TaskInvocationDefinition) => { + const startRefresh = async () => { + while (!task.signal?.aborted) { + try { + await task.fn(task.signal); + } catch { + // ignore intentionally + } + + await new Promise(resolve => setTimeout(resolve, 600 * 1000)); + } + }; + startRefresh(); + }, +}; + +indexBuilder.addCollator({ + schedule, + factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, }), diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 4417e62c67..340c0d9396 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -10,29 +10,20 @@ description: TechDocs is Spotify’s homegrown docs-like-code solution built dir -TechDocs is Spotify’s homegrown docs-like-code solution built directly into -Backstage. This means engineers write their documentation in Markdown files -which live together with their code. +TechDocs is Spotify’s homegrown docs-like-code solution built directly into Backstage. Engineers write their documentation in Markdown files which live together with their code - and with little configuration get a nice-looking doc site in Backstage. -Today, it is one of the core products in Spotify’s developer experience offering -with 2,400+ documentation sites and 1,000+ engineers using it daily. Read more -about TechDocs and the philosophy in its +Today, it is one of the core products in Spotify’s developer experience offering with 5000+ documentation sites and around 10000 average daily hits. Read more about TechDocs in its [announcement blog post](https://backstage.io/blog/2020/09/08/announcing-tech-docs). 🎉 ## Features - Deploy TechDocs no matter how your software environment is set up. -- Discover your Service's technical documentation from the Service's page in - Backstage Catalog. +- Discover your Service's technical documentation from the Service's page in Backstage Catalog. - Create documentation-only sites for any purpose by just writing Markdown. - Explore and take advantage of the large ecosystem of - [MkDocs plugins](https://www.mkdocs.org/user-guide/plugins/) to create a rich - reading experience. + [MkDocs plugins](https://www.mkdocs.org/user-guide/plugins/) to create a rich reading experience. - Search for and find docs. -- Highlight text and raise an Issue to create feedback loop to drive quality - documentation (future). -- Contribute to and deploy from a marketplace of TechDocs widgets (future). ## Platforms supported @@ -83,17 +74,21 @@ TechDocs packages: - '@backstage/plugin-techdocs-node' - '@techdocs/cli' -was promoted to v1.0! To understand how this change affects the package, please check out our [versioning policy](https://backstage.io/docs/overview/versioning-policy). +TechDocs promoted to v1.0! To understand how this change affects the package, please check out our [versioning policy](https://backstage.io/docs/overview/versioning-policy). ### **Future work 🔮** +Some of the following items are coming soon and some are potential ideas. + +- [TechDocs Addon Framework](https://github.com/backstage/backstage/issues/9636) +- Contribute to and deploy from a marketplace of TechDocs Addons +- Addon: Highlight text and raise an Issue to create a feedback loop to drive up documentation quality +- Addon: MDX (allows you to use JSX in your Markdown content) - Better integration with - [Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy - to choose and plug documentation template with Software Templates). + [Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy to choose and plug documentation template with Software Templates) - Static site generator agnostic - Possible to configure several aspects about TechDocs (e.g. URL, homepage, - theme). -- [TechDocs Addon Framework](https://github.com/backstage/backstage/issues/9636) + theme) ## Tech stack diff --git a/docs/integrations/aws-s3/discovery.md b/docs/integrations/aws-s3/discovery.md index 2c25aaaf05..316b5c5014 100644 --- a/docs/integrations/aws-s3/discovery.md +++ b/docs/integrations/aws-s3/discovery.md @@ -6,32 +6,55 @@ sidebar_label: Discovery description: Automatically discovering catalog entities from an AWS S3 Bucket --- -The AWS S3 integration has a special discovery processor for discovering catalog +The AWS S3 integration has a special entity provider for discovering catalog entities located in an S3 Bucket. If you have a bucket that contains multiple -catalog-info files and want to automatically discover them, you can use this -processor. The processor will crawl your S3 bucket and register entities +catalog files, and you want to automatically discover them, you can use this +provider. The provider will crawl your S3 bucket and register entities matching the configured path. This can be useful as an alternative to static locations or manually adding things to the catalog. -To use the discovery processor, you'll need an AWS S3 integration -[set up](locations.md) with an `AWS_ACCESS_KEY`, `AWS_SECRET_ACCESS_KEY`, and -optionally a `roleArn`. Then you can add a location target to the catalog -configuration: +To use the entity provider, you'll need an AWS S3 integration +[set up](locations.md) with `accessKeyId` and `secretAccessKey`, and/or +a `roleArn` or none of these (e.g., profile- or instance-based credentials). + +At production deployments, you likely manage these with the permissions attached +to your instance. + +At your configuration, you add a provider config per bucket: ```yaml +# app-config.yaml + catalog: - locations: - - type: s3-discovery - target: https://sample-bucket.s3.us-east-2.amazonaws.com/ + providers: + awsS3: + yourProviderId: # identifies your dataset / provider independent of config changes + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise ``` -Note the `s3-discovery` type, as this is not a regular `url` processor. +For simple setups, you can omit the provider ID at the config +which has the same effect as using `default` for it. -As this processor is not one of the default providers, you will first need to install the AWS catalog plugin: +```yaml +# app-config.yaml + +catalog: + providers: + awsS3: + # uses "default" as provider ID + bucketName: sample-bucket + prefix: prefix/ # optional + region: us-east-2 # optional, uses the default region otherwise +``` + +As this provider is not one of the default providers, you will first need to install +the AWS catalog plugin: ```bash # From the Backstage root directory -yarn install --cwd packages/backend @backstage/plugin-catalog-backend-module-aws +yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-aws ``` Once you've done that, you'll also need to add the segment below to `packages/backend/src/plugins/catalog.ts`: @@ -39,6 +62,38 @@ Once you've done that, you'll also need to add the segment below to `packages/ba ```ts /* packages/backend/src/plugins/catalog.ts */ +import { AwsS3EntityProvider } from '@backstage/plugin-catalog-backend-module-aws'; + +const builder = await CatalogBuilder.create(env); +/** ... other processors and/or providers ... */ +builder.addEntityProvider( + ...AwsS3EntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 30 }), + timeout: Duration.fromObject({ minutes: 3 }), + }), + }), +); +``` + +## Alternative Processor + +As alternative to the entity provider `AwsS3EntityProvider` +you can still use the `AwsS3DiscoveryProcessor`. + +```yaml +# app-config.yaml + +catalog: + locations: + - type: s3-discovery + target: https://sample-bucket.s3.us-east-2.amazonaws.com/prefix/ +``` + +```ts +/* packages/backend/src/plugins/catalog.ts */ + import { AwsS3DiscoveryProcessor } from '@backstage/plugin-catalog-backend-module-aws'; const builder = await CatalogBuilder.create(env); diff --git a/docs/integrations/gerrit/locations.md b/docs/integrations/gerrit/locations.md new file mode 100644 index 0000000000..8091de3a2e --- /dev/null +++ b/docs/integrations/gerrit/locations.md @@ -0,0 +1,43 @@ +--- +id: locations +title: Gerrit Locations +sidebar_label: Locations +description: Integrating source code stored in Gerrit into the Backstage catalog +--- + +The Gerrit integration supports loading catalog entities from Gerrit hosted gits. Entities can +be added to [static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +## Configuration + +To use this integration, add at least one Gerrit configuration to your root `app-config.yaml`: + +```yaml +integrations: + gerrit: + - host: gerrit.company.com + apiBaseUrl: https://gerrit.company.com/gerrit + gitilesBaseUrl: https://gerrit.company.com/gitiles + username: ${GERRIT_USERNAME} + password: ${GERRIT_PASSWORD} +``` + +Directly under the `gerrit` key is a list of provider configurations, where +you can list the Gerrit instances you want to fetch data from. Each entry is +a structure with up to four elements: + +- `host`: The host of the Gerrit instance, e.g. `gerrit.company.com`. +- `apiBaseUrl` (optional): Needed if the Gerrit instance is not reachable at + the base of the `host` option (e.g. `https://gerrit.company.com`) set the + address here. This is the address that you would open in a browser. +- `gitilesBaseUrl` (optional): This is needed for creating a valid user-friendly url + that can be used for browsing the content of the provider. If not set a default + value will be created in the same way as the "baseUrl" option. There is no + requirement to have Gitiles for the Backstage Gerrit integration but without it + some links in the Backstage UI will be broken. +- `username` (optional): The Gerrit username to use in API requests. If + neither a username nor password are supplied, anonymous access will be used. +- `password` (optional): The password or http token for the Gerrit user. diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index 46e054364f..1cbf8905a2 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -16,11 +16,12 @@ catalog. You will have to add the processors in the catalog initialization code of your backend. They are not installed by default, therefore you have to add a -dependency to `@backstage/plugin-catalog-backend-module-github` to your backend -package. +dependency on `@backstage/plugin-catalog-backend-module-github` to your backend +package, plus `@backstage/integration` for the basic credentials management: ```bash # From your Backstage root directory +yarn add --cwd packages/backend @backstage/integration yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-github ``` diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 5b6b81cf10..382399e603 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -256,6 +256,18 @@ The Backstage CLI is in a category of its own and is depended on by virtually all other packages. It's not a library in itself though, and must always be a development dependency only. +### Deciding where you place your code + +It can sometimes be difficult to decide where to place your plugin code. For example +should it go directly in the `-backend` plugin package or in the `-node` package? +As a rule of thumb you should try to keep the exposure of your code as low +as possible. If it doesn't need to be public API, it's best to avoid. If you don't +need it to be used by other plugins, then keep it directly in the plugin packages. + +Below is a chart to help you decide where to place your code. + +![Package decision](../assets/architecture-overview/package-decision.drawio.svg) + ## Databases As we have seen, both the `lighthouse-audit-service` and `catalog-backend` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f0f8811666..d284a3c8ab 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -153,6 +153,11 @@ "label": "Datadog", "ids": ["integrations/datadog-rum/installation"] }, + { + "type": "subcategory", + "label": "Gerrit", + "ids": ["integrations/gerrit/locations"] + }, { "type": "subcategory", "label": "GitHub", diff --git a/mkdocs.yml b/mkdocs.yml index 9fa5eaabd3..204a4768c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,6 +97,8 @@ nav: - Discovery: 'integrations/bitbucket/discovery.md' - Datadog: - Installation: 'integrations/datadog-rum/installation.md' + - Gerrit: + - Locations: 'integrations/gerrit/locations.md' - GitHub: - Locations: 'integrations/github/locations.md' - Discovery: 'integrations/github/discovery.md' diff --git a/package.json b/package.json index 13ba3c34ea..6fea4e081a 100644 --- a/package.json +++ b/package.json @@ -45,15 +45,17 @@ ] }, "resolutions": { - "**/@graphql-codegen/cli/**/ws": "^7.4.6" + "**/@graphql-codegen/cli/**/ws": "^7.4.6", + "@types/react": "^17", + "@types/react-dom": "^17" }, "version": "1.1.0-next.2", "dependencies": { "@manypkg/get-packages": "^1.1.3", - "@microsoft/api-documenter": "^7.17.0", - "@microsoft/api-extractor": "^7.19.4", - "@microsoft/api-extractor-model": "^7.16.0", - "@microsoft/tsdoc": "^0.13.2" + "@microsoft/api-documenter": "^7.17.5", + "@microsoft/api-extractor": "^7.21.2", + "@microsoft/api-extractor-model": "^7.16.1", + "@microsoft/tsdoc": "^0.14.1" }, "devDependencies": { "@changesets/cli": "^2.14.0", diff --git a/packages/app/package.json b/packages/app/package.json index 4522cabaf1..f8dc6127c8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,6 +10,7 @@ "@backstage/app-defaults": "^1.0.1-next.1", "@backstage/catalog-model": "^1.0.1-next.0", "@backstage/cli": "^0.17.0-next.1", + "@backstage/config": "^1.0.0", "@backstage/core-app-api": "^1.0.1-next.0", "@backstage/core-components": "^0.9.3-next.0", "@backstage/core-plugin-api": "^1.0.0", @@ -47,9 +48,11 @@ "@backstage/plugin-rollbar": "^0.4.4-next.0", "@backstage/plugin-scaffolder": "^1.0.1-next.1", "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-search-common": "^0.3.3-next.1", "@backstage/plugin-sentry": "^0.3.42-next.0", "@backstage/plugin-shortcuts": "^0.2.5-next.0", + "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/plugin-tech-radar": "^0.5.11-next.1", "@backstage/plugin-techdocs": "^1.0.1-next.1", "@backstage/plugin-todo": "^0.2.6-next.0", diff --git a/plugins/home/src/templates/DefaultTemplate.stories.tsx b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx similarity index 92% rename from plugins/home/src/templates/DefaultTemplate.stories.tsx rename to packages/app/src/components/home/templates/DefaultTemplate.stories.tsx index e2e570dcf6..8b9c5100b1 100644 --- a/plugins/home/src/templates/DefaultTemplate.stories.tsx +++ b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx @@ -14,14 +14,14 @@ * limitations under the License. */ -import { TemplateBackstageLogo } from './TemplateBackstageLogo'; -import { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon'; import { HomePageToolkit, HomePageCompanyLogo, HomePageStarredEntities, -} from '../plugin'; -import { wrapInTestApp, TestApiProvider} from '@backstage/test-utils'; + TemplateBackstageLogo, + TemplateBackstageLogoIcon +} from '@backstage/plugin-home'; +import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { Content, Page, InfoCard } from '@backstage/core-components'; import { starredEntitiesApiRef, @@ -32,10 +32,9 @@ import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigReader } from '@backstage/config'; import { HomePageSearchBar, - SearchContextProvider, - searchApiRef, searchPlugin, } from '@backstage/plugin-search'; +import { searchApiRef, SearchContextProvider } from '@backstage/plugin-search-react'; import { HomePageStackOverflowQuestions } from '@backstage/plugin-stack-overflow'; import { Grid, makeStyles } from '@material-ui/core'; import React, { ComponentType } from 'react'; @@ -54,10 +53,7 @@ export default { <> Promise.resolve({ results: [] }) }], [ configApiRef, diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 9081b99284..94b362d99d 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -41,7 +41,7 @@ const updateRedactionList = ( ) => { const secretAppConfigs = schema.process(configs, { visibility: ['secret'], - withDeprecatedKeys: true, + ignoreSchemaErrors: true, }); const secretConfig = ConfigReader.fromConfigs(secretAppConfigs); const values = new Set(); diff --git a/packages/backend/package.json b/packages/backend/package.json index 3fde1cdc11..aed934a028 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -68,6 +68,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", + "luxon": "^2.0.2", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", "prom-client": "^14.0.1", @@ -77,7 +78,8 @@ "@backstage/cli": "^0.17.0-next.1", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5" + "@types/express-serve-static-core": "^4.17.5", + "@types/luxon": "^2.0.4" }, "files": [ "dist" diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index cb675a842c..fc91d98cfe 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -26,6 +26,7 @@ import { } from '@backstage/plugin-search-backend-node'; import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; import { PluginEnvironment } from '../types'; async function createSearchEngine( @@ -55,10 +56,18 @@ export default async function createPlugin( searchEngine, }); + const schedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), + // A 3 second delay gives the backend server a chance to initialize before + // any collators are executed, which may attempt requests against the API. + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + // Collators are responsible for gathering documents known to plugins. This // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, @@ -66,7 +75,7 @@ export default async function createPlugin( }); indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { discovery: env.discovery, logger: env.logger, @@ -77,10 +86,8 @@ export default async function createPlugin( // The scheduler controls when documents are gathered from collators and sent // to the search engine for indexing. const { scheduler } = await indexBuilder.build(); + scheduler.start(); - // A 3 second delay gives the backend server a chance to initialize before - // any collators are executed, which may attempt requests against the API. - setTimeout(() => scheduler.start(), 3000); useHotCleanup(module, () => scheduler.stop()); return await createRouter({ diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 0b2543c4f5..3e47b1a523 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -23,8 +23,11 @@ import { TokenManager, UrlReader, } from '@backstage/backend-common'; -import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { + PermissionAuthorizer, + PermissionEvaluator, +} from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -34,6 +37,6 @@ export type PluginEnvironment = { reader: UrlReader; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; - permissions: ServerPermissionClient; + permissions: PermissionEvaluator | PermissionAuthorizer; scheduler: PluginTaskScheduler; }; diff --git a/packages/cli/package.json b/packages/cli/package.json index e362caf40e..bd8964ef68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -93,7 +93,7 @@ "mini-css-extract-plugin": "^2.4.2", "minimatch": "5.0.1", "node-libs-browser": "^2.2.1", - "npm-packlist": "^3.0.0", + "npm-packlist": "^5.0.0", "ora": "^5.3.0", "postcss": "^8.1.0", "process": "^0.11.10", @@ -153,7 +153,7 @@ "ts-node": "^10.0.0" }, "peerDependencies": { - "@microsoft/api-extractor": "^7.19.2" + "@microsoft/api-extractor": "^7.21.2" }, "peerDependenciesMeta": { "@microsoft/api-extractor": { diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 575af4ea8b..fb073a25e3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -226,7 +226,7 @@ export async function createBackendConfig( // See frontend config const externalPkgs = packages.filter(p => !isChildPath(paths.root, p.dir)); - const { loaders } = transforms(options); + const { loaders } = transforms({ ...options, isBackend: true }); const runScriptNodeArgs = new Array(); if (options.inspectEnabled) { diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index d1d26f383d..a24c24a194 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -25,12 +25,13 @@ type Transforms = { type TransformOptions = { isDev: boolean; + isBackend?: boolean; }; export const transforms = (options: TransformOptions): Transforms => { - const { isDev } = options; + const { isDev, isBackend } = options; - const extraTransforms = isDev ? ['react-hot-loader'] : []; + const extraTransforms = isDev && !isBackend ? ['react-hot-loader'] : []; // This ensures that styles inserted from the style-loader and any // async style chunks are always given lower priority than JSS styles. diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 06868e0f47..225b3ddd22 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -19,6 +19,7 @@ export type ConfigSchema = { // @public export type ConfigSchemaProcessingOptions = { visibility?: ConfigVisibility[]; + ignoreSchemaErrors?: boolean; valueTransform?: TransformFunc; withFilteredKeys?: boolean; withDeprecatedKeys?: boolean; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index 41971f7d01..475339be4e 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -275,5 +275,12 @@ describe('loadConfigSchema', () => { ).toThrow( "Config must have required property 'x a' { missingProperty=x a } at /other", ); + + expect( + schema.process([{ data: { other: {} }, context: 'test' }], { + visibility: ['frontend'], + ignoreSchemaErrors: true, + }), + ).toEqual([{ data: {}, context: 'test' }]); }); }); diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts index 959b438371..0309f64ea0 100644 --- a/packages/config-loader/src/lib/schema/load.ts +++ b/packages/config-loader/src/lib/schema/load.ts @@ -82,18 +82,26 @@ export async function loadConfigSchema( return { process( configs: AppConfig[], - { visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {}, + { + visibility, + valueTransform, + withFilteredKeys, + withDeprecatedKeys, + ignoreSchemaErrors, + } = {}, ): AppConfig[] { const result = validate(configs); - const visibleErrors = filterErrorsByVisibility( - result.errors, - visibility, - result.visibilityByDataPath, - result.visibilityBySchemaPath, - ); - if (visibleErrors.length > 0) { - throw errorsToError(visibleErrors); + if (!ignoreSchemaErrors) { + const visibleErrors = filterErrorsByVisibility( + result.errors, + visibility, + result.visibilityByDataPath, + result.visibilityBySchemaPath, + ); + if (visibleErrors.length > 0) { + throw errorsToError(visibleErrors); + } } let processedConfigs = configs; diff --git a/packages/config-loader/src/lib/schema/types.ts b/packages/config-loader/src/lib/schema/types.ts index cc62192e30..95576cf5aa 100644 --- a/packages/config-loader/src/lib/schema/types.ts +++ b/packages/config-loader/src/lib/schema/types.ts @@ -117,6 +117,11 @@ export type ConfigSchemaProcessingOptions = { */ visibility?: ConfigVisibility[]; + /** + * When set to `true`, any schema errors in the provided configuration will be ignored. + */ + ignoreSchemaErrors?: boolean; + /** * A transform function that can be used to transform primitive configuration values * during validation. The value returned from the transform function will be used diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 884eaf23cf..5bdbec7971 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -37,6 +37,10 @@ "prettier": "^2.3.2", "typescript": "~4.5.4" }, + "resolutions": { + "@types/react": "^17", + "@types/react-dom": "^17" + }, "prettier": "@spotify/prettier-config", "lint-staged": { "*.{js,jsx,ts,tsx,mjs,cjs}": [ diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index cd4d3e1222..939b13bf86 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -35,11 +35,10 @@ {{/if}} "@backstage/plugin-search-backend-node": "^{{version '@backstage/plugin-search-backend-node'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", - "@gitbeaker/node": "^34.6.0", - "@octokit/rest": "^18.5.3", "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "luxon": "^2.0.2", {{#if dbTypePG}} "pg": "^8.3.0", {{/if}} @@ -52,7 +51,8 @@ "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5" + "@types/express-serve-static-core": "^4.17.5", + "@types/luxon": "^2.0.4" }, "files": [ "dist" diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs index 8f44a35b16..62bc3056cc 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts.hbs @@ -11,6 +11,7 @@ import { PluginEnvironment } from '../types'; import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend'; import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend'; import { Router } from 'express'; +import { Duration } from 'luxon'; export default async function createPlugin( env: PluginEnvironment, @@ -31,10 +32,18 @@ export default async function createPlugin( searchEngine, }); + const schedule = env.scheduler.createScheduledTaskRunner({ + frequency: Duration.fromObject({ minutes: 10 }), + timeout: Duration.fromObject({ minutes: 15 }), + // A 3 second delay gives the backend server a chance to initialize before + // any collators are executed, which may attempt requests against the API. + initialDelay: Duration.fromObject({ seconds: 3 }), + }); + // Collators are responsible for gathering documents known to plugins. This // collator gathers entities from the software catalog. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultCatalogCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, @@ -43,7 +52,7 @@ export default async function createPlugin( // collator gathers entities from techdocs. indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, + schedule, factory: DefaultTechDocsCollatorFactory.fromConfig(env.config, { discovery: env.discovery, logger: env.logger, @@ -54,10 +63,8 @@ export default async function createPlugin( // The scheduler controls when documents are gathered from collators and sent // to the search engine for indexing. const { scheduler } = await indexBuilder.build(); + scheduler.start(); - // A 3 second delay gives the backend server a chance to initialize before - // any collators are executed, which may attempt requests against the API. - setTimeout(() => scheduler.start(), 3000); useHotCleanup(module, () => scheduler.stop()); return await createRouter({ diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 0862b0e874..8e0a86404b 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -8,7 +8,7 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -19,5 +19,5 @@ export type PluginEnvironment = { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator; }; diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx index baccf09de9..3b7aa406d4 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionWidget.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget'; import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget'; import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget'; +import { GrpcApiDefinitionWidget } from '../GrpcApiDefinitionWidget'; export type ApiDefinitionWidget = { type: string; @@ -51,5 +52,12 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] { ), }, + { + type: 'grpc', + title: 'gRPC', + component: definition => ( + + ), + }, ]; } diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx new file mode 100644 index 0000000000..046614fc91 --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.test.tsx @@ -0,0 +1,32 @@ +/* + * 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 { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { GrpcApiDefinitionWidget } from './GrpcApiDefinitionWidget'; + +describe('', () => { + it('renders plain text', async () => { + const { getAllByText } = await renderInTestApp( + , + ); + + expect( + getAllByText((_text, element) => element?.textContent === 'Hello World') + .length, + ).toBeGreaterThan(0); + }); +}); diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx new file mode 100644 index 0000000000..c5f9b071ef --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/GrpcApiDefinitionWidget.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { CodeSnippet } from '@backstage/core-components'; +import { useTheme } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; + +export type GrpcApiDefinitionWidgetProps = { + definition: string; +}; + +export const GrpcApiDefinitionWidget = ( + props: GrpcApiDefinitionWidgetProps, +) => { + const theme = useTheme(); + return ( + + ); +}; diff --git a/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts new file mode 100644 index 0000000000..f9ed12d111 --- /dev/null +++ b/plugins/api-docs/src/components/GrpcApiDefinitionWidget/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { GrpcApiDefinitionWidget } from './GrpcApiDefinitionWidget'; +export type { GrpcApiDefinitionWidgetProps } from './GrpcApiDefinitionWidget'; diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 12f7c7f4b4..5206dc125c 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -211,8 +211,10 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { if (optionalCacheKey) { return crypto.createPublicKey(optionalCacheKey); } - const keyText: string = await fetch( - `https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`, + const keyText = await fetch( + `https://public-keys.auth.elb.${encodeURIComponent( + this.region, + )}.amazonaws.com/${encodeURIComponent(keyId)}`, ).then(response => response.text()); const keyValue = crypto.createPublicKey(keyText); this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' })); diff --git a/plugins/catalog-backend-module-aws/README.md b/plugins/catalog-backend-module-aws/README.md index 304fe7a080..1e54822ea9 100644 --- a/plugins/catalog-backend-module-aws/README.md +++ b/plugins/catalog-backend-module-aws/README.md @@ -1,4 +1,4 @@ -# Catalog Backend Module for LDAP +# Catalog Backend Module for AWS This is an extension module to the plugin-catalog-backend plugin, providing an `AwsOrganizationCloudAccountProcessor` that can be used to ingest cloud accounts @@ -6,5 +6,5 @@ as `Resource` kind entities. ## Getting started -See [Backstage documentation](https://backstage.io/docs/integrations/ldap/org) for details on how to install +See [Backstage documentation](https://backstage.io/docs/integrations/aws-s3/discovery) for details on how to install and configure the plugin. diff --git a/plugins/catalog-backend-module-aws/api-report.md b/plugins/catalog-backend-module-aws/api-report.md index 876562fc8d..c2c18431b9 100644 --- a/plugins/catalog-backend-module-aws/api-report.md +++ b/plugins/catalog-backend-module-aws/api-report.md @@ -7,8 +7,11 @@ import { CatalogProcessor } from '@backstage/plugin-catalog-backend'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; +import { EntityProvider } from '@backstage/plugin-catalog-backend'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-backend'; import { LocationSpec } from '@backstage/plugin-catalog-backend'; import { Logger } from 'winston'; +import { TaskRunner } from '@backstage/backend-tasks'; import { UrlReader } from '@backstage/backend-common'; // @public @@ -43,4 +46,22 @@ export class AwsS3DiscoveryProcessor implements CatalogProcessor { parser: CatalogProcessorParser, ): Promise; } + +// @public +export class AwsS3EntityProvider implements EntityProvider { + // (undocumented) + connect(connection: EntityProviderConnection): Promise; + // (undocumented) + static fromConfig( + configRoot: Config, + options: { + logger: Logger; + schedule: TaskRunner; + }, + ): AwsS3EntityProvider[]; + // (undocumented) + getProviderName(): string; + // (undocumented) + refresh(logger: Logger): Promise; +} ``` diff --git a/plugins/catalog-backend-module-aws/config.d.ts b/plugins/catalog-backend-module-aws/config.d.ts index bb416a576e..2e41d60aac 100644 --- a/plugins/catalog-backend-module-aws/config.d.ts +++ b/plugins/catalog-backend-module-aws/config.d.ts @@ -14,6 +14,27 @@ * limitations under the License. */ +interface AwsS3Config { + /** + * (Required) AWS S3 Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) AWS S3 Object key prefix + * If not set, all keys will be accepted, no filtering will be applied. + * @visibility backend + */ + prefix?: string; + /** + * (Optional) AWS Region. + * If not set, AWS_REGION environment variable or aws config file will be used. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + * @visibility backend + */ + region?: string; +} + export interface Config { catalog?: { /** @@ -32,5 +53,16 @@ export interface Config { }; }; }; + /** + * List of provider-specific options and attributes + */ + providers?: { + /** + * AwsS3EntityProvider configuration + * + * Uses "default" as default id for the single config variant. + */ + awsS3?: AwsS3Config | Record; + }; }; } diff --git a/plugins/catalog-backend-module-aws/package.json b/plugins/catalog-backend-module-aws/package.json index 23b5055e6a..a115efeacb 100644 --- a/plugins/catalog-backend-module-aws/package.json +++ b/plugins/catalog-backend-module-aws/package.json @@ -34,14 +34,17 @@ }, "dependencies": { "@backstage/backend-common": "^0.13.2-next.1", + "@backstage/backend-tasks": "^0.3.0-next.1", "@backstage/catalog-model": "^1.0.1-next.0", "@backstage/config": "^1.0.0", "@backstage/errors": "^1.0.0", + "@backstage/integration": "^1.1.0-next.1", "@backstage/plugin-catalog-backend": "^1.1.0-next.1", "@backstage/types": "^1.0.0", "aws-sdk": "^2.840.0", "lodash": "^4.17.21", "p-limit": "^3.0.2", + "uuid": "^8.0.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts new file mode 100644 index 0000000000..ffb84d1c34 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/credentials/AwsCredentials.ts @@ -0,0 +1,61 @@ +/* + * 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 aws, { Credentials } from 'aws-sdk'; +import { CredentialsOptions } from 'aws-sdk/lib/credentials'; + +export class AwsCredentials { + /** + * If accessKeyId and secretAccessKey are missing, the DefaultAWSCredentialsProviderChain will be used: + * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html + */ + static create( + config: { + accessKeyId?: string; + secretAccessKey?: string; + roleArn?: string; + }, + roleSessionName: string, + ): Credentials | CredentialsOptions | undefined { + if (!config) { + return undefined; + } + + const accessKeyId = config.accessKeyId; + const secretAccessKey = config.secretAccessKey; + let explicitCredentials: Credentials | undefined; + + if (accessKeyId && secretAccessKey) { + explicitCredentials = new Credentials({ + accessKeyId, + secretAccessKey, + }); + } + + const roleArn = config.roleArn; + if (roleArn) { + return new aws.ChainableTemporaryCredentials({ + masterCredentials: explicitCredentials, + params: { + RoleArn: roleArn, + RoleSessionName: roleSessionName, + }, + }); + } + + return explicitCredentials; + } +} diff --git a/plugins/catalog-backend-module-aws/src/index.ts b/plugins/catalog-backend-module-aws/src/index.ts index 62c10a0f6d..1d8e844895 100644 --- a/plugins/catalog-backend-module-aws/src/index.ts +++ b/plugins/catalog-backend-module-aws/src/index.ts @@ -21,3 +21,4 @@ */ export * from './processors'; +export * from './providers'; diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts new file mode 100644 index 0000000000..5e5901a770 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { TaskInvocationDefinition, TaskRunner } from '@backstage/backend-tasks'; +import { ConfigReader } from '@backstage/config'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-backend'; +import { AwsS3EntityProvider } from './AwsS3EntityProvider'; +import aws from 'aws-sdk'; +import AWSMock from 'aws-sdk-mock'; + +class PersistingTaskRunner implements TaskRunner { + private tasks: TaskInvocationDefinition[] = []; + + getTasks() { + return this.tasks; + } + + run(task: TaskInvocationDefinition): Promise { + this.tasks.push(task); + return Promise.resolve(undefined); + } +} + +const logger = getVoidLogger(); + +describe('AwsS3EntityProvider', () => { + const config = new ConfigReader({ + catalog: { + providers: { + awsS3: { + anyProviderId: { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }, + }, + }, + }, + }); + + const schedule = new PersistingTaskRunner(); + + AWSMock.setSDKInstance(aws); + const createObjectList = (...keys: string[]): aws.S3.ObjectList => { + const objects = keys.map(key => { + return { + Key: key, + } as aws.S3.Types.Object; + }); + + return objects as aws.S3.ObjectList; + }; + + AWSMock.mock('S3', 'listObjectsV2', async req => { + const prefix = req.Prefix ?? ''; + + if (!req.ContinuationToken) { + return { + Contents: createObjectList(`${prefix}key1.yaml`, `${prefix}key2.yaml`), + NextContinuationToken: 'next-token', + } as aws.S3.Types.ListObjectsV2Output; + } + + return { + Contents: createObjectList(`${prefix}key3.yaml`, `${prefix}key4.yaml`), + } as aws.S3.Types.ListObjectsV2Output; + }); + + afterEach(() => jest.resetAllMocks()); + + it('apply full update on scheduled execution', async () => { + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + }; + + const provider = AwsS3EntityProvider.fromConfig(config, { + logger, + schedule, + })[0]; + expect(provider.getProviderName()).toEqual('awsS3-provider:anyProviderId'); + + await provider.connect(entityProviderConnection); + + const taskDef = schedule.getTasks()[0]; + expect(taskDef.id).toEqual('awsS3-provider:anyProviderId:refresh'); + await (taskDef.fn as () => Promise)(); + + expect(entityProviderConnection.applyMutation).toBeCalledWith({ + type: 'full', + entities: [ + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + }, + name: 'generated-980e6ad47fbfbfeead708a9c7c87331b7540296a', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key1.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + }, + name: 'generated-266794d8e789089dddba2b42cd79e70b149aa61c', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key2.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + }, + name: 'generated-96f0cdcd7e33aa687c19d160ec7d5b1975cb9ea1', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key3.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + }, + name: 'generated-cd1a799b5ecfc055a0c672654420af3afeb648d3', + }, + spec: { + presence: 'required', + target: + 'https://bucket-1.s3.us-east-1.amazonaws.com/sub/dir/key4.yaml', + type: 'url', + }, + }, + locationKey: 'awsS3-provider:anyProviderId', + }, + ], + }); + }); +}); diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts new file mode 100644 index 0000000000..2e64699d8b --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.ts @@ -0,0 +1,211 @@ +/* + * 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 { TaskRunner } from '@backstage/backend-tasks'; +import { Config } from '@backstage/config'; +import { AwsS3Integration, ScmIntegrations } from '@backstage/integration'; +import { + EntityProvider, + EntityProviderConnection, + LocationSpec, + locationSpecToLocationEntity, +} from '@backstage/plugin-catalog-backend'; +import { AwsCredentials } from '../credentials/AwsCredentials'; +import { readAwsS3Configs } from './config'; +import { AwsS3Config } from './types'; +import { S3 } from 'aws-sdk'; +import { ListObjectsV2Output } from 'aws-sdk/clients/s3'; +import * as uuid from 'uuid'; +import { Logger } from 'winston'; + +// TODO: event-based updates using S3 events (+ queue like SQS)? +/** + * Provider which discovers catalog files (any name) within an S3 bucket. + * + * Use `AwsS3EntityProvider.fromConfig(...)` to create instances. + * + * @public + */ +export class AwsS3EntityProvider implements EntityProvider { + private readonly logger: Logger; + private readonly s3: S3; + private readonly scheduleFn: () => Promise; + private connection?: EntityProviderConnection; + + static fromConfig( + configRoot: Config, + options: { + logger: Logger; + schedule: TaskRunner; + }, + ): AwsS3EntityProvider[] { + const providerConfigs = readAwsS3Configs(configRoot); + + // Even though the awsS3 integration allows a config array + // there is no *real* support for multiple configs. + // Usually, there will be just the integration for the default host. + // In case, a config custom endpoint is used, the host from this endpoint + // will be extracted and used as host (e.g., localhost when used with LocalStack) + // and the default integration will be added as second integration. + // In this case, we still want the first one though, but have no means to select it + // just from the bucket name (and region). + const integration = ScmIntegrations.fromConfig(configRoot).awsS3.list()[0]; + if (!integration) { + throw new Error('No integration found for awsS3'); + } + + return providerConfigs.map( + providerConfig => + new AwsS3EntityProvider( + providerConfig, + integration, + options.logger, + options.schedule, + ), + ); + } + + private constructor( + private readonly config: AwsS3Config, + private readonly integration: AwsS3Integration, + logger: Logger, + schedule: TaskRunner, + ) { + this.logger = logger.child({ + target: this.getProviderName(), + }); + + this.s3 = new S3({ + apiVersion: '2006-03-01', + credentials: AwsCredentials.create( + integration.config, + 'backstage-aws-s3-provider', + ), + endpoint: integration.config.endpoint, + region: this.config.region, + s3ForcePathStyle: integration.config.s3ForcePathStyle, + }); + + this.scheduleFn = this.createScheduleFn(schedule); + } + + private createScheduleFn(schedule: TaskRunner): () => Promise { + return async () => { + const taskId = `${this.getProviderName()}:refresh`; + return schedule.run({ + id: taskId, + fn: async () => { + const logger = this.logger.child({ + class: AwsS3EntityProvider.prototype.constructor.name, + taskId, + taskInstanceId: uuid.v4(), + }); + + try { + await this.refresh(logger); + } catch (error) { + logger.error(error); + } + }, + }); + }; + } + + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.getProviderName} */ + getProviderName(): string { + return `awsS3-provider:${this.config.id}`; + } + + /** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */ + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection; + await this.scheduleFn(); + } + + async refresh(logger: Logger) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + logger.info('Discovering AWS S3 objects'); + + const keys = await this.listAllObjectKeys(); + logger.info(`Discovered ${keys.length} AWS S3 objects`); + + const locations = keys.map(key => this.createLocationSpec(key)); + + await this.connection.applyMutation({ + type: 'full', + entities: locations.map(location => { + return { + locationKey: this.getProviderName(), + entity: locationSpecToLocationEntity({ location }), + }; + }), + }); + + logger.info(`Committed ${locations.length} Locations for AWS S3 objects`); + } + + private async listAllObjectKeys(): Promise { + const keys: string[] = []; + + let continuationToken: string | undefined = undefined; + let output: ListObjectsV2Output; + do { + const request = this.s3.listObjectsV2({ + Bucket: this.config.bucketName, + ContinuationToken: continuationToken, + Prefix: this.config.prefix, + }); + + output = await request.promise(); + if (output.Contents) { + output.Contents.forEach(item => { + if (item.Key && !item.Key.endsWith('/')) { + keys.push(item.Key); + } + }); + } + continuationToken = output.NextContinuationToken; + } while (continuationToken); + + return keys; + } + + private createLocationSpec(key: string): LocationSpec { + return { + type: 'url', + target: this.createObjectUrl(key), + presence: 'required', + }; + } + + private createObjectUrl(key: string): string { + const bucketName = this.config.bucketName; + const endpoint = this.integration.config.endpoint; + + if (endpoint) { + if (endpoint.startsWith(`https://${bucketName}.`)) { + return `${endpoint}/${key}`; + } + + return `${endpoint}/${bucketName}/${key}`; + } + + return `https://${bucketName}.s3.${this.config.region}.amazonaws.com/${key}`; + } +} diff --git a/plugins/catalog-backend-module-aws/src/providers/config.test.ts b/plugins/catalog-backend-module-aws/src/providers/config.test.ts new file mode 100644 index 0000000000..534d4da35e --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/config.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { readAwsS3Configs } from './config'; + +describe('readAwsS3Configs', () => { + it('reads single provider config', () => { + const provider = { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }; + const config = { + catalog: { + providers: { + awsS3: provider, + }, + }, + }; + + const actual = readAwsS3Configs(new ConfigReader(config)); + + expect(actual).toHaveLength(1); + expect(actual[0]).toEqual({ + ...provider, + id: 'default', + }); + }); + + it('reads all provider configs', () => { + const provider1 = { + bucketName: 'bucket-1', + region: 'us-east-1', + prefix: 'sub/dir/', + }; + const provider2 = { + bucketName: 'bucket-2', + region: 'eu-west-1', + }; + const provider3 = { + bucketName: 'bucket-3', + }; + const config = { + catalog: { + providers: { + awsS3: { provider1, provider2, provider3 }, + }, + }, + }; + + const actual = readAwsS3Configs(new ConfigReader(config)); + + expect(actual).toHaveLength(3); + expect(actual[0]).toEqual({ + ...provider1, + id: 'provider1', + }); + expect(actual[1]).toEqual({ + ...provider2, + id: 'provider2', + }); + expect(actual[2]).toEqual({ + ...provider3, + id: 'provider3', + }); + }); + + it('fails if bucketName is missing', () => { + const provider = { + region: 'us-east-1', + }; + const config = { + catalog: { + providers: { + awsS3: { provider }, + }, + }, + }; + + expect(() => readAwsS3Configs(new ConfigReader(config))).toThrow( + "Missing required config value at 'catalog.providers.awsS3.provider.bucketName'", + ); + }); +}); diff --git a/plugins/catalog-backend-module-aws/src/providers/config.ts b/plugins/catalog-backend-module-aws/src/providers/config.ts new file mode 100644 index 0000000000..5fdf2351c3 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/config.ts @@ -0,0 +1,55 @@ +/* + * 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 { Config } from '@backstage/config'; +import { AwsS3Config } from './types'; + +const DEFAULT_PROVIDER_ID = 'default'; + +export function readAwsS3Configs(config: Config): AwsS3Config[] { + const configs: AwsS3Config[] = []; + + const providerConfigs = config.getOptionalConfig('catalog.providers.awsS3'); + if (!providerConfigs) { + return configs; + } + + if (providerConfigs.has('bucketName')) { + // simple/single config variant + configs.push(readAwsS3Config(DEFAULT_PROVIDER_ID, providerConfigs)); + + return configs; + } + + for (const id of providerConfigs.keys()) { + configs.push(readAwsS3Config(id, providerConfigs.getConfig(id))); + } + + return configs; +} + +function readAwsS3Config(id: string, config: Config): AwsS3Config { + const bucketName = config.getString('bucketName'); + const region = config.getOptionalString('region'); + const prefix = config.getOptionalString('prefix'); + + return { + id, + bucketName, + region, + prefix, + }; +} diff --git a/plugins/catalog-backend-module-aws/src/providers/index.ts b/plugins/catalog-backend-module-aws/src/providers/index.ts new file mode 100644 index 0000000000..7bc8b4f265 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { AwsS3EntityProvider } from './AwsS3EntityProvider'; diff --git a/plugins/catalog-backend-module-aws/src/providers/types.ts b/plugins/catalog-backend-module-aws/src/providers/types.ts new file mode 100644 index 0000000000..3508bf919c --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/providers/types.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export type AwsS3Config = { + id: string; + bucketName: string; + prefix?: string; + region?: string; +}; diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index 1fd5fc3f19..e6429152e4 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -72,6 +72,9 @@ catalog: # Optional search for groups, see Microsoft Graph API for the syntax # See https://docs.microsoft.com/en-us/graph/search-query-parameter groupSearch: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' + # Optional select for groups, this will allow you work with schemaExtensions in order to add extra information to your groups that can be used on you custom groupTransformers + # See https://docs.microsoft.com/en-us/graph/api/resources/schemaextension?view=graph-rest-1.0 + groupSelect: ['id', 'displayName', 'description'] ``` `userFilter` and `userGroupMemberFilter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown. diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 2e741e65fe..2239ec1332 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -182,6 +182,7 @@ export type MicrosoftGraphProviderConfig = { groupExpand?: string; groupFilter?: string; groupSearch?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; }; @@ -219,6 +220,7 @@ export function readMicrosoftGraphOrg( groupExpand?: string; groupSearch?: string; groupFilter?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index db5ab81c4b..cf00713e72 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -79,6 +79,14 @@ export interface Config { * E.g. "\"displayName:-team\"" would only match groups which contain '-team' */ groupSearch?: string; + + /** + * The fields to be fetched on query. + * + * E.g. ["id", "displayName", "description"] + */ + groupSelect?: string[]; + /** * The filter to apply to extract users by groups memberships. * diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index 34796fb29a..e310fa544f 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -56,6 +56,7 @@ describe('readMicrosoftGraphConfig', () => { userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', + groupSelect: ['id', 'displayName', 'description'], groupFilter: 'securityEnabled eq false', }, ], @@ -71,6 +72,7 @@ describe('readMicrosoftGraphConfig', () => { userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', + groupSelect: ['id', 'displayName', 'description'], groupFilter: 'securityEnabled eq false', }, ]; diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 441892531a..aa2f2d6ee2 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -88,6 +88,14 @@ export type MicrosoftGraphProviderConfig = { * E.g. "\"displayName:-team\"" would only match groups which contain '-team' */ groupSearch?: string; + + /** + * The fields to be fetched on query. + * + * E.g. ["id", "displayName", "description"] + */ + groupSelect?: string[]; + /** * By default, the Microsoft Graph API only provides the basic feature set * for querying. Certain features are limited to advanced query capabilities @@ -145,6 +153,7 @@ export function readMicrosoftGraphConfig( ); } + const groupSelect = providerConfig.getOptionalStringArray('groupSelect'); const queryMode = providerConfig.getOptionalString('queryMode'); if ( queryMode !== undefined && @@ -167,6 +176,7 @@ export function readMicrosoftGraphConfig( groupExpand, groupFilter, groupSearch, + groupSelect, queryMode, }); } diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index e9760f7698..de7f3e49e0 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -343,6 +343,7 @@ export async function readMicrosoftGraphGroups( groupExpand?: string; groupFilter?: string; groupSearch?: string; + groupSelect?: string[]; groupTransformer?: GroupTransformer; organizationTransformer?: OrganizationTransformer; }, @@ -373,6 +374,7 @@ export async function readMicrosoftGraphGroups( expand: options?.groupExpand, search: options?.groupSearch, filter: options?.groupFilter, + select: options?.groupSelect, }, options?.queryMode, )) { @@ -535,6 +537,7 @@ export async function readMicrosoftGraphOrg( groupExpand?: string; groupSearch?: string; groupFilter?: string; + groupSelect?: string[]; queryMode?: 'basic' | 'advanced'; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; @@ -571,6 +574,7 @@ export async function readMicrosoftGraphOrg( queryMode: options.queryMode, groupSearch: options.groupSearch, groupFilter: options.groupFilter, + groupSelect: options.groupSelect, groupTransformer: options.groupTransformer, organizationTransformer: options.organizationTransformer, }); diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts index dfd0a7782e..d96faf8e03 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.ts @@ -176,7 +176,6 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider { const provider = this.options.provider; const { markReadComplete } = trackProgress(logger); const client = MicrosoftGraphClient.create(this.options.provider); - const { users, groups } = await readMicrosoftGraphOrg( client, provider.tenantId, @@ -186,6 +185,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider { userGroupMemberSearch: provider.userGroupMemberSearch, groupFilter: provider.groupFilter, groupSearch: provider.groupSearch, + groupSelect: provider.groupSelect, queryMode: provider.queryMode, groupTransformer: this.options.groupTransformer, userTransformer: this.options.userTransformer, diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts index 5d8619e874..5d0e6b2ec7 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts @@ -113,6 +113,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { groupExpand: provider.groupExpand, groupFilter: provider.groupFilter, groupSearch: provider.groupSearch, + groupSelect: provider.groupSelect, queryMode: provider.queryMode, userTransformer: this.userTransformer, groupTransformer: this.groupTransformer, diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index d44e744bb6..a770d6289e 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -22,6 +22,7 @@ import { Permission } from '@backstage/plugin-permission-common'; import { PermissionAuthorizer } 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 { PermissionRule } from '@backstage/plugin-permission-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -181,7 +182,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator | PermissionAuthorizer; }; // @alpha diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts index 98bcfcd8d4..cf082c271c 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollatorFactory.ts @@ -23,11 +23,7 @@ import { CatalogClient, GetEntitiesRequest, } from '@backstage/catalog-client'; -import { - Entity, - stringifyEntityRef, - UserEntity, -} from '@backstage/catalog-model'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { @@ -36,6 +32,7 @@ import { } from '@backstage/plugin-catalog-common'; import { Permission } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; +import { getDocumentText } from './util'; /** @public */ export type DefaultCatalogCollatorFactoryOptions = { @@ -100,24 +97,6 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { return formatted.toLowerCase(); } - private isUserEntity(entity: Entity): entity is UserEntity { - return entity.kind.toLocaleUpperCase('en-US') === 'USER'; - } - - private getDocumentText(entity: Entity): string { - let documentText = entity.metadata.description || ''; - if (this.isUserEntity(entity)) { - if (entity.spec?.profile?.displayName && documentText) { - // combine displayName and description - const displayName = entity.spec?.profile?.displayName; - documentText = displayName.concat(' : ', documentText); - } else { - documentText = entity.spec?.profile?.displayName || documentText; - } - } - return documentText; - } - private async *execute(): AsyncGenerator { const { token } = await this.tokenManager.getToken(); let entitiesRetrieved = 0; @@ -150,7 +129,7 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { kind: entity.kind, name: entity.metadata.name, }), - text: this.getDocumentText(entity), + text: getDocumentText(entity), componentType: entity.spec?.type?.toString() || 'other', type: entity.spec?.type?.toString() || 'other', namespace: entity.metadata.namespace || 'default', diff --git a/plugins/catalog-backend/src/search/util.test.ts b/plugins/catalog-backend/src/search/util.test.ts new file mode 100644 index 0000000000..d2d1776338 --- /dev/null +++ b/plugins/catalog-backend/src/search/util.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { + ComponentEntity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; +import { getDocumentText } from './util'; + +describe('getDocumentText', () => { + describe('kind is not User or Group', () => { + test('contains description if set', () => { + const entity = createComponent(); + entity.metadata.description = 'The expected description'; + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description is not set', () => { + const entity = createComponent(); + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); + + describe('kind is User', () => { + test('contains display name if set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + }); + + test('contains description if set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('contains both description and display name if both are set', () => { + const entity = createUser(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description and display name are not set', () => { + const entity = createUser(); + delete entity.metadata.description; + delete entity.spec.profile?.displayName; + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); + + describe('kind is Group', () => { + test('contains display name if set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + }); + + test('contains description if set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.metadata.description); + }); + + test('contains both description and display name if both are set', () => { + const entity = createGroup(); + const actual = getDocumentText(entity); + expect(actual).toContain(entity.spec.profile?.displayName); + expect(actual).toContain(entity.metadata.description); + }); + + test('is empty if description and display name are not set', () => { + const entity = createGroup(); + delete entity.metadata.description; + delete entity.spec.profile?.displayName; + const actual = getDocumentText(entity); + expect(actual).toEqual(''); + }); + }); +}); + +function createGroup(): GroupEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'group-1', + description: 'The expected description', + }, + spec: { + type: 'team', + profile: { + displayName: 'Group 1', + }, + children: [], + }, + }; +} + +function createUser(): UserEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'user-1', + description: 'The expected description', + }, + spec: { + profile: { + displayName: 'User 1', + }, + }, + }; +} + +function createComponent(): ComponentEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-1', + }, + spec: { + lifecycle: 'experimental', + owner: 'someone', + type: 'service', + }, + }; +} diff --git a/plugins/catalog-backend/src/search/util.ts b/plugins/catalog-backend/src/search/util.ts new file mode 100644 index 0000000000..7e7ce7064e --- /dev/null +++ b/plugins/catalog-backend/src/search/util.ts @@ -0,0 +1,37 @@ +/* + * 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 { Entity, UserEntity } from '@backstage/catalog-model'; + +function isUserEntity(entity: Entity): entity is UserEntity { + return entity.kind.toLocaleUpperCase('en-US') === 'USER'; +} + +function isGroupEntity(entity: Entity): entity is UserEntity { + return entity.kind.toLocaleUpperCase('en-US') === 'GROUP'; +} + +export function getDocumentText(entity: Entity): string { + const documentTexts: string[] = []; + documentTexts.push(entity.metadata.description || ''); + + if (isUserEntity(entity) || isGroupEntity(entity)) { + if (entity.spec?.profile?.displayName) { + documentTexts.push(entity.spec.profile.displayName); + } + } + return documentTexts.join(' : '); +} diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index b29e1121b3..51edd903e6 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -30,6 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => { }; const fakePermissionApi = { authorize: jest.fn(), + authorizeConditional: jest.fn(), }; const createCatalog = (...rules: CatalogPermissionRule[]) => @@ -45,7 +46,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('entities', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -61,7 +62,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -78,7 +79,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); @@ -98,7 +99,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -113,7 +114,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('throws error on CONDITIONAL authorization that evaluates to 0 entities', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -132,7 +133,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on CONDITIONAL authorization that evaluates to nonzero entities', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -158,7 +159,7 @@ describe('AuthorizedEntitiesCatalog', () => { { kind: 'component', namespace: 'default', name: 'my-component' }, ], }); - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = new AuthorizedEntitiesCatalog( @@ -252,7 +253,7 @@ describe('AuthorizedEntitiesCatalog', () => { describe('facets', () => { it('returns empty response on DENY', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.DENY }, ]); const catalog = createCatalog(); @@ -268,7 +269,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, @@ -286,7 +287,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); it('calls underlying catalog method on ALLOW', async () => { - fakePermissionApi.authorize.mockResolvedValue([ + fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.ALLOW }, ]); const catalog = createCatalog(); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 2f94e23303..0e861a39e1 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -22,7 +22,7 @@ import { import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { ConditionTransformer } from '@backstage/plugin-permission-node'; import { @@ -39,13 +39,13 @@ import { basicEntityFilter } from './request/basicEntityFilter'; export class AuthorizedEntitiesCatalog implements EntitiesCatalog { constructor( private readonly entitiesCatalog: EntitiesCatalog, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, private readonly transformConditions: ConditionTransformer, ) {} async entities(request?: EntitiesRequest): Promise { const authorizeDecision = ( - await this.permissionApi.authorize( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) @@ -78,7 +78,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { options?: { authorizationToken?: string }, ): Promise { const authorizeResponse = ( - await this.permissionApi.authorize( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityDeletePermission }], { token: options?.authorizationToken }, ) @@ -155,7 +155,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { async facets(request: EntityFacetsRequest): Promise { const authorizeDecision = ( - await this.permissionApi.authorize( + await this.permissionApi.authorizeConditional( [{ permission: catalogEntityReadPermission }], { token: request?.authorizationToken }, ) diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index b27ca92924..dc8719bb0e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -27,6 +27,7 @@ describe('AuthorizedLocationService', () => { }; const fakePermissionApi = { authorize: jest.fn(), + authorizeConditional: jest.fn(), }; const mockAllow = () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index 077f56c737..a73597649e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -24,14 +24,14 @@ import { } from '@backstage/plugin-catalog-common'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { LocationInput, LocationService } from './types'; export class AuthorizedLocationService implements LocationService { constructor( private readonly locationService: LocationService, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, ) {} async createLocation( diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts index 82bedec573..5d0a192470 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts @@ -25,6 +25,7 @@ describe('AuthorizedRefreshService', () => { }; const permissionApi = { authorize: jest.fn(), + query: jest.fn(), }; afterEach(() => { diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts index 17dfb58c54..8634fbf86d 100644 --- a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts @@ -18,14 +18,14 @@ import { NotAllowedError } from '@backstage/errors'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { RefreshOptions, RefreshService } from './types'; export class AuthorizedRefreshService implements RefreshService { constructor( private readonly service: RefreshService, - private readonly permissionApi: PermissionAuthorizer, + private readonly permissionApi: PermissionEvaluator, ) {} async refresh(options: RefreshOptions) { diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 075fcd6a5d..f5f00cd0fc 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -79,7 +79,11 @@ import { CatalogPermissionRule, permissionRules as catalogPermissionRules, } from '../permissions/rules'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { createConditionTransformer, createPermissionIntegrationRouter, @@ -95,7 +99,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator | PermissionAuthorizer; }; /** @@ -376,9 +380,20 @@ export class CatalogBuilder { policy, }); const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient); + + let permissionEvaluator: PermissionEvaluator; + if ('query' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', + ); + permissionEvaluator = toPermissionEvaluator(permissions); + } + const entitiesCatalog = new AuthorizedEntitiesCatalog( unauthorizedEntitiesCatalog, - permissions, + permissionEvaluator, createConditionTransformer(this.permissionRules), ); const permissionIntegrationRouter = createPermissionIntegrationRouter({ @@ -428,11 +443,11 @@ export class CatalogBuilder { this.locationAnalyzer ?? new RepoLocationAnalyzer(logger, integrations); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator), - permissions, + permissionEvaluator, ); const refreshService = new AuthorizedRefreshService( new DefaultRefreshService({ database: processingDatabase }), - permissions, + permissionEvaluator, ); const router = await createRouter({ entitiesCatalog, diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts index c14b6523c0..28e4863e8a 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.ts @@ -95,18 +95,13 @@ export function useUnregisterEntityDialogState( ); }, [catalogApi, entity]); - // Unregisters the underlying location and removes all of the entities that - // are spawned from it. Can only ever be called when the prerequisites have - // finished loading successfully, and if there was a matching location. + // Unregisters the underlying location which will remove all of the entities that are spawned from + // it. Can only ever be called when the prerequisites have finished loading successfully, and if + // there was a matching location. const unregisterLocation = useCallback( async function unregisterLocationFn() { - const { location, colocatedEntities } = prerequisites.value!; + const { location } = prerequisites.value!; await catalogApi.removeLocationById(location!.id); - await Promise.allSettled( - colocatedEntities.map(e => - catalogApi.removeEntityByUid(e.metadata.uid!), - ), - ); }, [catalogApi, prerequisites], ); diff --git a/plugins/cicd-statistics-module-gitlab/.eslintrc.js b/plugins/cicd-statistics-module-gitlab/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md new file mode 100644 index 0000000000..0b11f6950a --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -0,0 +1,38 @@ +# cicd-statistics-module-gitlab + +This is an extension module to the `cicd-statistics` plugin, providing a `CicdStatisticsApiGitlab` that you can use to extract the CI/CD statistics from your Gitlab repository. + +## Getting started + +1. Install the `cicd-statistics` and `cicd-statistics-module-gitlab` plugins in the `app` package. + +2. Configure your ApiFactory: + +```tsx +// packages/app/src/apis.ts +import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; + +import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; +import { CicdStatisticsApiGitlab } from '@backstage plugin-cicd-statistics-module-gitlab'; + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: cicdStatisticsApiRef, + deps: { gitlabAuthApi: gitlabAuthApiRef }, + factory({ gitlabAuthApi }) { + return new CicdStatisticsApiGitlab(gitlabAuthApi); + }, + }), +]; +``` + +3. Add the component to your EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EntityCicdStatisticsContent } from '@backstage/plugin-cicd-statistics'; + + + +; +``` diff --git a/plugins/cicd-statistics-module-gitlab/api-report.md b/plugins/cicd-statistics-module-gitlab/api-report.md new file mode 100644 index 0000000000..5e07255f54 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/plugin-cicd-statistics-module-gitlab" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CicdConfiguration } from '@backstage/plugin-cicd-statistics'; +import { CicdState } from '@backstage/plugin-cicd-statistics'; +import { CicdStatisticsApi } from '@backstage/plugin-cicd-statistics'; +import { Entity } from '@backstage/catalog-model'; +import { FetchBuildsOptions } from '@backstage/plugin-cicd-statistics'; +import { Gitlab } from '@gitbeaker/browser'; +import { OAuthApi } from '@backstage/core-plugin-api'; + +// @public +export class CicdStatisticsApiGitlab implements CicdStatisticsApi { + constructor(gitLabAuthApi: OAuthApi); + // (undocumented) + createGitlabApi(entity: Entity, scopes: string[]): Promise; + // (undocumented) + fetchBuilds(options: FetchBuildsOptions): Promise; + // (undocumented) + getConfiguration(): Promise>; +} + +// @public +export type GitlabClient = { + api: InstanceType; + owner: string; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/cicd-statistics-module-gitlab/package.json b/plugins/cicd-statistics-module-gitlab/package.json new file mode 100644 index 0000000000..b36bcf2476 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/plugin-cicd-statistics-module-gitlab", + "description": "CI/CD Statistics plugin module; Gitlab CICD", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin-module" + }, + "keywords": [ + "backstage", + "cicd statistics", + "gitlab" + ], + "scripts": { + "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/plugin-cicd-statistics": "^0.1.6-next.0", + "@gitbeaker/browser": "^35.6.0", + "@gitbeaker/core": "^35.6.0", + "luxon": "^2.0.2", + "p-limit": "^4.0.0", + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/catalog-model": "^1.0.1-next.1" + }, + "devDependencies": { + "@backstage/cli": "^0.17.0-next.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts new file mode 100644 index 0000000000..7439d1800c --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -0,0 +1,159 @@ +/* + * 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 { + CicdStatisticsApi, + CicdState, + CicdConfiguration, + Build, + FetchBuildsOptions, + Stage, +} from '@backstage/plugin-cicd-statistics'; +import { Gitlab } from '@gitbeaker/browser'; +import { OAuthApi } from '@backstage/core-plugin-api'; +import limiterFactory from 'p-limit'; +import { Entity, getEntitySourceLocation } from '@backstage/catalog-model'; +import { pipelinesToBuilds, jobsToStages } from './utils'; + +/** + * This type represents a initialized gitlab client with gitbeaker + * + * @public + */ +export type GitlabClient = { + /* the actual API of gitbeaker */ + api: InstanceType; + /* the owner the repository, retrieved from the entity source location */ + owner: string; +}; + +/** + * Extracts the CI/CD statistics from a Gitlab repository + * + * @public + */ +export class CicdStatisticsApiGitlab implements CicdStatisticsApi { + readonly #gitLabAuthApi: OAuthApi; + + constructor(gitLabAuthApi: OAuthApi) { + this.#gitLabAuthApi = gitLabAuthApi; + } + + public async createGitlabApi( + entity: Entity, + scopes: string[], + ): Promise { + const entityInfo = getEntitySourceLocation(entity); + const url = new URL(entityInfo.target); + const owner = url.pathname.split('/-/blob/')[0]; + const oauthToken = await this.#gitLabAuthApi.getAccessToken(scopes); + return { + api: new Gitlab({ + host: `https://${url.host}`, + oauthToken, + }), + owner: owner.substring(1), + }; + } + + private static async updateBuildWithStages( + gitbeaker: InstanceType, + owner: string, + build: Build, + ): Promise { + const jobs = await gitbeaker.Jobs.showPipelineJobs( + owner, + parseInt(build.id, 10), + ); + const stages = jobsToStages(jobs); + return stages; + } + + private static async getDurationOfBuild( + gitbeaker: InstanceType, + owner: string, + build: Build, + ): Promise { + const pipeline = await gitbeaker.Pipelines.show( + owner, + parseInt(build.id, 10), + ); + return parseInt(pipeline.duration as string, 10) * 1000; + } + + private static async getDefaultBranch( + gitbeaker: InstanceType, + owner: string, + ): Promise { + const branches = await gitbeaker.Branches.all(owner); + return branches.find(branch => branch.default)?.name; + } + + public async fetchBuilds(options: FetchBuildsOptions): Promise { + const { + entity, + updateProgress, + timeFrom, + timeTo, + filterStatus = ['all'], + filterType = 'all', + } = options; + const { api, owner } = await this.createGitlabApi(entity, ['read_api']); + updateProgress(0, 0, 0); + + const branch = + filterType === 'master' + ? await CicdStatisticsApiGitlab.getDefaultBranch(api, owner) + : undefined; + const pipelines = await api.Pipelines.all(owner, { + perPage: 25, + updated_after: timeFrom.toISOString(), + updated_before: timeTo.toISOString(), + ref: branch, + }); + + const limiter = limiterFactory(10); + const builds = pipelinesToBuilds(pipelines).map(async build => ({ + ...build, + duration: await limiter(() => + CicdStatisticsApiGitlab.getDurationOfBuild(api, owner, build), + ), + stages: await limiter(() => + CicdStatisticsApiGitlab.updateBuildWithStages(api, owner, build), + ), + })); + const promisedBuilds = (await Promise.all(builds)).filter(b => + filterStatus.includes(b.status), + ); + + return { builds: promisedBuilds }; + } + + public async getConfiguration(): Promise> { + return { + availableStatuses: [ + 'succeeded', + 'failed', + 'enqueued', + 'running', + 'aborted', + 'stalled', + 'expired', + 'unknown', + ] as const, + }; + } +} diff --git a/plugins/cicd-statistics-module-gitlab/src/api/index.ts b/plugins/cicd-statistics-module-gitlab/src/api/index.ts new file mode 100644 index 0000000000..21f5f622a6 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './gitlab'; diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts new file mode 100644 index 0000000000..fda5e68540 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { pipelinesToBuilds, jobsToStages } from './utils'; +import { Types } from '@gitbeaker/core'; + +const pipelineMock: Types.PipelineSchema[] = [ + { + id: 1000, + iid: 1, + project_id: 1, + sha: 'd40', + ref: 'main', + status: 'success', + source: 'schedule', + created_at: '2022-03-30T13:03:09.846Z', + updated_at: '2022-03-30T13:07:49.248Z', + web_url: 'https://gitlab.com/backstage/app/-/pipelines/1000', + user: { + name: 'user', + avatar_url: 'avatar_user', + }, + }, +]; + +// cast to unknown so we can omit a lot unused vars but also keep the type +const jobMock = [ + { + id: 6962883, + status: 'success', + stage: 'build', + name: 'docker', + ref: 'refs/merge-requests/209/train', + tag: false, + allow_failure: false, + created_at: new Date('2022-03-30T08:35:15.394Z'), + started_at: new Date('2022-03-30T08:35:16.532Z'), + finished_at: new Date('2022-03-30T08:35:37.731Z'), + duration: 21.199465, + queued_duration: 0.976313, + }, +] as unknown as Array; + +describe('util functionality', () => { + it('transforms the pipeline object to the build object', () => { + const builds = pipelinesToBuilds(pipelineMock); + expect(builds).toEqual([ + { + id: '1000', + status: 'succeeded', + branchType: 'master', + duration: 0, + requestedAt: new Date('2022-03-30T13:03:09.846Z'), + triggeredBy: 'internal', + stages: [], + }, + ]); + }); + + it('transforms the job object to the stage object', () => { + const stages = jobsToStages(jobMock); + expect(stages).toEqual([ + { + name: 'build', + status: 'succeeded', + duration: 21199.465, + stages: [ + { + name: 'docker', + status: 'succeeded', + duration: 21199.465, + }, + ], + }, + ]); + }); +}); diff --git a/plugins/cicd-statistics-module-gitlab/src/api/utils.ts b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts new file mode 100644 index 0000000000..5f1d1cbeba --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/api/utils.ts @@ -0,0 +1,107 @@ +/* + * 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 { + Build, + FilterStatusType, + TriggerReason, + Stage, +} from '@backstage/plugin-cicd-statistics'; +import { Types } from '@gitbeaker/core'; + +const statusMap: Record = { + manual: 'unknown', + created: 'enqueued', + waiting_for_resource: 'stalled', + preparing: 'unknown', + pending: 'scheduled', + running: 'running', + success: 'succeeded', + failed: 'failed', + canceled: 'aborted', + skipped: 'aborted', + scheduled: 'scheduled', +}; + +// all gitlab trigger reasons can be found here: https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines +const triggerReasonMap: Record = { + push: 'scm', + trigger: 'manual', + merge_request_event: 'scm', + schedule: 'internal', +}; + +/** + * Takes the Pipeline object from Gitlab and transforms it to the Build object + * + * @param pipelines - Pipeline object that gets returned from Gitlab + * + * @public + */ +export function pipelinesToBuilds( + pipelines: Array, +): Build[] { + return pipelines.map(pipeline => { + return { + id: pipeline.id.toString(), + status: statusMap[pipeline.status], + branchType: 'master', + duration: 0, // will get filled in later in a seperate API call + requestedAt: new Date(pipeline.created_at), + triggeredBy: triggerReasonMap[pipeline.source as string] ?? 'other', + stages: [], + }; + }); +} + +/** + * Takes the Job object from Gitlab and transforms it to the Stage object + * + * @param jobs - Job object that gets returned from Gitlab + * + * @public + * + * @remarks + * + * The Gitlab API can only return the job (sub-stage) of a pipeline and not a whole stage + * The job does return from which stage it is + * So, for the stage name we use the parent stage name and in the sub-stages we add the current job + * In the end the cicd-statistics plugin will calculate the right durations for each stage + * + * Furthermore, we don't add the job to the sub-stage if it is has the same name as the parent stage + * We then assume that the stage has no sub-stages + */ +export function jobsToStages(jobs: Array): Stage[] { + return jobs.map(job => { + const status = statusMap[job.status] ? statusMap[job.status] : 'unknown'; + const duration = job.duration ? ((job.duration * 1000) as number) : 0; + return { + name: job.stage, + status, + duration, + stages: + job.name !== job.stage + ? [ + { + name: job.name, + status, + duration, + }, + ] + : [], + }; + }); +} diff --git a/plugins/cicd-statistics-module-gitlab/src/index.ts b/plugins/cicd-statistics-module-gitlab/src/index.ts new file mode 100644 index 0000000000..c8d53d5bd2 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { CicdStatisticsApiGitlab } from './api'; +export type { GitlabClient } from './api'; diff --git a/plugins/cicd-statistics-module-gitlab/src/setupTests.ts b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts new file mode 100644 index 0000000000..8b9b6bd586 --- /dev/null +++ b/plugins/cicd-statistics-module-gitlab/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export {}; diff --git a/plugins/gocd/docs/gocd-plugin-screenshot.png b/plugins/gocd/docs/gocd-plugin-screenshot.png index 8ddc786fa5..d1e2d28c4e 100644 Binary files a/plugins/gocd/docs/gocd-plugin-screenshot.png and b/plugins/gocd/docs/gocd-plugin-screenshot.png differ diff --git a/plugins/gocd/src/api/gocdApi.model.ts b/plugins/gocd/src/api/gocdApi.model.ts index 8bb9a481e6..d76a6fdc19 100644 --- a/plugins/gocd/src/api/gocdApi.model.ts +++ b/plugins/gocd/src/api/gocdApi.model.ts @@ -101,6 +101,23 @@ export enum GoCdBuildResultStatus { pending, } +export const toBuildResultStatus = (status: string): GoCdBuildResultStatus => { + switch (status.toLocaleLowerCase('en-US')) { + case 'passed': + return GoCdBuildResultStatus.successful; + case 'failed': + return GoCdBuildResultStatus.error; + case 'aborted': + return GoCdBuildResultStatus.aborted; + case 'building': + return GoCdBuildResultStatus.running; + case 'pending': + return GoCdBuildResultStatus.pending; + default: + return GoCdBuildResultStatus.aborted; + } +}; + export interface GoCdBuildStageResult { status: GoCdBuildResultStatus; text: string; diff --git a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx index eb8b311896..abaaad8506 100644 --- a/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx +++ b/plugins/gocd/src/components/GoCdBuildsComponent/GoCdBuildsComponent.tsx @@ -27,6 +27,7 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; import { gocdApiRef } from '../../plugin'; import { GoCdBuildsTable } from '../GoCdBuildsTable/GoCdBuildsTable'; +import { GoCdBuildsInsights } from '../GoCdBuildsInsights/GoCdBuildsInsights'; import { GoCdApiError, PipelineHistory } from '../../api/gocdApi.model'; import { Item, Select } from '../Select'; @@ -120,6 +121,11 @@ export const GoCdBuildsComponent = (): JSX.Element => { items={getSelectionItems(rawPipelines)} /> + ', () => { + it('renders without exploding', () => { + const { getByText } = render(); + expect(getByText('Run Frequency')).toBeInTheDocument(); + expect(getByText('Mean Time to Recovery')).toBeInTheDocument(); + expect(getByText('Mean Time Between Failures')).toBeInTheDocument(); + expect(getByText('Failure Rate')).toBeInTheDocument(); + }); + + it('renders mean time to recovery', () => { + const { getByText } = render(); + expect(getByText('-1d -3h -21m -40s')).toBeInTheDocument(); + }); + + it('renders mean time between failures', () => { + const { getByText } = render(); + expect(getByText('2d 8h 23m 20s')).toBeInTheDocument(); + }); + + it('renders failure rate', () => { + const { getByText } = render(); + expect(getByText('11.11%')).toBeInTheDocument(); + expect(getByText('(2 out of 18 jobs)')).toBeInTheDocument(); + }); + + it('renders nothing when loading', () => { + const props = { + loading: true, + pipelineHistory: undefined, + error: undefined, + }; + const { getByTestId } = render(); + + expect(() => getByTestId('GoCdBuildsInsightsBox')).toThrow(); + }); +}); diff --git a/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx new file mode 100644 index 0000000000..20336fe6ac --- /dev/null +++ b/plugins/gocd/src/components/GoCdBuildsInsights/GoCdBuildsInsights.tsx @@ -0,0 +1,255 @@ +/* + * Copyright 2021 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 { groupBy, mean } from 'lodash'; +import { + PipelineHistory, + Job, + toBuildResultStatus, + GoCdBuildResultStatus, +} from '../../api/gocdApi.model'; +import { + Box, + Grid, + Card, + CardContent, + Tooltip, + Typography, +} from '@material-ui/core'; +import { DateTime, Duration } from 'luxon'; + +export type GoCdBuildsInsightsProps = { + pipelineHistory: PipelineHistory | undefined; + loading: boolean; + error: Error | undefined; +}; + +function runFrequency(pipelineHistory: PipelineHistory): string { + const lastMonth = DateTime.now().minus({ month: 1 }); + const buildLastMonth = pipelineHistory.pipelines + .map(p => p.scheduled_date) + .filter((d): d is number => !!d) + .map(d => DateTime.fromMillis(d)) + .filter(d => d > lastMonth).length; + return `${buildLastMonth} last month`; +} + +function meanTimeBetweenFailures(jobs: Job[]): string { + const timeBetweenFailures: Duration[] = []; + for (let index = 1; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let previousFailedJob: Job | null = null; + for (let j = index - 1; j >= 0; j--) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.error + ) { + previousFailedJob = candidateJob; + break; + } + } + if ( + !previousFailedJob || + !job.scheduled_date || + !previousFailedJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const previousFailedJobDate = DateTime.fromMillis( + previousFailedJob.scheduled_date, + ); + const timeBetweenFailure = failedJobDate.diff(previousFailedJobDate); + timeBetweenFailures.push(timeBetweenFailure); + } + } + return formatMean(timeBetweenFailures); +} + +/** + * Imagine a sequence like: + * S - S - S - F - S - F - F - S - S + * + * We iterate until finding a failure, and then iterate forward + * until we find the first immediate success to then + * calculate the time difference between the scheduling of the jobs. + */ +function meanTimeToRecovery(jobs: Job[]): string { + const timeToRecoverIntervals: Duration[] = []; + for (let index = 0; index < jobs.length; index++) { + const job = jobs[index]; + if (!job.result) { + continue; + } + if (toBuildResultStatus(job.result) === GoCdBuildResultStatus.error) { + let nextSuccessfulJob: Job | null = null; + for (let j = index + 1; j < jobs.length; j++) { + const candidateJob = jobs[j]; + if (!candidateJob.result) { + continue; + } + if ( + toBuildResultStatus(candidateJob.result) === + GoCdBuildResultStatus.successful + ) { + nextSuccessfulJob = candidateJob; + break; + } + } + if ( + !nextSuccessfulJob || + !job.scheduled_date || + !nextSuccessfulJob.scheduled_date + ) { + continue; + } + const failedJobDate = DateTime.fromMillis(job.scheduled_date); + const successfulJobDate = DateTime.fromMillis( + nextSuccessfulJob.scheduled_date, + ); + const timeToRecovery = successfulJobDate.diff(failedJobDate); + timeToRecoverIntervals.push(timeToRecovery); + } + } + + return formatMean(timeToRecoverIntervals); +} + +function formatMean(durations: Duration[]): string { + if (durations.length === 0) { + return 'N/A'; + } + + const mttr: Duration = Duration.fromMillis( + mean(durations.map(i => i.milliseconds)), + ); + return mttr.toFormat("d'd' h'h' m'm' s's'"); +} + +function failureRate(jobs: Job[]): { + title: string; + subtitle: string; +} { + const resultGroups = new Map( + Object.entries(groupBy(jobs, 'result')).map(([key, value]) => [ + toBuildResultStatus(key), + value.flat(), + ]), + ); + const failedJobs = resultGroups.get(GoCdBuildResultStatus.error); + if (!failedJobs) { + return { + title: '0', + subtitle: '(no failed jobs found)', + }; + } + + resultGroups.delete(GoCdBuildResultStatus.error); + const nonFailedJobs = Array.from(resultGroups.values()).flat(); + + const totalJobs = failedJobs.length + nonFailedJobs.length; + const percentage = (failedJobs.length / totalJobs) * 100; + const decimalPercentage = (Math.round(percentage * 100) / 100).toFixed(2); + return { + title: `${decimalPercentage}%`, + subtitle: `(${failedJobs.length} out of ${totalJobs} jobs)`, + }; +} + +export const GoCdBuildsInsights = ( + props: GoCdBuildsInsightsProps, +): JSX.Element => { + const { pipelineHistory, loading, error } = props; + + if (loading || error || !pipelineHistory) { + return <>; + } + + // We reverse the array to calculate insights to make sure jobs are ordered + // by their schedule date. + const stages = pipelineHistory.pipelines + .slice() + .reverse() + .map(p => p.stages) + .flat(); + const jobs = stages.map(s => s.jobs).flat(); + + const failureRateObj: { title: string; subtitle: string } = failureRate(jobs); + + return ( + + + + + + + Run Frequency + + {runFrequency(pipelineHistory)} + + + + + + + + + + Mean Time to Recovery + {meanTimeToRecovery(jobs)} + + + + + + + + + + Mean Time Between Failures + + + {meanTimeBetweenFailures(jobs)} + + + + + + + + + + Failure Rate + {failureRateObj.title} + + {failureRateObj.subtitle} + + + + + + + + ); +}; diff --git a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx index 58dfbcca39..498cdc26bf 100644 --- a/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx +++ b/plugins/gocd/src/components/GoCdBuildsTable/GoCdBuildsTable.tsx @@ -22,6 +22,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; import { GoCdBuildResult, GoCdBuildResultStatus, + toBuildResultStatus, PipelineHistory, Pipeline, } from '../../api/gocdApi.model'; @@ -157,23 +158,6 @@ const renderError = (error: Error): JSX.Element => { return {error.message}; }; -const toStageStatus = (status: string): GoCdBuildResultStatus => { - switch (status.toLocaleLowerCase('en-US')) { - case 'passed': - return GoCdBuildResultStatus.successful; - case 'failed': - return GoCdBuildResultStatus.error; - case 'aborted': - return GoCdBuildResultStatus.aborted; - case 'building': - return GoCdBuildResultStatus.running; - case 'pending': - return GoCdBuildResultStatus.pending; - default: - return GoCdBuildResultStatus.aborted; - } -}; - const toBuildResults = ( entity: Entity, builds: Pipeline[], @@ -185,7 +169,7 @@ const toBuildResults = ( id: build.counter, source: `${entitySourceLocation}/commit/${build.build_cause?.material_revisions[0]?.modifications[0].revision}`, stages: build.stages.map(s => ({ - status: toStageStatus(s.status), + status: toBuildResultStatus(s.status), text: s.name, })), buildSlug: `${build.name}/${build.counter}`, diff --git a/plugins/home/README.md b/plugins/home/README.md index a85994b3cb..0cd307b706 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -93,4 +93,4 @@ Additionally, the API is at a very early state, so contributing with additional ### Homepage Templates -We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/plugins/home/src/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion. +We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example to create your own](/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx), and then open a PR with your suggestion. diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 9bd577da7c..a65f8cda5c 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -119,6 +119,19 @@ export const SettingsModal: (props: { children: JSX.Element; }) => JSX.Element; +// Warning: (ae-forgotten-export) The symbol "TemplateBackstageLogoProps" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "TemplateBackstageLogo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const TemplateBackstageLogo: ( + props: TemplateBackstageLogoProps, +) => JSX.Element; + +// Warning: (ae-missing-release-tag) "TemplateBackstageLogoIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const TemplateBackstageLogoIcon: () => JSX.Element; + // @public export const WelcomeTitle: () => JSX.Element; diff --git a/plugins/home/package.json b/plugins/home/package.json index 5358f83b55..aac39c397a 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -38,10 +38,9 @@ "@backstage/core-components": "^0.9.3-next.1", "@backstage/core-plugin-api": "^1.0.0", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-search": "^0.7.5-next.0", "@backstage/plugin-stack-overflow": "^0.1.0-next.0", "@backstage/theme": "^0.2.15", - "@backstage/config": "^0.1.15", + "@backstage/config": "^1.0.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", diff --git a/plugins/home/src/templates/TemplateBackstageLogo.tsx b/plugins/home/src/assets/TemplateBackstageLogo.tsx similarity index 98% rename from plugins/home/src/templates/TemplateBackstageLogo.tsx rename to plugins/home/src/assets/TemplateBackstageLogo.tsx index edce9bb02e..9088cfa58c 100644 --- a/plugins/home/src/templates/TemplateBackstageLogo.tsx +++ b/plugins/home/src/assets/TemplateBackstageLogo.tsx @@ -19,9 +19,13 @@ import React from 'react'; type Classes = { svg: string; path: string; -} +}; -export const TemplateBackstageLogo = (props: { classes: Classes }) => { +type TemplateBackstageLogoProps = { + classes: Classes; +}; + +export const TemplateBackstageLogo = (props: TemplateBackstageLogoProps) => { return ( { ); }; - diff --git a/plugins/home/src/assets/index.ts b/plugins/home/src/assets/index.ts new file mode 100644 index 0000000000..79de43ca99 --- /dev/null +++ b/plugins/home/src/assets/index.ts @@ -0,0 +1,18 @@ +/* + * 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 './TemplateBackstageLogo'; +export * from './TemplateBackstageLogoIcon'; diff --git a/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx b/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx index ab71eea54e..741ad2e977 100644 --- a/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx +++ b/plugins/home/src/homePageComponents/CompanyLogo/CompanyLogo.stories.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateBackstageLogo } from '../../templates'; +import { TemplateBackstageLogo } from '../../assets'; import { HomePageCompanyLogo } from '../../plugin'; import { rootRouteRef } from '../../routes'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; diff --git a/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx b/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx index cad188c5d6..9a82301e49 100644 --- a/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx +++ b/plugins/home/src/homePageComponents/Toolkit/Toolkit.stories.tsx @@ -20,7 +20,7 @@ import { Grid } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { ComponentAccordion } from '../../componentRenderers'; import { HomePageToolkit } from '../../plugin'; -import { TemplateBackstageLogoIcon } from '../../templates'; +import { TemplateBackstageLogoIcon } from '../../assets'; export default { title: 'Plugins/Home/Components/Toolkit', diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index d30b76a7fc..8f137d589b 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -33,6 +33,7 @@ export { WelcomeTitle, } from './plugin'; export { SettingsModal, HeaderWorldClock } from './components'; +export * from './assets'; export type { ClockConfig } from './components'; export { createCardExtension } from './extensions'; export type { ComponentRenderer } from './extensions'; diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index fb155bafe2..029f2c7af3 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -9,6 +9,7 @@ import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -28,6 +29,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; + backstageToken?: string; }): Promise; // (undocumented) static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; @@ -68,6 +70,7 @@ export interface JenkinsInfoProvider { getInstance(options: { entityRef: CompoundEntityRef; jobFullName?: string; + backstageToken?: string; }): Promise; } @@ -96,6 +99,6 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - permissions?: PermissionAuthorizer; + permissions?: PermissionEvaluator | PermissionAuthorizer; } ``` diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index a5453b5ebe..29da414470 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -49,6 +49,7 @@ const fakePermissionApi = { result: AuthorizeResult.ALLOW, }, ]), + authorizeConditional: jest.fn(), }; describe('JenkinsApi', () => { diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 38ab20b17b..8069f57692 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -25,7 +25,7 @@ import type { } from '../types'; import { AuthorizeResult, - PermissionAuthorizer, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; import { NotAllowedError } from '@backstage/errors'; @@ -64,7 +64,7 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; - constructor(private readonly permissionApi?: PermissionAuthorizer) {} + constructor(private readonly permissionApi?: PermissionEvaluator) {} /** * Get a list of projects for the given JenkinsInfo. diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index a3a5ba069d..5e6198e1cd 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -185,7 +185,9 @@ describe('DefaultJenkinsInfoProvider', () => { const provider = configureProvider({ jenkins: {} }, undefined); await expect(provider.getInstance({ entityRef })).rejects.toThrowError(); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); }); it('Reads simple config and annotation', async () => { @@ -207,7 +209,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', crumbIssuer: undefined, @@ -243,7 +247,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -280,7 +286,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -317,7 +325,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', @@ -343,7 +353,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -369,7 +381,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: undefined, + }); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -400,7 +414,9 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef); + expect(mockCatalog.getEntityByRef).toBeCalledWith(entityRef, { + backstageToken: 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 d9a97e2f2d..fcce944dc8 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -32,6 +32,8 @@ export interface JenkinsInfoProvider { * A specific job to get. This is only passed in when we know about a job name we are interested in. */ jobFullName?: string; + + backstageToken?: string; }): Promise; } @@ -184,9 +186,12 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { async getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; + backstageToken?: string; }): Promise { // load entity - const entity = await this.catalog.getEntityByRef(opt.entityRef); + const entity = await this.catalog.getEntityByRef(opt.entityRef, { + token: opt.backstageToken, + }); 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 08712215e4..f6bd21c633 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -20,22 +20,38 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; import { JenkinsApiImpl } from './jenkinsApi'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { stringifyEntityRef } from '@backstage/catalog-model'; export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; - permissions?: PermissionAuthorizer; + permissions?: PermissionEvaluator | PermissionAuthorizer; } export async function createRouter( options: RouterOptions, ): Promise { - const { jenkinsInfoProvider } = options; + const { jenkinsInfoProvider, permissions, logger } = options; - const jenkinsApi = new JenkinsApiImpl(options.permissions); + let permissionEvaluator: PermissionEvaluator | undefined; + if (permissions && 'query' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', + ); + permissionEvaluator = permissions + ? toPermissionEvaluator(permissions) + : undefined; + } + + const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); const router = Router(); router.use(express.json()); @@ -44,6 +60,9 @@ 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 branchStr: string | undefined; @@ -67,6 +86,7 @@ export async function createRouter( namespace, name, }, + backstageToken: token, }); const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); @@ -79,6 +99,9 @@ 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; @@ -89,6 +112,7 @@ export async function createRouter( name, }, jobFullName, + backstageToken: token, }); const build = await jenkinsApi.getBuild( diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 4deeefc788..732fc30927 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@backstage/cli": "^0.17.0-next.1", - "@types/jest-when": "^2.7.2", + "@types/jest-when": "^3.5.0", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", "supertest": "^6.1.3" diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index 8a3b64bd93..691b9443e5 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { Duration } from 'luxon'; import express from 'express'; import type { FetchResponse } from '@backstage/plugin-kubernetes-common'; import type { JsonObject } from '@backstage/types'; @@ -85,18 +86,20 @@ export class KubernetesBuilder { // (undocumented) build(): KubernetesBuilderReturn; // (undocumented) - protected buildClusterSupplier(): KubernetesClustersSupplier; + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier; // (undocumented) protected buildCustomResources(): CustomResource[]; // (undocumented) protected buildFetcher(): KubernetesFetcher; // (undocumented) protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) protected buildObjectsProvider( @@ -105,12 +108,12 @@ export class KubernetesBuilder { // (undocumented) protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router; // (undocumented) protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator; // (undocumented) static createBuilder(env: KubernetesEnvironment): KubernetesBuilder; @@ -127,6 +130,8 @@ export class KubernetesBuilder { // (undocumented) setClusterSupplier(clusterSupplier?: KubernetesClustersSupplier): this; // (undocumented) + setDefaultClusterRefreshInterval(refreshInterval: Duration): this; + // (undocumented) setFetcher(fetcher?: KubernetesFetcher): this; // (undocumented) setObjectsProvider(objectsProvider?: KubernetesObjectsProvider): this; @@ -137,7 +142,6 @@ export class KubernetesBuilder { // @public export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; @@ -149,7 +153,6 @@ export type KubernetesBuilderReturn = Promise<{ // // @public (undocumented) export interface KubernetesClustersSupplier { - // (undocumented) getClusters(): Promise; } diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index a55ec70780..f5eb974431 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -43,6 +43,7 @@ "@google-cloud/container": "^3.0.0", "@kubernetes/client-node": "^0.16.0", "@types/express": "^4.17.6", + "@types/luxon": "^2.0.4", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", @@ -52,6 +53,7 @@ "fs-extra": "10.0.1", "helmet": "^5.0.2", "lodash": "^4.17.21", + "luxon": "^2.0.2", "morgan": "^1.10.0", "stream-buffers": "^3.0.2", "winston": "^3.2.1", @@ -60,8 +62,8 @@ "devDependencies": { "@backstage/cli": "^0.17.0-next.1", "@types/aws4": "^1.5.1", - "supertest": "^6.1.3", - "aws-sdk-mock": "^5.2.1" + "aws-sdk-mock": "^5.2.1", + "supertest": "^6.1.3" }, "files": [ "dist", diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 8b70db40eb..0eb25c2acd 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -17,7 +17,13 @@ import { Config } from '@backstage/config'; import { ForwardedError } from '@backstage/errors'; import * as container from '@google-cloud/container'; -import { GKEClusterDetails, KubernetesClustersSupplier } from '../types/types'; +import { Duration } from 'luxon'; +import { runPeriodically } from '../service/runPeriodically'; +import { + ClusterDetails, + GKEClusterDetails, + KubernetesClustersSupplier, +} from '../types/types'; type GkeClusterLocatorOptions = { projectId: string; @@ -31,11 +37,14 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { constructor( private readonly options: GkeClusterLocatorOptions, private readonly client: container.v1.ClusterManagerClient, + private clusterDetails: GKEClusterDetails[] | undefined = undefined, + private hasClusterDetails: boolean = false, ) {} static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, + refreshInterval: Duration | undefined = undefined, ): GkeClusterLocator { const options = { projectId: config.getString('projectId'), @@ -45,18 +54,37 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { config.getOptionalBoolean('skipMetricsLookup') ?? false, exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, }; - return new GkeClusterLocator(options, client); + const gkeClusterLocator = new GkeClusterLocator(options, client); + if (refreshInterval) { + runPeriodically( + () => gkeClusterLocator.refreshClusters(), + refreshInterval.toMillis(), + ); + } + return gkeClusterLocator; } - static fromConfig(config: Config): GkeClusterLocator { + static fromConfig( + config: Config, + refreshInterval: Duration | undefined = undefined, + ): GkeClusterLocator { return GkeClusterLocator.fromConfigWithClient( config, new container.v1.ClusterManagerClient(), + refreshInterval, ); } + async getClusters(): Promise { + if (!this.hasClusterDetails) { + // refresh at least once when first called, when retries are disabled and in tests + await this.refreshClusters(); + } + return this.clusterDetails ?? []; + } + // TODO pass caData into the object - async getClusters(): Promise { + async refreshClusters(): Promise { const { projectId, region, @@ -70,7 +98,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { try { const [response] = await this.client.listClusters(request); - return (response.clusters ?? []).map(r => ({ + this.clusterDetails = (response.clusters ?? []).map(r => ({ // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', url: `https://${r.endpoint ?? ''}`, @@ -88,6 +116,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } : {}), })); + this.hasClusterDetails = true; } catch (e) { throw new ForwardedError( `There was an error retrieving clusters from GKE for projectId=${projectId} region=${region}`, diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts index 2bc2c719a0..cc7e7d07d6 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -15,9 +15,9 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { getCombinedClusterDetails } from './index'; +import { getCombinedClusterSupplier } from './index'; -describe('getCombinedClusterDetails', () => { +describe('getCombinedClusterSupplier', () => { it('should retrieve cluster details from config', async () => { const config: Config = new ConfigReader( { @@ -45,7 +45,8 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - const result = await getCombinedClusterDetails(config); + const clusterSupplier = getCombinedClusterSupplier(config); + const result = await clusterSupplier.getClusters(); expect(result).toStrictEqual([ { @@ -99,7 +100,7 @@ describe('getCombinedClusterDetails', () => { 'ctx', ); - await expect(getCombinedClusterDetails(config)).rejects.toStrictEqual( + expect(() => getCombinedClusterSupplier(config)).toThrowError( new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), ); }); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index a4bcf77395..53aeb44f8b 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -15,38 +15,49 @@ */ import { Config } from '@backstage/config'; -import { ClusterDetails } from '../types/types'; +import { Duration } from 'luxon'; +import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; import { ConfigClusterLocator } from './ConfigClusterLocator'; import { GkeClusterLocator } from './GkeClusterLocator'; -export const getCombinedClusterDetails = async ( +class CombinedClustersSupplier implements KubernetesClustersSupplier { + constructor(readonly clusterSuppliers: KubernetesClustersSupplier[]) {} + + async getClusters(): Promise { + return await Promise.all( + this.clusterSuppliers.map(supplier => supplier.getClusters()), + ) + .then(res => { + return res.flat(); + }) + .catch(e => { + throw e; + }); + } +} + +export const getCombinedClusterSupplier = ( rootConfig: Config, -): Promise => { - return Promise.all( - rootConfig - .getConfigArray('kubernetes.clusterLocatorMethods') - .map(clusterLocatorMethod => { - const type = clusterLocatorMethod.getString('type'); - switch (type) { - case 'config': - return ConfigClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - case 'gke': - return GkeClusterLocator.fromConfig( - clusterLocatorMethod, - ).getClusters(); - default: - throw new Error( - `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, - ); - } - }), - ) - .then(res => { - return res.flat(); - }) - .catch(e => { - throw e; + refreshInterval: Duration | undefined = undefined, +): KubernetesClustersSupplier => { + const clusterSuppliers = rootConfig + .getConfigArray('kubernetes.clusterLocatorMethods') + .map(clusterLocatorMethod => { + const type = clusterLocatorMethod.getString('type'); + switch (type) { + case 'config': + return ConfigClusterLocator.fromConfig(clusterLocatorMethod); + case 'gke': + return GkeClusterLocator.fromConfig( + clusterLocatorMethod, + refreshInterval, + ); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${type}"`, + ); + } }); + + return new CombinedClustersSupplier(clusterSuppliers); }; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts index 8586c5e445..0dce2305bf 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -19,7 +19,9 @@ import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; describe('MultiTenantConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { - const sut = new MultiTenantServiceLocator([]); + const sut = new MultiTenantServiceLocator({ + getClusters: async () => [], + }); const result = await sut.getClustersByServiceId('ignored'); @@ -27,14 +29,18 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('one clusters returns one cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', - serviceAccountToken: '12345', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + serviceAccountToken: '12345', + }, + ]; }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); @@ -49,19 +55,23 @@ describe('MultiTenantConfigClusterLocator', () => { }); it('two clusters returns two cluster details', async () => { - const sut = new MultiTenantServiceLocator([ - { - name: 'cluster1', - serviceAccountToken: 'token', - url: 'http://localhost:8080', - authProvider: 'serviceAccount', + const sut = new MultiTenantServiceLocator({ + getClusters: async () => { + return [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]; }, - { - name: 'cluster2', - url: 'http://localhost:8081', - authProvider: 'google', - }, - ]); + }); const result = await sut.getClustersByServiceId('ignored'); diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts index 9f874c7fc8..30c66a38d1 100644 --- a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts @@ -14,20 +14,24 @@ * limitations under the License. */ -import { ClusterDetails, KubernetesServiceLocator } from '../types/types'; +import { + ClusterDetails, + KubernetesClustersSupplier, + KubernetesServiceLocator, +} from '../types/types'; // This locator assumes that every service is located on every cluster // Therefore it will always return all clusters provided export class MultiTenantServiceLocator implements KubernetesServiceLocator { - private readonly clusterDetails: ClusterDetails[]; + private readonly clusterSupplier: KubernetesClustersSupplier; - constructor(clusterDetails: ClusterDetails[]) { - this.clusterDetails = clusterDetails; + constructor(clusterSupplier: KubernetesClustersSupplier) { + this.clusterSupplier = clusterSupplier; } // As this implementation always returns all clusters serviceId is ignored here // eslint-disable-next-line @typescript-eslint/no-unused-vars async getClustersByServiceId(_serviceId: string): Promise { - return this.clusterDetails; + return this.clusterSupplier.getClusters(); } } diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index a1eb60a239..a3dfc8b005 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -17,10 +17,10 @@ import { Config } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { getCombinedClusterDetails } from '../cluster-locator'; +import { Duration } from 'luxon'; +import { getCombinedClusterSupplier } from '../cluster-locator'; import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { - ClusterDetails, KubernetesObjectTypes, ServiceLocatorMethod, CustomResource, @@ -50,7 +50,6 @@ export interface KubernetesEnvironment { */ export type KubernetesBuilderReturn = Promise<{ router: express.Router; - clusterDetails: ClusterDetails[]; clusterSupplier: KubernetesClustersSupplier; customResources: CustomResource[]; fetcher: KubernetesFetcher; @@ -60,6 +59,9 @@ export type KubernetesBuilderReturn = Promise<{ export class KubernetesBuilder { private clusterSupplier?: KubernetesClustersSupplier; + private defaultClusterRefreshInterval: Duration = Duration.fromObject({ + minutes: 60, + }); private objectsProvider?: KubernetesObjectsProvider; private fetcher?: KubernetesFetcher; private serviceLocator?: KubernetesServiceLocator; @@ -91,13 +93,13 @@ export class KubernetesBuilder { const fetcher = this.fetcher ?? this.buildFetcher(); - const clusterSupplier = this.clusterSupplier ?? this.buildClusterSupplier(); - - const clusterDetails = await this.fetchClusterDetails(clusterSupplier); + const clusterSupplier = + this.clusterSupplier ?? + this.buildClusterSupplier(this.defaultClusterRefreshInterval); const serviceLocator = this.serviceLocator ?? - this.buildServiceLocator(this.getServiceLocatorMethod(), clusterDetails); + this.buildServiceLocator(this.getServiceLocatorMethod(), clusterSupplier); const objectsProvider = this.objectsProvider ?? @@ -109,10 +111,9 @@ export class KubernetesBuilder { objectTypesToFetch: this.getObjectTypesToFetch(), }); - const router = this.buildRouter(objectsProvider, clusterDetails); + const router = this.buildRouter(objectsProvider, clusterSupplier); return { - clusterDetails, clusterSupplier, customResources, fetcher, @@ -127,6 +128,11 @@ export class KubernetesBuilder { return this; } + public setDefaultClusterRefreshInterval(refreshInterval: Duration) { + this.defaultClusterRefreshInterval = refreshInterval; + return this; + } + public setObjectsProvider(objectsProvider?: KubernetesObjectsProvider) { this.objectsProvider = objectsProvider; return this; @@ -161,13 +167,11 @@ export class KubernetesBuilder { return customResources; } - protected buildClusterSupplier(): KubernetesClustersSupplier { + protected buildClusterSupplier( + refreshInterval: Duration, + ): KubernetesClustersSupplier { const config = this.env.config; - return { - getClusters() { - return getCombinedClusterDetails(config); - }, - }; + return getCombinedClusterSupplier(config, refreshInterval); } protected buildObjectsProvider( @@ -185,13 +189,13 @@ export class KubernetesBuilder { protected buildServiceLocator( method: ServiceLocatorMethod, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { switch (method) { case 'multiTenant': - return this.buildMultiTenantServiceLocator(clusterDetails); + return this.buildMultiTenantServiceLocator(clusterSupplier); case 'http': - return this.buildHttpServiceLocator(clusterDetails); + return this.buildHttpServiceLocator(clusterSupplier); default: throw new Error( `Unsupported kubernetes.clusterLocatorMethod "${method}"`, @@ -200,20 +204,20 @@ export class KubernetesBuilder { } protected buildMultiTenantServiceLocator( - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { - return new MultiTenantServiceLocator(clusterDetails); + return new MultiTenantServiceLocator(clusterSupplier); } protected buildHttpServiceLocator( - _clusterDetails: ClusterDetails[], + _clusterSupplier: KubernetesClustersSupplier, ): KubernetesServiceLocator { throw new Error('not implemented'); } protected buildRouter( objectsProvider: KubernetesObjectsProvider, - clusterDetails: ClusterDetails[], + clusterSupplier: KubernetesClustersSupplier, ): express.Router { const logger = this.env.logger; const router = Router(); @@ -236,6 +240,7 @@ export class KubernetesBuilder { }); router.get('/clusters', async (_, res) => { + const clusterDetails = await this.fetchClusterDetails(clusterSupplier); res.json({ items: clusterDetails.map(cd => ({ name: cd.name, diff --git a/plugins/search-backend-node/src/runPeriodically.ts b/plugins/kubernetes-backend/src/service/runPeriodically.ts similarity index 100% rename from plugins/search-backend-node/src/runPeriodically.ts rename to plugins/kubernetes-backend/src/service/runPeriodically.ts diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 37c26956fe..2d7e2d3da7 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -80,6 +80,12 @@ export type KubernetesObjectTypes = // Used to load cluster details from different sources export interface KubernetesClustersSupplier { + /** + * Returns the cached list of clusters. + * + * Implementations _should_ cache the clusters and refresh them periodically, + * as getClusters is called whenever the list of clusters is needed. + */ getClusters(): Promise; } diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index c5496226ee..433ba5538e 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -46,7 +46,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@types/react": "^16.13.1 || ^17.0.0", - "cronstrue": "^1.122.0", + "cronstrue": "^2.2.0", "js-yaml": "^4.0.0", "lodash": "^4.17.21", "luxon": "^2.0.2", diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index ab0c68470d..c0c351d6cd 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -15,6 +15,20 @@ export type AnyOfCriteria = { anyOf: NonEmptyArray>; }; +// @public +export type AuthorizePermissionRequest = + | { + permission: Exclude; + resourceRef?: never; + } + | { + permission: ResourcePermission; + resourceRef: string; + }; + +// @public +export type AuthorizePermissionResponse = DefinitivePolicyDecision; + // @public export type AuthorizeRequestOptions = { token?: string; @@ -78,6 +92,11 @@ export type EvaluatePermissionResponse = PolicyDecision; export type EvaluatePermissionResponseBatch = PermissionMessageBatch; +// @public +export type EvaluatorRequestOptions = { + token?: string; +}; + // @public export type IdentifiedPermissionMessage = T & { id: string; @@ -120,7 +139,7 @@ export type PermissionAttributes = { action?: 'create' | 'read' | 'update' | 'delete'; }; -// @public +// @public @deprecated export interface PermissionAuthorizer { // (undocumented) authorize( @@ -138,12 +157,16 @@ export type PermissionBase = { } & TFields; // @public -export class PermissionClient implements PermissionAuthorizer { +export class PermissionClient implements PermissionEvaluator { constructor(options: { discovery: DiscoveryApi; config: Config }); authorize( - queries: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise; + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + authorizeConditional( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; } // @public @@ -163,6 +186,18 @@ export type PermissionCriteria = | NotCriteria | TQuery; +// @public +export interface PermissionEvaluator { + authorize( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + authorizeConditional( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; +} + // @public export type PermissionMessageBatch = { items: IdentifiedPermissionMessage[]; @@ -173,6 +208,15 @@ export type PolicyDecision = | DefinitivePolicyDecision | ConditionalPolicyDecision; +// @public +export type QueryPermissionRequest = { + permission: ResourcePermission; + resourceRef?: never; +}; + +// @public +export type QueryPermissionResponse = PolicyDecision; + // @public export type ResourcePermission = PermissionBase< @@ -181,4 +225,9 @@ export type ResourcePermission = resourceType: TResourceType; } >; + +// @public +export function toPermissionEvaluator( + permissionAuthorizer: PermissionAuthorizer, +): PermissionEvaluator; ``` diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index 65a7c7fbbe..503ce2768d 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -22,6 +22,7 @@ import { EvaluatePermissionRequest, AuthorizeResult, IdentifiedPermissionMessage, + ConditionalPolicyDecision, } from './types/api'; import { DiscoveryApi } from './types/discovery'; import { createPermission } from './permissions'; @@ -52,7 +53,7 @@ describe('PermissionClient', () => { afterEach(() => server.resetHandlers()); describe('authorize', () => { - const mockAuthorizeQuery = { + const mockAuthorizeConditional = { permission: mockPermission, resourceRef: 'foo:bar', }; @@ -77,12 +78,12 @@ describe('PermissionClient', () => { }); it('should fetch entities from correct endpoint', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); expect(mockAuthorizeHandler).toHaveBeenCalled(); }); it('should include a request body', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); const request = mockAuthorizeHandler.mock.calls[0][0]; @@ -97,21 +98,21 @@ describe('PermissionClient', () => { }); it('should return the response from the fetch request', async () => { - const response = await client.authorize([mockAuthorizeQuery]); + const response = await client.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); }); it('should not include authorization headers if no token is supplied', async () => { - await client.authorize([mockAuthorizeQuery]); + await client.authorize([mockAuthorizeConditional]); const request = mockAuthorizeHandler.mock.calls[0][0]; expect(request.headers.has('authorization')).toEqual(false); }); it('should include correctly-constructed authorization header if token is supplied', async () => { - await client.authorize([mockAuthorizeQuery], { token }); + await client.authorize([mockAuthorizeConditional], { token }); const request = mockAuthorizeHandler.mock.calls[0][0]; expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); @@ -124,7 +125,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), + client.authorize([mockAuthorizeConditional], { token }), ).rejects.toThrowError(/request failed with 401/i); }); @@ -139,8 +140,8 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), - ).rejects.toThrowError(/Unexpected authorization response/i); + client.authorize([mockAuthorizeConditional], { token }), + ).rejects.toThrowError(/items in response do not match request/i); }); it('should reject invalid responses', async () => { @@ -157,7 +158,7 @@ describe('PermissionClient', () => { }, ); await expect( - client.authorize([mockAuthorizeQuery], { token }), + client.authorize([mockAuthorizeConditional], { token }), ).rejects.toThrowError(/invalid input/i); }); @@ -178,7 +179,7 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({ permission: { enabled: false } }), }); - const response = await disabled.authorize([mockAuthorizeQuery]); + const response = await disabled.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); @@ -202,11 +203,206 @@ describe('PermissionClient', () => { discovery, config: new ConfigReader({}), }); - const response = await disabled.authorize([mockAuthorizeQuery]); + const response = await disabled.authorize([mockAuthorizeConditional]); expect(response[0]).toEqual( expect.objectContaining({ result: AuthorizeResult.ALLOW }), ); expect(mockAuthorizeHandler).not.toBeCalled(); }); }); + + describe('authorizeConditional', () => { + const mockResourceAuthorizeConditional = { + permission: mockPermission, + }; + + const mockPolicyDecisionHandler = jest.fn( + (req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + pluginId: 'test-plugin', + resourceType: 'test-resource', + result: AuthorizeResult.CONDITIONAL, + conditions: { + resourceType: 'test-resource', + rule: 'FOO', + params: ['bar'], + }, + }), + ); + + return res(json({ items: responses })); + }, + ); + + beforeEach(() => { + server.use( + rest.post(`${mockBaseUrl}/authorize`, mockPolicyDecisionHandler), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch entities from correct endpoint', async () => { + await client.authorizeConditional([mockResourceAuthorizeConditional]); + expect(mockPolicyDecisionHandler).toHaveBeenCalled(); + }); + + it('should include a request body', async () => { + await client.authorizeConditional([mockResourceAuthorizeConditional]); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + + expect(request.body).toEqual({ + items: [ + expect.objectContaining({ + permission: mockPermission, + }), + ], + }); + }); + + it('should return the response from the fetch request', async () => { + const response = await client.authorizeConditional([ + mockResourceAuthorizeConditional, + ]); + expect(response[0]).toEqual( + expect.objectContaining({ + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'FOO', + resourceType: 'test-resource', + params: ['bar'], + }, + }), + ); + }); + + it('should not include authorization headers if no token is supplied', async () => { + await client.authorizeConditional([mockResourceAuthorizeConditional]); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + expect(request.headers.has('authorization')).toEqual(false); + }); + + it('should include correctly-constructed authorization header if token is supplied', async () => { + await client.authorizeConditional([mockResourceAuthorizeConditional], { + token, + }); + + const request = mockPolicyDecisionHandler.mock.calls[0][0]; + expect(request.headers.get('authorization')).toEqual('Bearer fake-token'); + }); + + it('should forward response errors', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (_req, res, { status }: RestContext) => { + return res(status(401)); + }, + ); + await expect( + client.authorizeConditional([mockResourceAuthorizeConditional], { + token, + }), + ).rejects.toThrowError(/request failed with 401/i); + }); + + it('should reject responses with missing ids', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (_req, res, { json }: RestContext) => { + return res( + json({ + items: [{ id: 'wrong-id', result: AuthorizeResult.ALLOW }], + }), + ); + }, + ); + await expect( + client.authorizeConditional([mockResourceAuthorizeConditional], { + token, + }), + ).rejects.toThrowError(/items in response do not match request/i); + }); + + it('should reject invalid responses', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + outcome: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }, + ); + await expect( + client.authorizeConditional([mockResourceAuthorizeConditional], { + token, + }), + ).rejects.toThrowError(/invalid input/i); + }); + + it('should allow all when permission.enabled is false', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + result: AuthorizeResult.DENY, + }), + ); + + return res(json({ items: responses })); + }, + ); + const disabled = new PermissionClient({ + discovery, + config: new ConfigReader({ permission: { enabled: false } }), + }); + const response = await disabled.authorizeConditional( + [mockResourceAuthorizeConditional], + { + token, + }, + ); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockPolicyDecisionHandler).not.toBeCalled(); + }); + + it('should allow all when permission.enabled is not configured', async () => { + mockPolicyDecisionHandler.mockImplementationOnce( + (req, res, { json }: RestContext) => { + const responses = req.body.map( + (a: IdentifiedPermissionMessage) => ({ + id: a.id, + outcome: AuthorizeResult.DENY, + }), + ); + + return res(json(responses)); + }, + ); + const disabled = new PermissionClient({ + discovery, + config: new ConfigReader({}), + }); + const response = await disabled.authorizeConditional( + [mockResourceAuthorizeConditional], + { + token, + }, + ); + expect(response[0]).toEqual( + expect.objectContaining({ result: AuthorizeResult.ALLOW }), + ); + expect(mockPolicyDecisionHandler).not.toBeCalled(); + }); + }); }); diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index 72f6b04567..223209c69f 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -21,19 +21,18 @@ import * as uuid from 'uuid'; import { z } from 'zod'; import { AuthorizeResult, - EvaluatePermissionRequest, - EvaluatePermissionResponse, - IdentifiedPermissionMessage, + PermissionMessageBatch, PermissionCriteria, PermissionCondition, - EvaluatePermissionResponseBatch, - EvaluatePermissionRequestBatch, + PermissionEvaluator, + QueryPermissionRequest, + AuthorizePermissionRequest, + EvaluatorRequestOptions, + AuthorizePermissionResponse, + QueryPermissionResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; -import { - PermissionAuthorizer, - AuthorizeRequestOptions, -} from './types/permission'; +import { AuthorizeRequestOptions } from './types/permission'; const permissionCriteriaSchema: z.ZodSchema< PermissionCriteria @@ -58,30 +57,56 @@ const permissionCriteriaSchema: z.ZodSchema< .or(z.object({ not: permissionCriteriaSchema }).strict()), ); -const responseSchema = z.object({ - items: z.array( - z - .object({ - id: z.string(), - result: z - .literal(AuthorizeResult.ALLOW) - .or(z.literal(AuthorizeResult.DENY)), - }) - .or( - z.object({ - id: z.string(), - result: z.literal(AuthorizeResult.CONDITIONAL), - conditions: permissionCriteriaSchema, - }), +const authorizePermissionResponseSchema: z.ZodSchema = + z.object({ + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }); + +const queryPermissionResponseSchema: z.ZodSchema = + z.union([ + z.object({ + result: z + .literal(AuthorizeResult.ALLOW) + .or(z.literal(AuthorizeResult.DENY)), + }), + z.object({ + result: z.literal(AuthorizeResult.CONDITIONAL), + pluginId: z.string(), + resourceType: z.string(), + conditions: permissionCriteriaSchema, + }), + ]); + +const responseSchema = ( + itemSchema: z.ZodSchema, + ids: Set, +): z.ZodSchema> => + z.object({ + items: z + .array( + z.intersection( + z.object({ + id: z.string(), + }), + itemSchema, + ), + ) + .refine( + items => + items.length === ids.size && items.every(({ id }) => ids.has(id)), + { + message: 'Items in response do not match request', + }, ), - ), -}); + }); /** * An isomorphic client for requesting authorization for Backstage permissions. * @public */ -export class PermissionClient implements PermissionAuthorizer { +export class PermissionClient implements PermissionEvaluator { private readonly enabled: boolean; private readonly discovery: DiscoveryApi; @@ -92,35 +117,39 @@ export class PermissionClient implements PermissionAuthorizer { } /** - * Request authorization from the permission-backend for the given set of permissions. - * - * Authorization requests check that a given Backstage user can perform a protected operation, - * potentially for a specific resource (such as a catalog entity). The Backstage identity token - * should be included in the `options` if available. - * - * Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`. - * - * The response will be either ALLOW or DENY when either the permission has no resourceType, or a - * resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be - * returned if no resourceRef is provided in the request. Conditional responses are intended only - * for backends which have access to the data source for permissioned resources, so that filters - * can be applied when loading collections of resources. - * @public + * {@inheritdoc PermissionEvaluator.authorize} */ async authorize( - queries: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise { - // TODO(permissions): it would be great to provide some kind of typing guarantee that - // conditional responses will only ever be returned for requests containing a resourceType - // but no resourceRef. That way clients who aren't prepared to handle filtering according - // to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response. + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return this.makeRequest( + requests, + authorizePermissionResponseSchema, + options, + ); + } + /** + * {@inheritdoc PermissionEvaluator.authorizeConditional} + */ + async authorizeConditional( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return this.makeRequest(queries, queryPermissionResponseSchema, options); + } + + private async makeRequest( + queries: TQuery[], + itemSchema: z.ZodSchema, + options?: AuthorizeRequestOptions, + ) { if (!this.enabled) { - return queries.map(_ => ({ result: AuthorizeResult.ALLOW })); + return queries.map(_ => ({ result: AuthorizeResult.ALLOW as const })); } - const request: EvaluatePermissionRequestBatch = { + const request: PermissionMessageBatch = { items: queries.map(query => ({ id: uuid.v4(), ...query, @@ -141,12 +170,16 @@ export class PermissionClient implements PermissionAuthorizer { } const responseBody = await response.json(); - this.assertValidResponse(request, responseBody); - const responsesById = responseBody.items.reduce((acc, r) => { + const parsedResponse = responseSchema( + itemSchema, + new Set(request.items.map(({ id }) => id)), + ).parse(responseBody); + + const responsesById = parsedResponse.items.reduce((acc, r) => { acc[r.id] = r; return acc; - }, {} as Record>); + }, {} as Record>); return request.items.map(query => responsesById[query.id]); } @@ -154,20 +187,4 @@ export class PermissionClient implements PermissionAuthorizer { private getAuthorizationHeader(token?: string): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } - - private assertValidResponse( - request: EvaluatePermissionRequestBatch, - json: any, - ): asserts json is EvaluatePermissionResponseBatch { - const authorizedResponses = responseSchema.parse(json); - const responseIds = authorizedResponses.items.map(r => r.id); - const hasAllRequestIds = request.items.every(r => - responseIds.includes(r.id), - ); - if (!hasAllRequestIds) { - throw new Error( - 'Unexpected authorization response from permission-backend', - ); - } - } } diff --git a/plugins/permission-common/src/permissions/util.ts b/plugins/permission-common/src/permissions/util.ts index 1a5ed434a7..5cc9d9c08a 100644 --- a/plugins/permission-common/src/permissions/util.ts +++ b/plugins/permission-common/src/permissions/util.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -import { Permission, ResourcePermission } from '../types'; +import { + AuthorizePermissionRequest, + AuthorizePermissionResponse, + DefinitivePolicyDecision, + EvaluatorRequestOptions, + Permission, + PermissionAuthorizer, + PermissionEvaluator, + QueryPermissionRequest, + QueryPermissionResponse, + ResourcePermission, +} from '../types'; /** * Check if the two parameters are equivalent permissions. @@ -75,3 +86,31 @@ export function isUpdatePermission(permission: Permission) { export function isDeletePermission(permission: Permission) { return permission.attributes.action === 'delete'; } + +/** + * Convert {@link PermissionAuthorizer} to {@link PermissionEvaluator}. + * + * @public + */ +export function toPermissionEvaluator( + permissionAuthorizer: PermissionAuthorizer, +): PermissionEvaluator { + return { + authorize: async ( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise => { + const response = await permissionAuthorizer.authorize(requests, options); + + return response as DefinitivePolicyDecision[]; + }, + authorizeConditional( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + const parsedRequests = + requests as unknown as AuthorizePermissionRequest[]; + return permissionAuthorizer.authorize(parsedRequests, options); + }, + }; +} diff --git a/plugins/permission-common/src/types/api.ts b/plugins/permission-common/src/types/api.ts index 6aaabf978d..f29a639d18 100644 --- a/plugins/permission-common/src/types/api.ts +++ b/plugins/permission-common/src/types/api.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ResourcePermission } from '.'; import { Permission } from './permission'; /** @@ -182,3 +183,73 @@ export type EvaluatePermissionResponse = PolicyDecision; */ export type EvaluatePermissionResponseBatch = PermissionMessageBatch; + +/** + * Request object for {@link PermissionEvaluator.authorize}. If a {@link ResourcePermission} + * is provided, it must include a corresponding `resourceRef`. + * @public + */ +export type AuthorizePermissionRequest = + | { + permission: Exclude; + resourceRef?: never; + } + | { permission: ResourcePermission; resourceRef: string }; + +/** + * Response object for {@link PermissionEvaluator.authorize}. + * @public + */ +export type AuthorizePermissionResponse = DefinitivePolicyDecision; + +/** + * Request object for {@link PermissionEvaluator.authorizeConditional}. + * @public + */ +export type QueryPermissionRequest = { + permission: ResourcePermission; + resourceRef?: never; +}; + +/** + * Response object for {@link PermissionEvaluator.authorizeConditional}. + * @public + */ +export type QueryPermissionResponse = PolicyDecision; + +/** + * A client interacting with the permission backend can implement this evaluator interface. + * + * @public + */ +export interface PermissionEvaluator { + /** + * Evaluates {@link Permission | Permissions} and returns a definitive decision. + */ + authorize( + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + + /** + * Evaluates {@link ResourcePermission | ResourcePermissions} and returns both definitive and + * conditional decisions, depending on the configured + * {@link @backstage/plugin-permission-node#PermissionPolicy}. This method is useful when the + * caller needs more control over the processing of conditional decisions. For example, a plugin + * backend may want to use {@link PermissionCriteria | conditions} in a database query instead of + * evaluating each resource in memory. + */ + authorizeConditional( + requests: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; +} + +/** + * Options for {@link PermissionEvaluator} requests. + * The Backstage identity token should be defined if available. + * @public + */ +export type EvaluatorRequestOptions = { + token?: string; +}; diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index 1a993a981c..f244a70b1a 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -22,6 +22,12 @@ export type { EvaluatePermissionResponseBatch, IdentifiedPermissionMessage, PermissionMessageBatch, + AuthorizePermissionRequest, + AuthorizePermissionResponse, + QueryPermissionRequest, + QueryPermissionResponse, + EvaluatorRequestOptions, + PermissionEvaluator, ConditionalPolicyDecision, DefinitivePolicyDecision, PolicyDecision, diff --git a/plugins/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts index 34e7884793..eb1c0eb50d 100644 --- a/plugins/permission-common/src/types/permission.ts +++ b/plugins/permission-common/src/types/permission.ts @@ -91,6 +91,7 @@ export type ResourcePermission = /** * A client interacting with the permission backend can implement this authorizer interface. * @public + * @deprecated Use {@link @backstage/plugin-permission-common#PermissionEvaluator} instead */ export interface PermissionAuthorizer { authorize( diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 57379dd68b..b2e4ff45b8 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -5,22 +5,23 @@ ```ts import { AllOfCriteria } from '@backstage/plugin-permission-common'; import { AnyOfCriteria } from '@backstage/plugin-permission-common'; -import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; import { DefinitivePolicyDecision } from '@backstage/plugin-permission-common'; -import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common'; -import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common'; +import { EvaluatorRequestOptions } from '@backstage/plugin-permission-common'; import express from 'express'; import { IdentifiedPermissionMessage } from '@backstage/plugin-permission-common'; import { NotCriteria } from '@backstage/plugin-permission-common'; import { Permission } from '@backstage/plugin-permission-common'; -import { PermissionAuthorizer } 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 { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PolicyDecision } from '@backstage/plugin-permission-common'; +import { QueryPermissionRequest } from '@backstage/plugin-permission-common'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { TokenManager } from '@backstage/backend-common'; @@ -178,12 +179,17 @@ export type PolicyQuery = { }; // @public -export class ServerPermissionClient implements PermissionAuthorizer { +export class ServerPermissionClient implements PermissionEvaluator { // (undocumented) authorize( - requests: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise; + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; + // (undocumented) + authorizeConditional( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise; // (undocumented) static fromConfig( config: Config, diff --git a/plugins/permission-node/src/ServerPermissionClient.test.ts b/plugins/permission-node/src/ServerPermissionClient.test.ts index f5711ed4a0..e531bd3556 100644 --- a/plugins/permission-node/src/ServerPermissionClient.test.ts +++ b/plugins/permission-node/src/ServerPermissionClient.test.ts @@ -17,9 +17,10 @@ import { ServerPermissionClient } from './ServerPermissionClient'; import { IdentifiedPermissionMessage, - EvaluatePermissionRequest, AuthorizeResult, createPermission, + DefinitivePolicyDecision, + ConditionalPolicyDecision, } from '@backstage/plugin-permission-common'; import { ConfigReader } from '@backstage/config'; import { @@ -31,16 +32,7 @@ import { setupServer } from 'msw/node'; import { RestContext, rest } from 'msw'; const server = setupServer(); -const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { - const responses = req.body.items.map( - (r: IdentifiedPermissionMessage) => ({ - id: r.id, - result: AuthorizeResult.ALLOW, - }), - ); - return res(json({ items: responses })); -}); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discovery: PluginEndpointDiscovery = { async getBaseUrl() { @@ -50,10 +42,17 @@ const discovery: PluginEndpointDiscovery = { return mockBaseUrl; }, }; -const testPermission = createPermission({ +const testBasicPermission = createPermission({ name: 'test.permission', attributes: {}, }); + +const testResourcePermission = createPermission({ + name: 'test.permission-2', + attributes: {}, + resourceType: 'resource-type', +}); + const config = new ConfigReader({ permission: { enabled: true }, backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, @@ -63,49 +62,6 @@ const logger = getVoidLogger(); describe('ServerPermissionClient', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterAll(() => server.close()); - beforeEach(() => { - server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); - }); - afterEach(() => server.resetHandlers()); - - it('should bypass authorization if permissions are disabled', async () => { - const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { - discovery, - tokenManager: ServerTokenManager.noop(), - }); - - await client.authorize([{ permission: testPermission }]); - - expect(mockAuthorizeHandler).not.toHaveBeenCalled(); - }); - - it('should bypass authorization if permissions are enabled and request has valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); - const client = ServerPermissionClient.fromConfig(config, { - discovery, - tokenManager, - }); - - await client.authorize([{ permission: testPermission }], { - token: (await tokenManager.getToken()).token, - }); - - expect(mockAuthorizeHandler).not.toHaveBeenCalled(); - }); - - it('should authorize normally if permissions are enabled and request does not have valid server token', async () => { - const tokenManager = ServerTokenManager.fromConfig(config, { logger }); - const client = ServerPermissionClient.fromConfig(config, { - discovery, - tokenManager, - }); - - await client.authorize([{ permission: testPermission }], { - token: 'a-user-token', - }); - - expect(mockAuthorizeHandler).toHaveBeenCalled(); - }); it('should error if permissions are enabled but a no-op token manager is configured', async () => { expect(() => @@ -117,4 +73,134 @@ describe('ServerPermissionClient', () => { 'Backend-to-backend authentication must be configured before enabling permissions. Read more here https://backstage.io/docs/tutorials/backend-to-backend-auth', ); }); + + describe('authorize', () => { + let mockAuthorizeHandler: jest.Mock; + + beforeEach(() => { + mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (r: IdentifiedPermissionMessage) => ({ + id: r.id, + result: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }); + + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + afterEach(() => server.resetHandlers()); + + it('should bypass the permission backend if permissions are disabled', async () => { + const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { + discovery, + tokenManager: ServerTokenManager.noop(), + }); + + await client.authorize([ + { + permission: testBasicPermission, + }, + ]); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testBasicPermission }], { + token: (await tokenManager.getToken()).token, + }); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testBasicPermission }], { + token: 'a-user-token', + }); + + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + }); + + describe('authorizeConditional', () => { + let mockAuthorizeHandler: jest.Mock; + + beforeEach(() => { + mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.items.map( + (r: IdentifiedPermissionMessage) => ({ + id: r.id, + result: AuthorizeResult.ALLOW, + }), + ); + + return res(json({ items: responses })); + }); + + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + afterEach(() => server.resetHandlers()); + + it('should bypass the permission backend if permissions are disabled', async () => { + const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { + discovery, + tokenManager: ServerTokenManager.noop(), + }); + + await client.authorizeConditional([ + { permission: testResourcePermission }, + ]); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorizeConditional( + [{ permission: testResourcePermission }], + { + token: (await tokenManager.getToken()).token, + }, + ); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorizeConditional( + [{ permission: testResourcePermission }], + { + token: 'a-user-token', + }, + ); + + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + }); }); diff --git a/plugins/permission-node/src/ServerPermissionClient.ts b/plugins/permission-node/src/ServerPermissionClient.ts index 56381dff17..664e5a8309 100644 --- a/plugins/permission-node/src/ServerPermissionClient.ts +++ b/plugins/permission-node/src/ServerPermissionClient.ts @@ -20,12 +20,14 @@ import { } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { - EvaluatePermissionRequest, - AuthorizeRequestOptions, - EvaluatePermissionResponse, AuthorizeResult, PermissionClient, - PermissionAuthorizer, + PermissionEvaluator, + AuthorizePermissionRequest, + EvaluatorRequestOptions, + AuthorizePermissionResponse, + PolicyDecision, + QueryPermissionRequest, } from '@backstage/plugin-permission-common'; /** @@ -34,7 +36,7 @@ import { * backend-to-backend requests. * @public */ -export class ServerPermissionClient implements PermissionAuthorizer { +export class ServerPermissionClient implements PermissionEvaluator { private readonly permissionClient: PermissionClient; private readonly tokenManager: TokenManager; private readonly permissionEnabled: boolean; @@ -77,21 +79,22 @@ export class ServerPermissionClient implements PermissionAuthorizer { this.permissionEnabled = options.permissionEnabled; } + async authorizeConditional( + queries: QueryPermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return (await this.isEnabled(options?.token)) + ? this.permissionClient.authorizeConditional(queries, options) + : queries.map(_ => ({ result: AuthorizeResult.ALLOW })); + } + async authorize( - requests: EvaluatePermissionRequest[], - options?: AuthorizeRequestOptions, - ): Promise { - // Check if permissions are enabled before validating the server token. That - // way when permissions are disabled, the noop token manager can be used - // without fouling up the logic inside the ServerPermissionClient, because - // the code path won't be reached. - if ( - !this.permissionEnabled || - (await this.isValidServerToken(options?.token)) - ) { - return requests.map(_ => ({ result: AuthorizeResult.ALLOW })); - } - return this.permissionClient.authorize(requests, options); + requests: AuthorizePermissionRequest[], + options?: EvaluatorRequestOptions, + ): Promise { + return (await this.isEnabled(options?.token)) + ? this.permissionClient.authorize(requests, options) + : requests.map(_ => ({ result: AuthorizeResult.ALLOW })); } private async isValidServerToken( @@ -105,4 +108,12 @@ export class ServerPermissionClient implements PermissionAuthorizer { .then(() => true) .catch(() => false); } + + private async isEnabled(token?: string) { + // Check if permissions are enabled before validating the server token. That + // way when permissions are disabled, the noop token manager can be used + // without fouling up the logic inside the ServerPermissionClient, because + // the code path won't be reached. + return this.permissionEnabled && !(await this.isValidServerToken(token)); + } } diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md index 17b29a463a..12f1300c70 100644 --- a/plugins/permission-react/api-report.md +++ b/plugins/permission-react/api-report.md @@ -4,6 +4,8 @@ ```ts import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common'; +import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common'; import { ComponentProps } from 'react'; import { Config } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -26,8 +28,8 @@ export type AsyncPermissionResult = { export class IdentityPermissionApi implements PermissionApi { // (undocumented) authorize( - request: EvaluatePermissionRequest, - ): Promise; + request: AuthorizePermissionRequest, + ): Promise; // (undocumented) static create(options: { config: Config; diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 80d4eb8859..1fe295c9f2 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -17,8 +17,8 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { PermissionApi } from './PermissionApi'; import { - EvaluatePermissionRequest, - EvaluatePermissionResponse, + AuthorizePermissionRequest, + AuthorizePermissionResponse, PermissionClient, } from '@backstage/plugin-permission-common'; import { Config } from '@backstage/config'; @@ -45,8 +45,8 @@ export class IdentityPermissionApi implements PermissionApi { } async authorize( - request: EvaluatePermissionRequest, - ): Promise { + request: AuthorizePermissionRequest, + ): Promise { const response = await this.permissionClient.authorize( [request], await this.identityApi.getCredentials(), diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx index 1c63852866..e2052b792f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -65,7 +65,8 @@ export const GitlabRepoPicker = (props: { )} - The organization, user or project that this repo will belong to + The organization, groups, subgroups, user, project (also known as + namespaces in gitlab), that this repo will belong to { let testSearchEngine: SearchEngine; let testIndexBuilder: IndexBuilder; + let testScheduledTaskRunner: TaskRunner; beforeEach(() => { const logger = getVoidLogger(); + testScheduledTaskRunner = { + run: async (task: TaskInvocationDefinition & { fn: () => void }) => { + task.fn(); + }, + }; testSearchEngine = new LunrSearchEngine({ logger }); testIndexBuilder = new IndexBuilder({ logger, @@ -65,35 +72,32 @@ describe('IndexBuilder', () => { describe('addCollator', () => { it('adds a collator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TestDocumentCollatorFactory(); const collatorSpy = jest.spyOn(testCollatorFactory, 'getCollator'); // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Build the index and ensure the collator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); }); }); describe('addDecorator', () => { it('adds a decorator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TestDocumentCollatorFactory(); const testDecoratorFactory = new TestDocumentDecoratorFactory(); const decoratorSpy = jest.spyOn(testDecoratorFactory, 'getDecorator'); // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator. @@ -104,14 +108,12 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); }); it('adds a type-specific decorator', async () => { - jest.useFakeTimers(); const testCollatorFactory = new TypedDocumentCollatorFactory(); const testDecoratorFactory = new TypedDocumentDecoratorFactory(); jest.spyOn(testCollatorFactory, 'getCollator'); @@ -119,8 +121,8 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator for the same type. @@ -131,7 +133,6 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); // wait for async decorator execution await Promise.resolve(); expect(decoratorSpy).toHaveBeenCalled(); @@ -146,8 +147,8 @@ describe('IndexBuilder', () => { // Add a collator. testIndexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 6, factory: testCollatorFactory, + schedule: testScheduledTaskRunner, }); // Add a decorator for a different type. @@ -158,7 +159,6 @@ describe('IndexBuilder', () => { // Build the index and ensure the decorator was not invoked. const { scheduler } = await testIndexBuilder.build(); scheduler.start(); - jest.advanceTimersByTime(6000); expect(collatorSpy).toHaveBeenCalled(); expect(decoratorSpy).not.toHaveBeenCalled(); }); diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts index 69c1b2e6fc..235bac905a 100644 --- a/plugins/search-backend-node/src/IndexBuilder.ts +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -13,32 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { - DocumentCollatorFactory, DocumentDecoratorFactory, DocumentTypeInfo, SearchEngine, } from '@backstage/plugin-search-common'; import { Transform, pipeline } from 'stream'; import { Logger } from 'winston'; -import { Scheduler } from './index'; +import { Scheduler } from './Scheduler'; import { IndexBuilderOptions, RegisterCollatorParameters, RegisterDecoratorParameters, } from './types'; -interface CollatorEnvelope { - factory: DocumentCollatorFactory; - refreshInterval: number; -} - /** * @beta */ export class IndexBuilder { - private collators: Record; + private collators: Record; private decorators: Record; private documentTypes: Record; private searchEngine: SearchEngine; @@ -64,16 +57,13 @@ export class IndexBuilder { * Makes the index builder aware of a collator that should be executed at the * given refresh interval. */ - addCollator({ - factory, - defaultRefreshIntervalSeconds, - }: RegisterCollatorParameters): void { + addCollator({ factory, schedule }: RegisterCollatorParameters): void { this.logger.info( `Added ${factory.constructor.name} collator factory for type ${factory.type}`, ); this.collators[factory.type] = { - refreshInterval: defaultRefreshIntervalSeconds, factory, + schedule, }; this.documentTypes[factory.type] = { visibilityPermission: factory.visibilityPermission, @@ -106,51 +96,57 @@ export class IndexBuilder { * scheduler returned to the caller. */ async build(): Promise<{ scheduler: Scheduler }> { - const scheduler = new Scheduler({ logger: this.logger }); + const scheduler = new Scheduler({ + logger: this.logger, + }); Object.keys(this.collators).forEach(type => { - scheduler.addToSchedule(async () => { - // Instantiate the collator. - const collator = await this.collators[type].factory.getCollator(); - this.logger.info( - `Collating documents for ${type} via ${this.collators[type].factory.constructor.name}`, - ); - - // Instantiate all relevant decorators. - const decorators: Transform[] = await Promise.all( - (this.decorators['*'] || []) - .concat(this.decorators[type] || []) - .map(async factory => { - const decorator = await factory.getDecorator(); - this.logger.info( - `Attached decorator via ${factory.constructor.name} to ${type} index pipeline.`, - ); - return decorator; - }), - ); - - // Instantiate the indexer. - const indexer = await this.searchEngine.getIndexer(type); - - // Compose collator/decorators/indexer into a pipeline - return new Promise(done => { - pipeline( - [collator, ...decorators, indexer], - (error: NodeJS.ErrnoException | null) => { - if (error) { - this.logger.error( - `Collating documents for ${type} failed: ${error}`, - ); - } else { - this.logger.info(`Collating documents for ${type} succeeded`); - } - - // Signal index pipeline completion! - done(); - }, + scheduler.addToSchedule({ + id: `search_index_${type.replace('-', '_').toLocaleLowerCase('en-US')}`, + scheduledRunner: this.collators[type].schedule, + task: async () => { + // Instantiate the collator. + const collator = await this.collators[type].factory.getCollator(); + this.logger.info( + `Collating documents for ${type} via ${this.collators[type].factory.constructor.name}`, ); - }); - }, this.collators[type].refreshInterval * 1000); + + // Instantiate all relevant decorators. + const decorators: Transform[] = await Promise.all( + (this.decorators['*'] || []) + .concat(this.decorators[type] || []) + .map(async factory => { + const decorator = await factory.getDecorator(); + this.logger.info( + `Attached decorator via ${factory.constructor.name} to ${type} index pipeline.`, + ); + return decorator; + }), + ); + + // Instantiate the indexer. + const indexer = await this.searchEngine.getIndexer(type); + + // Compose collator/decorators/indexer into a pipeline + return new Promise(done => { + pipeline( + [collator, ...decorators, indexer], + (error: NodeJS.ErrnoException | null) => { + if (error) { + this.logger.error( + `Collating documents for ${type} failed: ${error}`, + ); + } else { + this.logger.info(`Collating documents for ${type} succeeded`); + } + + // Signal index pipeline completion! + done(); + }, + ); + }); + }, + }); }); return { diff --git a/plugins/search-backend-node/src/Scheduler.test.ts b/plugins/search-backend-node/src/Scheduler.test.ts index de6add13fe..7eaa6e8c9d 100644 --- a/plugins/search-backend-node/src/Scheduler.test.ts +++ b/plugins/search-backend-node/src/Scheduler.test.ts @@ -29,31 +29,107 @@ describe('Scheduler', () => { describe('addToSchedule', () => { it('should not add a task and interval to schedule, if already started', async () => { - jest.useFakeTimers(); const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add a task and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); // Starts scheduling process testScheduler.start(); // Throws Error if task and interval is added to a already started schedule - expect(() => testScheduler.addToSchedule(mockTask2, 2)).toThrowError(); + expect(() => + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), + ).toThrowError(); - jest.runOnlyPendingTimers(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).not.toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); + }); + + it('should not add a task to schedule, if it already exists', async () => { + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; + + // Add a task and interval to schedule + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); + + // Throws Error if task and interval is added to a already started schedule + expect(() => + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), + ).toThrowError(); + + // Starts scheduling process + testScheduler.start(); + + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); it('should be possible to add a task and interval to schedule, if already started, but stopped in between', async () => { - jest.useFakeTimers(); const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add a task and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); // Starts scheduling process testScheduler.start(); @@ -63,15 +139,28 @@ describe('Scheduler', () => { // Shouldn't throw error, as it is stopped. expect(() => - testScheduler.addToSchedule(mockTask2, 4), + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }), ).not.toThrowError(); // Starts scheduling process testScheduler.start(); - jest.runOnlyPendingTimers(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); }); @@ -79,16 +168,40 @@ describe('Scheduler', () => { it('should execute tasks on start', () => { const mockTask1 = jest.fn(); const mockTask2 = jest.fn(); + const mockScheduledTaskRunner1 = { + run: jest.fn(), + }; + const mockScheduledTaskRunner2 = { + run: jest.fn(), + }; // Add tasks and interval to schedule - testScheduler.addToSchedule(mockTask1, 2); - testScheduler.addToSchedule(mockTask2, 2); + testScheduler.addToSchedule({ + id: 'id1', + task: mockTask1, + scheduledRunner: mockScheduledTaskRunner1, + }); + testScheduler.addToSchedule({ + id: 'id2', + task: mockTask2, + scheduledRunner: mockScheduledTaskRunner2, + }); // Starts scheduling process testScheduler.start(); - expect(mockTask1).toHaveBeenCalled(); - expect(mockTask2).toHaveBeenCalled(); + expect(mockScheduledTaskRunner1.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id1', + fn: mockTask1, + }), + ); + expect(mockScheduledTaskRunner2.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'id2', + fn: mockTask2, + }), + ); }); }); }); diff --git a/plugins/search-backend-node/src/Scheduler.ts b/plugins/search-backend-node/src/Scheduler.ts index 6debaa3dcd..9f3d95b112 100644 --- a/plugins/search-backend-node/src/Scheduler.ts +++ b/plugins/search-backend-node/src/Scheduler.ts @@ -14,29 +14,38 @@ * limitations under the License. */ +import { AbortController } from 'node-abort-controller'; import { Logger } from 'winston'; -import { runPeriodically } from './runPeriodically'; +import { TaskFunction, TaskRunner } from '@backstage/backend-tasks'; type TaskEnvelope = { - task: Function; - interval: number; + task: TaskFunction; + scheduledRunner: TaskRunner; }; /** - * TODO: coordination, error handling + * @public ScheduleTaskParameters */ +export interface ScheduleTaskParameters { + id: string; + task: TaskFunction; + scheduledRunner: TaskRunner; +} /** * @beta */ export class Scheduler { private logger: Logger; - private schedule: TaskEnvelope[]; - private runningTasks: Function[] = []; + private schedule: { [id: string]: TaskEnvelope }; + private abortController: AbortController; + private isRunning: boolean; constructor({ logger }: { logger: Logger }) { this.logger = logger; - this.schedule = []; + this.schedule = {}; + this.abortController = new AbortController(); + this.isRunning = false; } /** @@ -44,13 +53,18 @@ export class Scheduler { * When running the tasks, the scheduler waits at least for the time specified * in the interval once the task was completed, before running it again. */ - addToSchedule(task: Function, interval: number) { - if (this.runningTasks.length) { + addToSchedule({ id, task, scheduledRunner }: ScheduleTaskParameters) { + if (this.isRunning) { throw new Error( 'Cannot add task to schedule that has already been started.', ); } - this.schedule.push({ task, interval }); + + if (this.schedule[id]) { + throw new Error(`Task with id ${id} already exists.`); + } + + this.schedule[id] = { task, scheduledRunner }; } /** @@ -58,8 +72,14 @@ export class Scheduler { */ start() { this.logger.info('Starting all scheduled search tasks.'); - this.schedule.forEach(({ task, interval }) => { - this.runningTasks.push(runPeriodically(() => task(), interval)); + this.isRunning = true; + Object.keys(this.schedule).forEach(id => { + const { task, scheduledRunner } = this.schedule[id]; + scheduledRunner.run({ + id, + fn: task, + signal: this.abortController.signal, + }); }); } @@ -68,9 +88,7 @@ export class Scheduler { */ stop() { this.logger.info('Stopping all scheduled search tasks.'); - this.runningTasks.forEach(cancel => { - cancel(); - }); - this.runningTasks = []; + this.abortController.abort(); + this.isRunning = false; } } diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index e84c5d6a4d..e5202ac640 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -36,6 +36,8 @@ export type { export * from './indexing'; export * from './test-utils'; +export type { ScheduleTaskParameters } from './Scheduler'; + /** * @deprecated Import from @backstage/plugin-search-common instead */ diff --git a/plugins/search-backend-node/src/runPeriodically.test.ts b/plugins/search-backend-node/src/runPeriodically.test.ts deleted file mode 100644 index 0f2ee44b8d..0000000000 --- a/plugins/search-backend-node/src/runPeriodically.test.ts +++ /dev/null @@ -1,84 +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 { runPeriodically } from './runPeriodically'; - -jest.useFakeTimers(); - -describe('runPeriodically', () => { - const flushPromises = async () => { - const promise = new Promise(resolve => process.nextTick(resolve)); - jest.runAllTicks(); - await promise; - }; - const advanceTimersByTime = async (time: number) => { - jest.advanceTimersByTime(time); - // Advancing the time with jest doesn't run all promises, but only sync code - await flushPromises(); - }; - - it('runs task initially', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - expect(task).toHaveBeenCalledTimes(1); - cancel(); - }); - - it('runs at requested interval', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(3); - cancel(); - }); - - it('stops after being canceled', async () => { - const task = jest.fn(async () => {}); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - cancel(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(1); - }); - - it('continues running after failures', async () => { - const task = jest.fn(async () => { - throw new Error(); - }); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(3); - cancel(); - }); - - it('waits till a long running task is completed', async () => { - const task = jest.fn( - () => new Promise(resolve => setTimeout(resolve, 10000)), - ); - const cancel = runPeriodically(task, 1000); - await flushPromises(); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(1); - await advanceTimersByTime(9000); - await advanceTimersByTime(1000); - expect(task).toHaveBeenCalledTimes(2); - cancel(); - }); -}); diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts index d21bfaf453..a92398e8c8 100644 --- a/plugins/search-backend-node/src/types.ts +++ b/plugins/search-backend-node/src/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { TaskRunner } from '@backstage/backend-tasks'; import { DocumentCollatorFactory, DocumentDecoratorFactory, @@ -35,10 +36,10 @@ export type IndexBuilderOptions = { */ export interface RegisterCollatorParameters { /** - * The default interval (in seconds) that the provided collator will be called (can be overridden in config). + * The schedule for which the provided collator will be called, commonly the result of + * {@link @backstage/backend-tasks#PluginTaskScheduler.createScheduledTaskRunner} */ - defaultRefreshIntervalSeconds: number; - + schedule: TaskRunner; /** * The class responsible for returning the document collator of the given type. */ diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md index 3c7f9c2754..c5c8eb7a13 100644 --- a/plugins/search-backend/api-report.md +++ b/plugins/search-backend/api-report.md @@ -8,6 +8,7 @@ import { DocumentTypeInfo } from '@backstage/plugin-search-common'; import express from 'express'; import { Logger } from 'winston'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -21,7 +22,7 @@ export function createRouter(options: RouterOptions): Promise; export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator | PermissionAuthorizer; config: Config; logger: Logger; }; diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts index 47503312fb..256608fd82 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts @@ -19,7 +19,8 @@ import { EvaluatePermissionResponse, AuthorizeResult, createPermission, - PermissionAuthorizer, + PolicyDecision, + PermissionEvaluator, } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, @@ -69,12 +70,15 @@ describe('AuthorizedSearchEngine', () => { query: mockedQuery, }; - const mockedAuthorize: jest.MockedFunction< - PermissionAuthorizer['authorize'] + const mockedAuthorize: jest.MockedFunction = + jest.fn(); + const mockedPermissionQuery: jest.MockedFunction< + PermissionEvaluator['authorizeConditional'] > = jest.fn(); - const permissionAuthorizer: PermissionAuthorizer = { + const permissionEvaluator: PermissionEvaluator = { authorize: mockedAuthorize, + authorizeConditional: mockedPermissionQuery, }; const defaultTypes: Record = { @@ -111,13 +115,14 @@ describe('AuthorizedSearchEngine', () => { const authorizedSearchEngine = new AuthorizedSearchEngine( searchEngine, defaultTypes, - permissionAuthorizer, + permissionEvaluator, new ConfigReader({}), ); const options = { token: 'token' }; - const allowAll: PermissionAuthorizer['authorize'] = async queries => { + const allowAll: PermissionEvaluator['authorize'] & + PermissionEvaluator['authorizeConditional'] = async queries => { return queries.map(() => ({ result: AuthorizeResult.ALLOW, })); @@ -126,11 +131,12 @@ describe('AuthorizedSearchEngine', () => { beforeEach(() => { mockedQuery.mockReset(); mockedAuthorize.mockClear(); + mockedPermissionQuery.mockClear(); }); it('should forward the parameters correctly', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + const filters = { just: 1, a: 2, filter: 3 }; await authorizedSearchEngine.query( { term: 'term', filters, types: ['one', 'two'] }, @@ -148,7 +154,8 @@ describe('AuthorizedSearchEngine', () => { it('should forward the default types if none are passed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); + await authorizedSearchEngine.query({ term: '' }, options); expect(mockedQuery).toHaveBeenCalledWith( { term: '', types: ['users', 'templates', 'services', 'groups'] }, @@ -158,17 +165,17 @@ describe('AuthorizedSearchEngine', () => { it('should return all the results if all queries are allowed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); await expect( authorizedSearchEngine.query({ term: '' }, options), ).resolves.toEqual({ results }); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); }); it('should batch authorized requests', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(allowAll); + mockedPermissionQuery.mockImplementation(allowAll); await authorizedSearchEngine.query( { term: '', types: [typeUsers, typeTemplates] }, @@ -178,8 +185,8 @@ describe('AuthorizedSearchEngine', () => { { term: '', types: ['users', 'templates'] }, { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); - expect(mockedAuthorize).toHaveBeenLastCalledWith( + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenLastCalledWith( [ { permission: defaultTypes[typeUsers].visibilityPermission }, { permission: defaultTypes[typeTemplates].visibilityPermission }, @@ -190,7 +197,7 @@ describe('AuthorizedSearchEngine', () => { it('should skip sending request for types that are not allowed', async () => { mockedQuery.mockImplementation(async () => ({ results })); - mockedAuthorize.mockImplementation(async queries => { + mockedPermissionQuery.mockImplementation(async queries => { return queries.map(query => { if ( query.permission.name === @@ -213,7 +220,7 @@ describe('AuthorizedSearchEngine', () => { { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); }); it('should perform result-by-result filtering', async () => { @@ -226,21 +233,12 @@ describe('AuthorizedSearchEngine', () => { results: resultsWithAuth, })); - const userToBeReturned = 8; - - mockedAuthorize.mockImplementation(async queries => + mockedPermissionQuery.mockImplementation(async queries => queries.map(query => { if ( query.permission.name === defaultTypes.users.visibilityPermission?.name ) { - if (query.resourceRef) { - return { - result: query.resourceRef.endsWith(userToBeReturned.toString()) - ? AuthorizeResult.ALLOW - : AuthorizeResult.DENY, - }; - } return { result: AuthorizeResult.CONDITIONAL, } as EvaluatePermissionResponse; @@ -252,9 +250,20 @@ describe('AuthorizedSearchEngine', () => { }), ); + mockedAuthorize.mockImplementation(async queries => + queries.map(query => { + return { + result: + query.resourceRef! === `users_doc_8` + ? AuthorizeResult.ALLOW + : AuthorizeResult.DENY, + }; + }), + ); + await expect( authorizedSearchEngine.query({ term: '' }, options), - ).resolves.toEqual({ results: [usersWithAuth[userToBeReturned]] }); + ).resolves.toEqual({ results: [usersWithAuth[8]] }); expect(mockedQuery).toHaveBeenCalledWith( { term: '', types: ['users'] }, @@ -284,27 +293,27 @@ describe('AuthorizedSearchEngine', () => { results: searchResults, })); - mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { - result: AuthorizeResult.ALLOW, - }; - } + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as EvaluatePermissionResponse), + ), + ); - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + mockedAuthorize.mockImplementation(async queries => + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), ); await expect( authorizedSearchEngine.query({ term: '', types: ['templates'] }, options), ).resolves.toEqual({ results: searchResults }); - expect(mockedAuthorize).toHaveBeenCalledTimes(2); - expect(mockedAuthorize).toHaveBeenNthCalledWith( - 1, + expect(mockedPermissionQuery).toHaveBeenCalledTimes(1); + expect(mockedPermissionQuery).toHaveBeenCalledWith( [ { permission: expect.objectContaining({ @@ -314,8 +323,8 @@ describe('AuthorizedSearchEngine', () => { ], { token: 'token' }, ); - expect(mockedAuthorize).toHaveBeenNthCalledWith( - 2, + expect(mockedAuthorize).toHaveBeenCalledTimes(1); + expect(mockedAuthorize).toHaveBeenCalledWith( [ { permission: expect.objectContaining({ @@ -329,7 +338,16 @@ describe('AuthorizedSearchEngine', () => { }); it('should perform search until the target number of results is reached', async () => { - mockedAuthorize.mockImplementation(async queries => + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + + mockedPermissionQuery.mockImplementation(async queries => queries.map(query => { if (query.resourceRef) { return { @@ -342,6 +360,12 @@ describe('AuthorizedSearchEngine', () => { }), ); + mockedAuthorize.mockImplementation(async queries => + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), + ); + const usersWithAuth = generateSampleResults(typeUsers, true); const templatesWithAuth = generateSampleResults(typeTemplates, true); const servicesWithAuth = generateSampleResults(typeServices, true); @@ -405,20 +429,22 @@ describe('AuthorizedSearchEngine', () => { }); it('should perform search until the target number of results is reached, excluding unauthorized results', async () => { + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { - result: - query.permission.name === 'search.services.read' - ? AuthorizeResult.DENY - : AuthorizeResult.ALLOW, - }; - } - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + queries.map(query => ({ + result: + query.permission.name === 'search.services.read' + ? AuthorizeResult.DENY + : AuthorizeResult.ALLOW, + })), ); const usersWithAuth = generateSampleResults(typeUsers, true); @@ -494,15 +520,19 @@ describe('AuthorizedSearchEngine', () => { }); it('should discard results until the target cursor is reached', async () => { + mockedPermissionQuery.mockImplementation(async queries => + queries.map( + _ => + ({ + result: AuthorizeResult.CONDITIONAL, + } as PolicyDecision), + ), + ); + mockedAuthorize.mockImplementation(async queries => - queries.map(query => { - if (query.resourceRef) { - return { result: AuthorizeResult.ALLOW }; - } - return { - result: AuthorizeResult.CONDITIONAL, - } as EvaluatePermissionResponse; - }), + queries.map(_ => ({ + result: AuthorizeResult.ALLOW, + })), ); const usersWithAuth = generateSampleResults(typeUsers, true); diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts index a529bf04c6..a74cdf8eef 100644 --- a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts +++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts @@ -22,7 +22,9 @@ import { EvaluatePermissionRequest, AuthorizeResult, isResourcePermission, - PermissionAuthorizer, + PermissionEvaluator, + AuthorizePermissionRequest, + QueryPermissionRequest, } from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, @@ -67,7 +69,7 @@ export class AuthorizedSearchEngine implements SearchEngine { constructor( private readonly searchEngine: SearchEngine, private readonly types: Record, - private readonly permissions: PermissionAuthorizer, + private readonly permissions: PermissionEvaluator, config: Config, ) { this.queryLatencyBudgetMs = @@ -89,8 +91,16 @@ export class AuthorizedSearchEngine implements SearchEngine { ): Promise { const queryStartTime = Date.now(); + const conditionFetcher = new DataLoader( + (requests: readonly QueryPermissionRequest[]) => + this.permissions.authorizeConditional(requests.slice(), options), + { + cacheKeyFn: ({ permission: { name } }) => name, + }, + ); + const authorizer = new DataLoader( - (requests: readonly EvaluatePermissionRequest[]) => + (requests: readonly AuthorizePermissionRequest[]) => this.permissions.authorize(requests.slice(), options), { // Serialize the permission name and resourceRef as @@ -100,6 +110,7 @@ export class AuthorizedSearchEngine implements SearchEngine { qs.stringify({ name, resourceRef }), }, ); + const requestedTypes = query.types || Object.keys(this.types); const typeDecisions = zipObject( @@ -108,9 +119,18 @@ export class AuthorizedSearchEngine implements SearchEngine { requestedTypes.map(type => { const permission = this.types[type]?.visibilityPermission; - return permission - ? authorizer.load({ permission }) - : { result: AuthorizeResult.ALLOW as const }; + // No permission configured for this document type - always allow. + if (!permission) { + return { result: AuthorizeResult.ALLOW as const }; + } + + // Resource permission supplied, so we need to check for conditional decisions. + if (isResourcePermission(permission)) { + return conditionFetcher.load({ permission }); + } + + // Non-resource permission supplied - we can perform a standard authorization. + return authorizer.load({ permission }); }), ), ); diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 2efab91256..26d901e519 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -16,7 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { IndexBuilder, SearchEngine, @@ -26,10 +26,13 @@ import request from 'supertest'; import { createRouter } from './router'; -const mockPermissionAuthorizer: PermissionAuthorizer = { +const mockPermissionEvaluator: PermissionEvaluator = { authorize: () => { throw new Error('Not implemented'); }, + authorizeConditional: () => { + throw new Error('Not implemented'); + }, }; describe('createRouter', () => { @@ -59,7 +62,7 @@ describe('createRouter', () => { 'second-type': {}, }, config: new ConfigReader({ permissions: { enabled: false } }), - permissions: mockPermissionAuthorizer, + permissions: mockPermissionEvaluator, logger, }); app = express().use(router); @@ -164,7 +167,7 @@ describe('createRouter', () => { engine: indexBuilder.getSearchEngine(), types: indexBuilder.getDocumentTypes(), config: new ConfigReader({ permissions: { enabled: false } }), - permissions: mockPermissionAuthorizer, + permissions: mockPermissionEvaluator, logger, }); app = express().use(router); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index ff91465cf4..11f0eb22e6 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -23,7 +23,11 @@ import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; +import { + PermissionAuthorizer, + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; import { DocumentTypeInfo, IndexableResultSet, @@ -50,7 +54,7 @@ const jsonObjectSchema: z.ZodSchema = z.lazy(() => { export type RouterOptions = { engine: SearchEngine; types: Record; - permissions: PermissionAuthorizer; + permissions: PermissionEvaluator | PermissionAuthorizer; config: Config; logger: Logger; }; @@ -71,8 +75,23 @@ export async function createRouter( pageCursor: z.string().optional(), }); + let permissionEvaluator: PermissionEvaluator; + if ('query' in permissions) { + permissionEvaluator = permissions as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', + ); + permissionEvaluator = toPermissionEvaluator(permissions); + } + const engine = config.getOptionalBoolean('permission.enabled') - ? new AuthorizedSearchEngine(inputEngine, types, permissions, config) + ? new AuthorizedSearchEngine( + inputEngine, + types, + permissionEvaluator, + config, + ) : inputEngine; const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ diff --git a/plugins/search-react/.eslintrc.js b/plugins/search-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/search-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/search-react/README.md b/plugins/search-react/README.md new file mode 100644 index 0000000000..01db493e9e --- /dev/null +++ b/plugins/search-react/README.md @@ -0,0 +1,3 @@ +# Backstage Search + +A search plugin library which holds functionality the [search plugin](/plugins/search/README.md) itself and other frontend plugins (e.g. [techdocs](/plugins/techdocs/README.md), [home](/plugins/home/README.md)) depend on. diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md new file mode 100644 index 0000000000..f9b34e3f3f --- /dev/null +++ b/plugins/search-react/api-report.md @@ -0,0 +1,61 @@ +## API Report File for "@backstage/plugin-search-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { ComponentProps } from 'react'; +import { JsonObject } from '@backstage/types'; +import { PropsWithChildren } from 'react'; +import { default as React_2 } from 'react'; +import { SearchQuery } from '@backstage/plugin-search-common'; +import { SearchResultSet } from '@backstage/plugin-search-common'; + +// @public (undocumented) +export interface SearchApi { + // (undocumented) + query(query: SearchQuery): Promise; +} + +// Warning: (ae-forgotten-export) The symbol "QueryResultProps" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "SearchApiProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function SearchApiProviderForStorybook( + props: PropsWithChildren, +): JSX.Element; + +// @public (undocumented) +export const searchApiRef: ApiRef; + +// @public (undocumented) +export const SearchContextProvider: ({ + initialState, + children, +}: React_2.PropsWithChildren<{ + initialState?: SearchContextState | undefined; +}>) => JSX.Element; + +// Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const SearchContextProviderForStorybook: ( + props: ComponentProps & QueryResultProps, +) => JSX.Element; + +// @public (undocumented) +export type SearchContextState = { + term: string; + types: string[]; + filters: JsonObject; + pageCursor?: string; +}; + +// Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export const useSearch: () => SearchContextValue; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json new file mode 100644 index 0000000000..151660943e --- /dev/null +++ b/plugins/search-react/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-search-react", + "version": "0.0.0", + "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" + }, + "backstage": { + "role": "web-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/search-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli package build", + "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" + }, + "dependencies": { + "@backstage/plugin-search-common": "^0.3.3-next.1", + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/core-app-api": "^1.0.1-next.0", + "react-use": "^17.3.2", + "@backstage/types": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.13.1 || ^17.0.0", + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/test-utils": "^1.0.1-next.1", + "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^7.0.2", + "@testing-library/jest-dom": "^5.10.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/search-react/src/api.ts b/plugins/search-react/src/api.ts new file mode 100644 index 0000000000..eb8c9c23db --- /dev/null +++ b/plugins/search-react/src/api.ts @@ -0,0 +1,32 @@ +/* + * 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 { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; +import { createApiRef } from '@backstage/core-plugin-api'; + +/** + * @public + */ +export const searchApiRef = createApiRef({ + id: 'plugin.search.queryservice', +}); + +/** + * @public + */ +export interface SearchApi { + query(query: SearchQuery): Promise; +} diff --git a/plugins/search/src/components/SearchContext/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx similarity index 99% rename from plugins/search/src/components/SearchContext/SearchContext.test.tsx rename to plugins/search-react/src/context/SearchContext.test.tsx index 6513dc21bb..b19953f6a5 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx new file mode 100644 index 0000000000..d6245d5ccf --- /dev/null +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -0,0 +1,147 @@ +/* + * 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 { JsonObject } from '@backstage/types'; +import { useApi, AnalyticsContext } from '@backstage/core-plugin-api'; +import { SearchResultSet } from '@backstage/plugin-search-common'; +import React, { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import useAsync, { AsyncState } from 'react-use/lib/useAsync'; +import usePrevious from 'react-use/lib/usePrevious'; +import { searchApiRef } from '../api'; + +type SearchContextValue = { + result: AsyncState; + setTerm: React.Dispatch>; + setTypes: React.Dispatch>; + setFilters: React.Dispatch>; + setPageCursor: React.Dispatch>; + fetchNextPage?: React.DispatchWithoutAction; + fetchPreviousPage?: React.DispatchWithoutAction; +} & SearchContextState; + +/** + * + * @public + */ +export type SearchContextState = { + term: string; + types: string[]; + filters: JsonObject; + pageCursor?: string; +}; + +/** + * @public + */ +export const SearchContext = createContext( + undefined, +); + +/** + * The initial state of `SearchContextProvider`. + * + */ +const searchInitialState: SearchContextState = { + term: '', + pageCursor: undefined, + filters: {}, + types: [], +}; + +/** + * @public + */ +export const SearchContextProvider = ({ + initialState = searchInitialState, + children, +}: PropsWithChildren<{ initialState?: SearchContextState }>) => { + const searchApi = useApi(searchApiRef); + const [pageCursor, setPageCursor] = useState( + initialState.pageCursor, + ); + const [filters, setFilters] = useState(initialState.filters); + const [term, setTerm] = useState(initialState.term); + const [types, setTypes] = useState(initialState.types); + + const prevTerm = usePrevious(term); + + const result = useAsync( + () => + searchApi.query({ + term, + filters, + pageCursor, + types, + }), + [term, filters, types, pageCursor], + ); + + const hasNextPage = + !result.loading && !result.error && result.value?.nextPageCursor; + const hasPreviousPage = + !result.loading && !result.error && result.value?.previousPageCursor; + const fetchNextPage = useCallback(() => { + setPageCursor(result.value?.nextPageCursor); + }, [result.value?.nextPageCursor]); + const fetchPreviousPage = useCallback(() => { + setPageCursor(result.value?.previousPageCursor); + }, [result.value?.previousPageCursor]); + + useEffect(() => { + // Any time a term is reset, we want to start from page 0. + if (term && prevTerm && term !== prevTerm) { + setPageCursor(undefined); + } + }, [term, prevTerm, initialState.pageCursor]); + + const value: SearchContextValue = { + result, + filters, + setFilters, + term, + setTerm, + types, + setTypes, + pageCursor, + setPageCursor, + fetchNextPage: hasNextPage ? fetchNextPage : undefined, + fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined, + }; + + return ( + + + + ); +}; + +/** + * @public + */ +export const useSearch = () => { + const context = useContext(SearchContext); + if (context === undefined) { + throw new Error('useSearch must be used within a SearchContextProvider'); + } + return context; +}; diff --git a/plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx b/plugins/search-react/src/context/SearchContextForStorybook.stories.tsx similarity index 91% rename from plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx rename to plugins/search-react/src/context/SearchContextForStorybook.stories.tsx index 7d6c35b00c..51297c965f 100644 --- a/plugins/search/src/components/SearchContext/SearchContextForStorybook.stories.tsx +++ b/plugins/search-react/src/context/SearchContextForStorybook.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -17,7 +17,7 @@ import { ApiProvider } from '@backstage/core-app-api'; import { SearchResultSet } from '@backstage/plugin-search-common'; import { TestApiRegistry } from '@backstage/test-utils'; import React, { ComponentProps, PropsWithChildren } from 'react'; -import { searchApiRef } from '../../apis'; +import { searchApiRef } from '../api'; import { SearchContextProvider as RealSearchContextProvider } from './SearchContext'; type QueryResultProps = { @@ -26,7 +26,7 @@ type QueryResultProps = { /** * Utility context provider only for use in Storybook stories. You should use - * the real `` exported by `@backstage/plugin-search` in + * the real `` exported by `@backstage/plugin-search-react` in * your app instead of this! In some cases (like the search page) it may * already be provided on your behalf. */ @@ -40,6 +40,10 @@ export const SearchContextProvider = ( ); }; +/** + * Utility api provider only for use in Storybook stories. + * + */ export function SearchApiProvider(props: PropsWithChildren) { const { mockedResults, children } = props; const query: any = () => Promise.resolve(mockedResults || {}); diff --git a/plugins/search-react/src/context/index.tsx b/plugins/search-react/src/context/index.tsx new file mode 100644 index 0000000000..f2ea486e9d --- /dev/null +++ b/plugins/search-react/src/context/index.tsx @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export { + SearchContextProvider, + SearchContext, + useSearch, +} from './SearchContext'; +export type { SearchContextState } from './SearchContext'; +export { + SearchContextProvider as SearchContextProviderForStorybook, + SearchApiProvider as SearchApiProviderForStorybook, +} from './SearchContextForStorybook.stories'; diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts new file mode 100644 index 0000000000..934cf2e9f1 --- /dev/null +++ b/plugins/search-react/src/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 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 { searchApiRef } from './api'; +export type { SearchApi } from './api'; +export { + SearchContextProvider, + useSearch, + SearchContextProviderForStorybook, + SearchApiProviderForStorybook, +} from './context'; +export type { SearchContextState } from './context'; diff --git a/plugins/home/src/templates/index.ts b/plugins/search-react/src/setupTests.ts similarity index 81% rename from plugins/home/src/templates/index.ts rename to plugins/search-react/src/setupTests.ts index bfebe73cd8..992b60d3a4 100644 --- a/plugins/home/src/templates/index.ts +++ b/plugins/search-react/src/setupTests.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon'; -export { TemplateBackstageLogo } from './TemplateBackstageLogo' +import '@testing-library/jest-dom'; diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 5b2a1bb59a..2f9a8fbb77 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -83,7 +83,7 @@ export const Router: () => JSX.Element; // Warning: (ae-missing-release-tag) "SearchApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export interface SearchApi { // (undocumented) query(query: SearchQuery): Promise; @@ -91,7 +91,7 @@ export interface SearchApi { // Warning: (ae-missing-release-tag) "searchApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const searchApiRef: ApiRef; // @public (undocumented) @@ -140,7 +140,7 @@ export type SearchBarProps = Partial; // Warning: (ae-missing-release-tag) "SearchContextProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const SearchContextProvider: ({ initialState, children, @@ -330,10 +330,11 @@ export type SidebarSearchProps = { icon?: IconComponent; }; +// Warning: (tsdoc-at-sign-in-word) The "@" character looks like part of a TSDoc tag; use a backslash to escape it // Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export const useSearch: () => SearchContextValue; // @public diff --git a/plugins/search/package.json b/plugins/search/package.json index c32e830950..4ac891775e 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -39,6 +39,7 @@ "@backstage/core-plugin-api": "^1.0.0", "@backstage/errors": "^1.0.0", "@backstage/plugin-catalog-react": "^1.0.1-next.1", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/plugin-search-common": "^0.3.3-next.1", "@backstage/theme": "^0.2.15", "@backstage/types": "^1.0.0", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 908942d87d..096b11bb5e 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -21,12 +21,19 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; + import qs from 'qs'; +/** + * @deprecated import from `@backstage/plugin-search-react` instead + */ export const searchApiRef = createApiRef({ id: 'plugin.search.queryservice', }); +/** + * @deprecated import from `@backstage/plugin-search-react` instead + */ export interface SearchApi { query(query: SearchQuery): Promise; } diff --git a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx index c0d4b07965..72c7deb06b 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.stories.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.stories.tsx @@ -16,7 +16,7 @@ import { Grid, makeStyles, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchBar } from './SearchBar'; export default { @@ -24,13 +24,13 @@ export default { component: SearchBar, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index 100d8a3ac3..c87295ab99 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -51,6 +51,9 @@ export type SearchContextState = { pageCursor?: string; }; +/** + * @deprecated import from `@backstage/plugin-search-react` instead + */ export const SearchContext = createContext( undefined, ); @@ -62,6 +65,9 @@ const searchInitialState: SearchContextState = { types: [], }; +/** + * @deprecated import from `@backstage/plugin-search-react` instead + */ export const SearchContextProvider = ({ initialState = searchInitialState, children, @@ -126,6 +132,9 @@ export const SearchContextProvider = ({ ); }; +/** + * @deprecated import from "@backstage/plugin-search-react" instead + */ export const useSearch = () => { const context = useContext(SearchContext); if (context === undefined) { diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx index c43753e8d2..3c49b76699 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx @@ -16,7 +16,7 @@ import { Grid, Paper } from '@material-ui/core'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchFilter } from './SearchFilter'; export default { @@ -24,13 +24,13 @@ export default { component: SearchFilter, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx index 7ce4c9f0c6..e2e3db694b 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.stories.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.stories.tsx @@ -29,7 +29,7 @@ import React, { ComponentType } from 'react'; import { rootRouteRef } from '../../plugin'; import { DefaultResultListItem } from '../DefaultResultListItem'; import { SearchBar } from '../SearchBar'; -import { SearchApiProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchApiProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchModal } from './SearchModal'; import { SearchResult } from '../SearchResult'; import { SearchResultPager } from '../SearchResultPager'; @@ -71,9 +71,9 @@ export default { decorators: [ (Story: ComponentType<{}>) => wrapInTestApp( - + - , + , { mountedRoutes: { '/search': rootRouteRef } }, ), ], diff --git a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx index c1685df2c1..4a3d950164 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx @@ -19,7 +19,8 @@ import { List, ListItem } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router'; import { DefaultResultListItem } from '../DefaultResultListItem'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; + +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchResult } from './SearchResult'; const mockResults = { @@ -57,9 +58,9 @@ export default { decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], diff --git a/plugins/search/src/components/SearchType/SearchType.stories.tsx b/plugins/search/src/components/SearchType/SearchType.stories.tsx index 596c4a027a..b56f4c089c 100644 --- a/plugins/search/src/components/SearchType/SearchType.stories.tsx +++ b/plugins/search/src/components/SearchType/SearchType.stories.tsx @@ -19,7 +19,7 @@ import CatalogIcon from '@material-ui/icons/MenuBook'; import DocsIcon from '@material-ui/icons/Description'; import UsersGroupsIcon from '@material-ui/icons/Person'; import React, { ComponentType } from 'react'; -import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories'; +import { SearchContextProviderForStorybook } from '@backstage/plugin-search-react'; import { SearchType } from './SearchType'; export default { @@ -27,13 +27,13 @@ export default { component: SearchType, decorators: [ (Story: ComponentType<{}>) => ( - + - + ), ], }; diff --git a/plugins/tech-insights/README.md b/plugins/tech-insights/README.md index 5f7d7ad4e5..a305ef5dc7 100644 --- a/plugins/tech-insights/README.md +++ b/plugins/tech-insights/README.md @@ -38,6 +38,10 @@ const serviceEntityPage = ( title="Customized title for the scorecard" description="Small description about scorecards" /> + ... @@ -46,6 +50,8 @@ const serviceEntityPage = ( It is not obligatory to pass title and description props to `EntityTechInsightsScorecardContent`. If those are left out, default values from `defaultCheckResultRenderers` in `CheckResultRenderer` will be taken, hence `Boolean scorecard` and `This card represents an overview of default boolean Backstage checks`. +You can pass an array `checksId` as a prop with the [Fact Retrievers ids](../tech-insights-backend#creating-fact-retrievers) to limit which checks you want to show in this card, If you don't pass, the default value is show all checks. + ## Boolean Scorecard Example If you follow the [Backend Example](https://github.com/backstage/backstage/tree/master/plugins/tech-insights-backend#backend-example), once the needed facts have been generated the boolean scorecard will look like this: diff --git a/plugins/tech-insights/api-report.md b/plugins/tech-insights/api-report.md index deadd06010..9574efca28 100644 --- a/plugins/tech-insights/api-report.md +++ b/plugins/tech-insights/api-report.md @@ -34,9 +34,11 @@ export type CheckResultRenderer = { export const EntityTechInsightsScorecardContent: ({ title, description, + checksId, }: { title?: string | undefined; description?: string | undefined; + checksId?: string[] | undefined; }) => JSX.Element; // @public @@ -58,7 +60,7 @@ export interface TechInsightsApi { // (undocumented) runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise; } diff --git a/plugins/tech-insights/src/api/TechInsightsApi.ts b/plugins/tech-insights/src/api/TechInsightsApi.ts index 0a6bb937a6..a24bf74a01 100644 --- a/plugins/tech-insights/src/api/TechInsightsApi.ts +++ b/plugins/tech-insights/src/api/TechInsightsApi.ts @@ -47,7 +47,7 @@ export interface TechInsightsApi { getAllChecks(): Promise; runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise; runBulkChecks( entities: CompoundEntityRef[], diff --git a/plugins/tech-insights/src/api/TechInsightsClient.ts b/plugins/tech-insights/src/api/TechInsightsClient.ts index 5b9af9557f..04de46dba9 100644 --- a/plugins/tech-insights/src/api/TechInsightsClient.ts +++ b/plugins/tech-insights/src/api/TechInsightsClient.ts @@ -75,13 +75,12 @@ export class TechInsightsClient implements TechInsightsApi { async runChecks( entityParams: CompoundEntityRef, - checks?: Check[], + checks?: string[], ): Promise { const url = await this.discoveryApi.getBaseUrl('tech-insights'); const { token } = await this.identityApi.getCredentials(); const { namespace, kind, name } = entityParams; - const checkIds = checks ? checks.map(check => check.id) : []; - const requestBody = { checks: checkIds.length > 0 ? checkIds : undefined }; + const requestBody = { checks }; const response = await fetch( `${url}/checks/run/${encodeURIComponent(namespace)}/${encodeURIComponent( kind, diff --git a/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx index 73143a7ea8..53cf826e78 100644 --- a/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx +++ b/plugins/tech-insights/src/components/ScorecardsOverview/ScorecardsOverview.tsx @@ -26,14 +26,16 @@ import { techInsightsApiRef } from '../../api/TechInsightsApi'; export const ScorecardsOverview = ({ title, description, + checksId, }: { title?: string; description?: string; + checksId?: string[]; }) => { const api = useApi(techInsightsApiRef); const { namespace, kind, name } = useParams(); const { value, loading, error } = useAsync( - async () => await api.runChecks({ namespace, kind, name }), + async () => await api.runChecks({ namespace, kind, name }, checksId), ); if (loading) { diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 8516c09783..b6901e8edd 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -43,7 +43,7 @@ "@backstage/integration": "^1.1.0-next.1", "@backstage/integration-react": "^1.0.1-next.1", "@backstage/plugin-catalog-react": "^1.0.1-next.2", - "@backstage/plugin-search": "^0.7.5-next.0", + "@backstage/plugin-search-react": "^0.0.0", "@backstage/theme": "^0.2.15", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/plugins/techdocs/src/reader/components/Reader.test.tsx b/plugins/techdocs/src/reader/components/Reader.test.tsx index 6e8609694d..09cd33e286 100644 --- a/plugins/techdocs/src/reader/components/Reader.test.tsx +++ b/plugins/techdocs/src/reader/components/Reader.test.tsx @@ -25,7 +25,7 @@ import React from 'react'; import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api'; import { Reader } from './Reader'; import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx index 55637806a3..9fbf5c2195 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage.test.tsx @@ -30,7 +30,7 @@ import { TechDocsStorageApi, } from '../../api'; import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx index 06d5ac6aac..5957aecc4d 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearch.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { ApiProvider } from '@backstage/core-app-api'; -import { searchApiRef } from '@backstage/plugin-search'; +import { searchApiRef } from '@backstage/plugin-search-react'; import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { act, diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.tsx index 30cc692b7c..f11fa38948 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearch.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearch.tsx @@ -15,7 +15,10 @@ */ import { CompoundEntityRef } from '@backstage/catalog-model'; -import { SearchContextProvider, useSearch } from '@backstage/plugin-search'; +import { + SearchContextProvider, + useSearch, +} from '@backstage/plugin-search-react'; import { makeStyles, CircularProgress, diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md index 4015087b02..4686c2d39f 100644 --- a/plugins/todo-backend/README.md +++ b/plugins/todo-backend/README.md @@ -52,8 +52,9 @@ async function main() { ## Scanned Files -The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location` -](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. +The included `TodoReaderService` and `TodoScmReader` works by getting the entity source location from the catalog. + +The location source code is determined automatically. In case of the source code of the component is not in the same place of the entity YAML file, you can explicitly set the value of the [`backstage.io/source-location`](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. ## Parser Configuration diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 943462bd63..6dd14afcb4 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -310,7 +310,7 @@ describe('TodoReaderService', () => { }); }); - it('should throw if entity does not have a location', async () => { + it('should not throw if entity does not have a location', async () => { const todoReader = mockTodoReader([]); const catalogClient = mockCatalogClient({ ...mockEntity, @@ -320,9 +320,8 @@ describe('TodoReaderService', () => { await expect(service.listTodos({ entity: entityName })).rejects.toEqual( expect.objectContaining({ - name: 'InputError', - message: - 'No entity location annotation found for component:default/my-component', + name: 'Error', + message: "Entity 'component:default/my-component' is missing location", }), ); }); @@ -334,7 +333,8 @@ describe('TodoReaderService', () => { metadata: { ...mockEntity.metadata, annotations: { - ['backstage.io/managed-by-location']: 'file:../info.yaml', + ['backstage.io/managed-by-location']: + 'file:../managed-by-location.yaml', }, }, }); @@ -343,7 +343,7 @@ describe('TodoReaderService', () => { await expect(service.listTodos({ entity: entityName })).rejects.toEqual( expect.objectContaining({ name: 'InputError', - message: `Invalid entity location type for component:default/my-component, got 'file'`, + message: `Invalid entity location type for component:default/my-component, got 'file' for location ../managed-by-location.yaml`, }), ); }); @@ -355,7 +355,7 @@ describe('TodoReaderService', () => { metadata: { ...mockEntity.metadata, annotations: { - ['backstage.io/source-location']: 'file:../info.yaml', + ['backstage.io/source-location']: 'file:../source-location.yaml', }, }, }); @@ -364,7 +364,7 @@ describe('TodoReaderService', () => { await expect(service.listTodos({ entity: entityName })).rejects.toEqual( expect.objectContaining({ name: 'InputError', - message: `Invalid entity source location type for component:default/my-component, got 'file'`, + message: `Invalid entity location type for component:default/my-component, got 'file' for location ../source-location.yaml`, }), ); }); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 4397f34045..35fd76b8be 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -17,10 +17,7 @@ import { InputError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; import { - ANNOTATION_LOCATION, - ANNOTATION_SOURCE_LOCATION, - Entity, - parseLocationRef, + getEntitySourceLocation, stringifyEntityRef, } from '@backstage/catalog-model'; import { TodoReader } from '../lib'; @@ -74,8 +71,15 @@ export class TodoReaderService implements TodoService { `Entity not found, ${stringifyEntityRef(req.entity)}`, ); } - - const url = this.getEntitySourceUrl(entity); + const entitySourceLocation = getEntitySourceLocation(entity); + if (entitySourceLocation.type !== 'url') { + throw new InputError( + `Invalid entity location type for ${stringifyEntityRef(entity)}, got '${ + entitySourceLocation.type + }' for location ${entitySourceLocation.target}`, + ); + } + const url = entitySourceLocation.target; const todos = await this.todoReader.readTodos({ url }); let limit = req.limit ?? this.defaultPageSize; @@ -125,36 +129,4 @@ export class TodoReaderService implements TodoService { limit, }; } - - private getEntitySourceUrl(entity: Entity) { - const sourceLocation = - entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION]; - if (sourceLocation) { - const parsed = parseLocationRef(sourceLocation); - if (parsed.type !== 'url') { - throw new InputError( - `Invalid entity source location type for ${stringifyEntityRef( - entity, - )}, got '${parsed.type}'`, - ); - } - return parsed.target; - } - - const location = entity.metadata.annotations?.[ANNOTATION_LOCATION]; - if (location) { - const parsed = parseLocationRef(location); - if (parsed.type !== 'url') { - throw new InputError( - `Invalid entity location type for ${stringifyEntityRef( - entity, - )}, got '${parsed.type}'`, - ); - } - return parsed.target; - } - throw new InputError( - `No entity location annotation found for ${stringifyEntityRef(entity)}`, - ); - } } diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js index ad783c4609..d5ad1842d4 100755 --- a/scripts/prepare-release.js +++ b/scripts/prepare-release.js @@ -39,6 +39,43 @@ const DEPENDENCY_TYPES = [ 'peerDependencies', ]; +/** + * Finds the current stable release version of the repo, looking at + * the current commit and backwards, finding the first commit were a + * stable version is present. + */ +async function findCurrentReleaseVersion(repo) { + const rootPkgPath = path.resolve(repo.root.dir, 'package.json'); + const pkg = await fs.readJson(rootPkgPath); + + if (!semver.prerelease(pkg.version)) { + return pkg.version; + } + + const { stdout: revListStr } = await execFile('git', [ + 'rev-list', + 'HEAD', + '--', + 'package.json', + ]); + const revList = revListStr.trim().split(/\r?\n/); + + for (const rev of revList) { + const { stdout: pkgJsonStr } = await execFile('git', [ + 'show', + `${rev}:package.json`, + ]); + if (pkgJsonStr) { + const pkgJson = JSON.parse(pkgJsonStr); + if (!semver.prerelease(pkgJson.version)) { + return pkgJson.version; + } + } + } + + throw new Error('No stable release found'); +} + /** * Finds the tip of the patch branch of a given release version. * Returns undefined if no patch branch exists. @@ -63,7 +100,7 @@ async function findTipOfPatchBranch(repo, release) { * Returns a map of packages to their versions for any package version * in that does not match the current version in the working directory. */ -async function detectPatchVersionsForRef(repo, ref) { +async function detectPatchVersionsBetweenRefs(repo, baseRef, ref) { const patchVersions = new Map(); for (const pkg of repo.packages) { @@ -72,20 +109,29 @@ async function detectPatchVersionsForRef(repo, ref) { 'package.json', ); try { + const { stdout: basePkgJsonStr } = await execFile('git', [ + 'show', + `${baseRef}:${pkgJsonPath}`, + ]); + const { stdout: pkgJsonStr } = await execFile('git', [ 'show', `${ref}:${pkgJsonPath}`, ]); - if (pkgJsonStr) { + if (basePkgJsonStr && pkgJsonStr) { + const basePkgJson = JSON.parse(basePkgJsonStr); const releasePkgJson = JSON.parse(pkgJsonStr); - const pkgJson = pkg.packageJson; - if (releasePkgJson.name !== pkgJson.name) { + + if (releasePkgJson.private) { + continue; + } + if (releasePkgJson.name !== basePkgJson.name) { throw new Error( - `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${pkgJson.name}`, + `Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${basePkgJson.name}`, ); } - if (releasePkgJson.version !== pkgJson.version) { - patchVersions.set(pkgJson.name, releasePkgJson.version); + if (releasePkgJson.version !== basePkgJson.version) { + patchVersions.set(basePkgJson.name, releasePkgJson.version); } } } catch (error) { @@ -110,24 +156,33 @@ async function detectPatchVersionsForRef(repo, ref) { async function applyPatchVersions(repo, patchVersions) { const pendingVersionBumps = new Map(); - for (const [name, version] of patchVersions) { + for (const [name, patchVersion] of patchVersions) { const pkg = repo.packages.find(p => p.packageJson.name === name); if (!pkg) { throw new Error(`Package ${name} not found`); } - if (!semver.valid(version)) { - throw new Error(`Invalid base version ${version} for package ${name}`); + if (!semver.valid(patchVersion)) { + throw new Error( + `Invalid base version ${patchVersion} for package ${name}`, + ); } - let targetVersion = version; + if (semver.gte(pkg.packageJson.version, patchVersion)) { + console.log( + `No need to bump ${name} ${pkg.packageJson.version} is already ahead of ${patchVersion}`, + ); + continue; + } + + let targetVersion = patchVersion; // If we're currently in a pre-release we need to manually execute the // patch bump up to the next version. And we also need to make sure we // resume the releases at the same pre-release tag. const currentPrerelease = semver.prerelease(pkg.packageJson.version); if (currentPrerelease) { - const parsed = targetVersion.parse(version); + const parsed = semver.parse(targetVersion); parsed.inc('patch'); parsed.prerelease = currentPrerelease; targetVersion = parsed.format(); @@ -182,15 +237,18 @@ async function applyPatchVersions(repo, patchVersions) { * the main branch, and then bumps all packages in the repo accordingly. */ async function updatePackageVersions(repo) { - const rootPkgPath = path.resolve(repo.root.dir, 'package.json'); - const { version: currentRelease } = await fs.readJson(rootPkgPath); + const currentRelease = await findCurrentReleaseVersion(repo); console.log(`Current release version: ${currentRelease}`); const patchRef = await findTipOfPatchBranch(repo, currentRelease); if (patchRef) { console.log(`Tip of the patch branch: ${patchRef}`); - const patchVersions = await detectPatchVersionsForRef(repo, patchRef); + const patchVersions = await detectPatchVersionsBetweenRefs( + repo, + `v${currentRelease}`, + patchRef, + ); if (patchVersions.size > 0) { console.log( `Found ${patchVersions.size} packages that were patched since the last release`, diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js new file mode 100755 index 0000000000..e11ae2e55d --- /dev/null +++ b/scripts/verify-local-dependencies.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const fs = require('fs-extra'); +const semver = require('semver'); +const { getPackages } = require('@manypkg/get-packages'); +const { + resolve: resolvePath, + relative: relativePath, + join: joinPath, +} = require('path'); + +/** + * This script checks that all local package dependencies within the repo + * point to the correct version ranges. + * + * It can be run with a `--fix` flag to a automatically fix any issues. + */ + +const depTypes = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +async function main(args) { + const shouldFix = args.includes('--fix'); + const rootPath = resolvePath(__dirname, '..'); + const { packages } = await getPackages(rootPath); + + let hadErrors = false; + + const pkgMap = new Map(packages.map(pkg => [pkg.packageJson.name, pkg])); + + for (const pkg of packages) { + let fixed = false; + + for (const depType of depTypes) { + const deps = pkg.packageJson[depType]; + + for (const [dep, range] of Object.entries(deps || {})) { + if (range === '' || range.startsWith('link:')) { + continue; + } + const localPackage = pkgMap.get(dep); + if (localPackage) { + const localVersion = localPackage.packageJson.version; + if (!semver.satisfies(localVersion, range)) { + const path = joinPath( + relativePath(rootPath, pkg.dir), + 'package.json', + ); + console.log( + `${path} depends on the wrong version of ${dep}: ${range} does not satisfy ${localVersion}`, + ); + hadErrors = true; + + fixed = true; + pkg.packageJson[depType][dep] = `^${localVersion}`; + } + } + } + } + + if (shouldFix && fixed) { + await fs.writeJson(joinPath(pkg.dir, 'package.json'), pkg.packageJson, { + spaces: 2, + }); + } + } + + if (!shouldFix && hadErrors) { + console.error(); + console.error('At least one package has an invalid local dependency'); + console.error( + 'Run `node scripts/verify-local-dependencies.js --fix` to fix', + ); + + process.exit(2); + } +} + +main(process.argv.slice(2)).catch(error => { + console.error(error.stack || error); + process.exit(1); +}); diff --git a/storybook/.storybook/main.js b/storybook/.storybook/main.js index 2356e4e41b..8ed4949cf3 100644 --- a/storybook/.storybook/main.js +++ b/storybook/.storybook/main.js @@ -6,6 +6,7 @@ const WebpackPluginFailBuildOnWarning = require('./webpack-plugin-fail-build-on- */ const BACKSTAGE_CORE_STORIES = [ 'packages/core-components', + 'packages/app', 'plugins/org', 'plugins/search', 'plugins/home', diff --git a/storybook/package.json b/storybook/package.json index 0aeb541955..87c627ac4f 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@sucrase/webpack-loader": "^2.0.0", - "sucrase": "^3.20.3", + "sucrase": "^3.21.0", "react": "^17.0.2", "react-hot-loader": "^4.13.0", "react-dom": "^17.0.2" @@ -17,7 +17,7 @@ "devDependencies": { "@storybook/addon-a11y": "^6.4.20", "@storybook/addon-actions": "^6.4.20", - "@storybook/addon-links": "^6.4.20", + "@storybook/addon-links": "^6.4.21", "@storybook/addon-storysource": "^6.4.20", "@storybook/addons": "^6.4.20", "@storybook/react": "^6.4.20", diff --git a/storybook/yarn.lock b/storybook/yarn.lock index a8ce5d1ff7..fb7aa071fe 100644 --- a/storybook/yarn.lock +++ b/storybook/yarn.lock @@ -1356,16 +1356,16 @@ util-deprecate "^1.0.2" uuid-browser "^3.1.0" -"@storybook/addon-links@^6.4.20": - version "6.4.20" - resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.4.20.tgz#7e845a20deece65e7e684433d4c66a6ad61da52c" - integrity sha512-TyRuEd/3yRn2N9xasCKuE2bsY0dTRjAquGeg5WEtvHvr8V6QBLYAC4caXwPxIHSTcRQyO5IYYiVzEJ/+219neA== +"@storybook/addon-links@^6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.4.21.tgz#7251406c3060b63684f4de56799385f3675867c6" + integrity sha512-KajbsVAmCLVSKsrPnUEsfWuD5V0lbNBAtdil0EiOqWZU0r3ch92aSMh6H13zfT+lEPlh0PVLKamHur1js1iXGQ== dependencies: - "@storybook/addons" "6.4.20" - "@storybook/client-logger" "6.4.20" - "@storybook/core-events" "6.4.20" + "@storybook/addons" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" "@storybook/csf" "0.0.2--canary.87bc651.0" - "@storybook/router" "6.4.20" + "@storybook/router" "6.4.21" "@types/qs" "^6.9.5" core-js "^3.8.2" global "^4.4.0" @@ -1394,7 +1394,7 @@ react-syntax-highlighter "^13.5.3" regenerator-runtime "^0.13.7" -"@storybook/addons@6.4.20", "@storybook/addons@^6.4.20": +"@storybook/addons@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.20.tgz#bbf568b7c4c5a25ef296f285aef0299998ec5933" integrity sha512-NbsLjDSkE9v2fOr0M7r2hpdYnlYs789ALkXemdTz2y0NUYSPdRfzVVQNXWrgmXivWQRL0aJ3bOjCOc668PPYjg== @@ -1411,6 +1411,23 @@ global "^4.4.0" regenerator-runtime "^0.13.7" +"@storybook/addons@6.4.21", "@storybook/addons@^6.4.20": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.21.tgz#a0081d167eda8a30b2206ccabfe75abae0bb6b58" + integrity sha512-TFLv4FyqP5SBOHEqE6tiW+2++HngkyQ2KRbHICC7khQgRqDkrwvrdKZwzF29igseglhSmftpZrBLXyWbA7q1vg== + dependencies: + "@storybook/api" "6.4.21" + "@storybook/channels" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.21" + "@storybook/theming" "6.4.21" + "@types/webpack-env" "^1.16.0" + core-js "^3.8.2" + global "^4.4.0" + regenerator-runtime "^0.13.7" + "@storybook/api@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.20.tgz#65da720985b4b46998a405bddc42c9cef9bad7e4" @@ -1434,6 +1451,29 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/api@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.21.tgz#efee41ae7bde37f6fe43ee960fef1a261b1b1dd6" + integrity sha512-AULsLd7ew11IRCpzffyLFGl5cwt9BLMok33DcIlCyvXsiqLm4/OsbgM4sj6QqWVuxcFlWMQJHoRJyeFlULFvZA== + dependencies: + "@storybook/channels" "6.4.21" + "@storybook/client-logger" "6.4.21" + "@storybook/core-events" "6.4.21" + "@storybook/csf" "0.0.2--canary.87bc651.0" + "@storybook/router" "6.4.21" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.4.21" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.12.0" + telejson "^5.3.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/builder-webpack4@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.4.20.tgz#e3b5d6b665fbf5a1ec75b7ef32c4c811897ef20d" @@ -1542,6 +1582,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/channels@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.4.21.tgz#0f1924963f77ec0c3d82aa643a246824ca9f5fca" + integrity sha512-qgy8z3Hp04Q4p+E/8V9MamYYJLW8z1uv1Z+rvosNkg+eAApPg+Qe08BSj59OAUwPLrr2vpBW7WZ/BYSieW1tUg== + dependencies: + core-js "^3.8.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-api@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.4.20.tgz#17a24af4bc047f7a6de647b9c1844ab4e40baf83" @@ -1576,6 +1625,14 @@ core-js "^3.8.2" global "^4.4.0" +"@storybook/client-logger@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.4.21.tgz#7df21cec4d5426669e828af59232ec44ea19c81a" + integrity sha512-XkVCQ5swyYDVh5U+87DGRBdC5utJBpVW7kU5P14TQKMnSc/yHbMcXWaA89K8WKDa/WGkGbc0bKi4WrUwHFg2FA== + dependencies: + core-js "^3.8.2" + global "^4.4.0" + "@storybook/components@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.4.20.tgz#d063b6a7e70e1be7c8aa79220bb2cd92be8057a1" @@ -1694,6 +1751,13 @@ dependencies: core-js "^3.8.2" +"@storybook/core-events@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.21.tgz#28fff8b10c0d564259edf4439ff8677615ce59c0" + integrity sha512-K6b9M1zYvW/Kfb1cnH6JDfmFvTYDMx/ot9zdl9O5SPH9glUwzOXSk8qKu6GmZTiW2YnC2nKbjaN20mfMsCBPGw== + dependencies: + core-js "^3.8.2" + "@storybook/core-server@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.4.20.tgz#6bdf6dd5d83713034df950a98f7638e23c64171c" @@ -1915,6 +1979,23 @@ react-router-dom "^6.0.0" ts-dedent "^2.0.0" +"@storybook/router@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.4.21.tgz#a18172601907918c1442a8a125c9c625d798d09b" + integrity sha512-otn3xYc017SNebeA95xLQ7P6elfyu9541QteXbLR5gFvrT+MB/8zMRZrVuD7n1xwpBgazlonzAdODC736Be9jQ== + dependencies: + "@storybook/client-logger" "6.4.21" + core-js "^3.8.2" + fast-deep-equal "^3.1.3" + global "^4.4.0" + history "5.0.0" + lodash "^4.17.21" + memoizerific "^1.11.3" + qs "^6.10.0" + react-router "^6.0.0" + react-router-dom "^6.0.0" + ts-dedent "^2.0.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -1978,6 +2059,24 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" +"@storybook/theming@6.4.21": + version "6.4.21" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.21.tgz#ea1a33be70c654cb31e5b38fae93f72171e88ef8" + integrity sha512-7pLNwmqbyqCeHXzjsacI69IdJcAZr6zoZA84iGqx+Na32OI8wtIpFczbwuYpVPN2jzgRYp23CgIv1Gz27yk/zw== + dependencies: + "@emotion/core" "^10.1.1" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.27" + "@storybook/client-logger" "6.4.21" + core-js "^3.8.2" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.27" + global "^4.4.0" + memoizerific "^1.11.3" + polished "^4.0.5" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + "@storybook/ui@6.4.20": version "6.4.20" resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.4.20.tgz#30e8fba0877b66000841046133d3dc098a807d13" @@ -8001,10 +8100,10 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" -sucrase@^3.20.3: - version "3.20.3" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.20.3.tgz#424f1e75b77f955724b06060f1ae708f5f0935cf" - integrity sha512-azqwq0/Bs6RzLAdb4dXxsCgMtAaD2hzmUr4UhSfsxO46JFPAwMnnb441B/qsudZiS6Ylea3JXZe3Q497lsgXzQ== +sucrase@^3.21.0: + version "3.21.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.21.0.tgz#6a5affdbe716b22e4dc99c57d366ad0d216444b9" + integrity sha512-FjAhMJjDcifARI7bZej0Bi1yekjWQHoEvWIXhLPwDhC6O4iZ5PtGb86WV56riW87hzpgB13wwBKO9vKAiWu5VQ== dependencies: commander "^4.0.0" glob "7.1.6" diff --git a/yarn.lock b/yarn.lock index fbff8be9c3..dac475d2b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,14 +1478,6 @@ lodash "^4.17.21" uuid "^8.0.0" -"@backstage/config@^0.1.15": - version "0.1.15" - resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.15.tgz#4bad122ad861be5bd61a60639f92d2494fa245c5" - integrity sha512-eNJEYYSEu9MkrkBYiMpUBWEc3Bu64YgB9pZZGCMW7/9350tV2wbylEdoBJHslilJlJhiUyTXBckn8Ua7DOH7rw== - dependencies: - "@backstage/types" "^0.1.3" - lodash "^4.17.21" - "@backstage/core-components@^0.9.0", "@backstage/core-components@^0.9.2": version "0.9.2" resolved "https://registry.npmjs.org/@backstage/core-components/-/core-components-0.9.2.tgz#9a3d79a15039256bbc007e5daa08c983050e0238" @@ -1610,11 +1602,6 @@ react-use "^17.2.4" swr "^1.1.2" -"@backstage/types@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@backstage/types/-/types-0.1.3.tgz#6613d8cbdf97d42d31cd1e66a833df533e7ccf14" - integrity sha512-fJVi4oVrlO+G3PRv1fYSll9/X4pE11HLnkI//Geare9sP6wSfp/2zXpLYfKVsG0e24jOl7Swkc8lwLkQ90zMaQ== - "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" @@ -1630,16 +1617,16 @@ resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.0.tgz#fe364f025ba74f6de6c837a84ef44bdb1d61e68f" integrity sha512-mgmE7XBYY/21erpzhexk4Cj1cyTQ9LzvnTxtzM17BJ7ERMNE6W72mQRo0I1Ud8eFJ+RVVIcBNhLFZ3GX4XFz5w== -"@changesets/apply-release-plan@^5.0.5": - version "5.0.5" - resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-5.0.5.tgz#d67b1e022c876d18d887f3c475a3abcad9944b68" - integrity sha512-CxL9dkhzjHiVmXCyHgsLCQj7i/coFTMv/Yy0v6BC5cIWZkQml+lf7zvQqAcFXwY7b54HxRWZPku02XFB53Q0Uw== +"@changesets/apply-release-plan@^6.0.0": + version "6.0.0" + resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-6.0.0.tgz#6c663ff99d919bba3902343d76c35cbbbb046520" + integrity sha512-gp6nIdVdfYdwKww2+f8whckKmvfE4JEm4jJgBhTmooi0uzHWhnxvk6JIzQi89qEAMINN0SeVNnXiAtbFY0Mj3w== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/config" "^1.7.0" + "@changesets/config" "^2.0.0" "@changesets/get-version-range-type" "^0.3.2" - "@changesets/git" "^1.3.1" - "@changesets/types" "^4.1.0" + "@changesets/git" "^1.3.2" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" detect-indent "^6.0.0" fs-extra "^7.0.1" @@ -1649,44 +1636,44 @@ resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^5.1.0": - version "5.1.0" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-5.1.0.tgz#0fcb18253998e3bc037a554874de43bcc58c4840" - integrity sha512-iYlqffCMhcwZ+6Cv8cimf10OBGYXQKufBI7J6htpRgCV2nT99RKXEjbYOtrXWKQqzu0XxOsk15apSEwjZN0JRw== +"@changesets/assemble-release-plan@^5.1.2": + version "5.1.2" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-5.1.2.tgz#63ed3a00f62b5af08a82e83801a252ac9726c625" + integrity sha512-nOFyDw4APSkY/vh5WNwGEtThPgEjVShp03PKVdId6wZTJALVcAALCSLmDRfeqjE2z9EsGJb7hZdDlziKlnqZgw== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" - "@changesets/types" "^4.1.0" + "@changesets/get-dependents-graph" "^1.3.2" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" semver "^5.4.1" -"@changesets/changelog-git@^0.1.10": - version "0.1.10" - resolved "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.1.10.tgz#df616e92671082a7976381280b4af98ff3a7067d" - integrity sha512-4t7zqPOv3aDZp4Y+AyDhiOG2ypaUXDpOz+MT1wOk3uSZNv78AaDByam0hdk5kfYuH1RlMecWU4/U5lO1ZL5eaA== +"@changesets/changelog-git@^0.1.11": + version "0.1.11" + resolved "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.1.11.tgz#80eb45d3562aba2164f25ccc31ac97b9dcd1ded3" + integrity sha512-sWJvAm+raRPeES9usNpZRkooeEB93lOpUN0Lmjz5vhVAb7XGIZrHEJ93155bpE1S0c4oJ5Di9ZWgzIwqhWP/Wg== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@changesets/cli@^2.14.0": - version "2.21.0" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.21.0.tgz#b689f91ed908150efc06e0985e6b4cfbd9aea3a3" - integrity sha512-cJXRg28MmF9VbQrlwSjpY4AJA2xZUbXFCpQ3kFmX0IeppO7wknZ2QfocAhIqwM828t8d3R4Zpi5xnvJ/crIcQw== + version "2.22.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.22.0.tgz#3bdbfb4a7a81ef37f63114e77da84e23f906b763" + integrity sha512-4bA3YoBkd5cm5WUxmrR2N9WYE7EeQcM+R3bVYMUj2NvffkQVpU3ckAI+z8UICoojq+HRl2OEwtz+S5UBmYY4zw== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/apply-release-plan" "^5.0.5" - "@changesets/assemble-release-plan" "^5.1.0" - "@changesets/changelog-git" "^0.1.10" - "@changesets/config" "^1.7.0" + "@changesets/apply-release-plan" "^6.0.0" + "@changesets/assemble-release-plan" "^5.1.2" + "@changesets/changelog-git" "^0.1.11" + "@changesets/config" "^2.0.0" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" - "@changesets/get-release-plan" "^3.0.6" - "@changesets/git" "^1.3.1" + "@changesets/get-dependents-graph" "^1.3.2" + "@changesets/get-release-plan" "^3.0.8" + "@changesets/git" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/pre" "^1.0.10" - "@changesets/read" "^0.5.4" - "@changesets/types" "^4.1.0" - "@changesets/write" "^0.1.7" + "@changesets/pre" "^1.0.11" + "@changesets/read" "^0.5.5" + "@changesets/types" "^5.0.0" + "@changesets/write" "^0.1.8" "@manypkg/get-packages" "^1.1.3" "@types/is-ci" "^3.0.0" "@types/semver" "^6.0.0" @@ -1700,20 +1687,21 @@ outdent "^0.5.0" p-limit "^2.2.0" preferred-pm "^3.0.0" + resolve-from "^5.0.0" semver "^5.4.1" spawndamnit "^2.0.0" term-size "^2.1.0" tty-table "^2.8.10" -"@changesets/config@^1.7.0": - version "1.7.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.7.0.tgz#18353f88ea8153d7f1fb7c321a3fe8667035eddb" - integrity sha512-Ctk6ZO5Ay6oZ95bbKXyA2a1QG0jQUePaGCY6BKkZtUG4PgysesfmiQOPgOY5OsRMt8exJeo6l+DJ75YiKmh0rQ== +"@changesets/config@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-2.0.0.tgz#1770fdfeba2155cf07154c37e96b55cbd27969f0" + integrity sha512-r5bIFY6CN3K6SQ+HZbjyE3HXrBIopONR47mmX7zUbORlybQXtympq9rVAOzc0Oflbap8QeIexc+hikfZoREXDg== dependencies: "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.3.1" + "@changesets/get-dependents-graph" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" fs-extra "^7.0.1" micromatch "^4.0.2" @@ -1725,28 +1713,28 @@ dependencies: extendable-error "^0.1.5" -"@changesets/get-dependents-graph@^1.3.1": - version "1.3.1" - resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.3.1.tgz#f1ebadbd4e17bb2b987c4542a588e0ee9f2e829a" - integrity sha512-HwUs8U0XK/ZqCQon1/80jJEyswS8JVmTiHTZslrTpuavyhhhxrSpO1eVCdKgaVHBRalOw3gRzdS3uzkmqYsQSQ== +"@changesets/get-dependents-graph@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.3.2.tgz#f3ec7ce75f4afb6e3e4b6a87fde065f552c85998" + integrity sha512-tsqA6qZRB86SQuApSoDvI8yEWdyIlo/WLI4NUEdhhxLMJ0dapdeT6rUZRgSZzK1X2nv5YwR0MxQBbDAiDibKrg== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" chalk "^2.1.0" fs-extra "^7.0.1" semver "^5.4.1" -"@changesets/get-release-plan@^3.0.6": - version "3.0.6" - resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-3.0.6.tgz#15aac108b9d0f139841562c9372d8cfd738503dc" - integrity sha512-HpPyr8y6xkihy3rONLZ6OtfgYq88NotidPAuS3nwMeZjLHiIVLyejR2+/5q717f6HKcrATxAjTwMAcjl7X/uzA== +"@changesets/get-release-plan@^3.0.8": + version "3.0.8" + resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-3.0.8.tgz#2ac7c4f7903aedede3d27af66311ad1db7937e5d" + integrity sha512-TJYiWNuP0Lzu2dL/KHuk75w7TkiE5HqoYirrXF7SJIxkhlgH9toQf2C7IapiFTObtuF1qDN8HJAX1CuIOwXldg== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/assemble-release-plan" "^5.1.0" - "@changesets/config" "^1.7.0" - "@changesets/pre" "^1.0.10" - "@changesets/read" "^0.5.4" - "@changesets/types" "^4.1.0" + "@changesets/assemble-release-plan" "^5.1.2" + "@changesets/config" "^2.0.0" + "@changesets/pre" "^1.0.11" + "@changesets/read" "^0.5.5" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" "@changesets/get-version-range-type@^0.3.2": @@ -1754,14 +1742,14 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.3.1": - version "1.3.1" - resolved "https://registry.npmjs.org/@changesets/git/-/git-1.3.1.tgz#e86b4d2b28acdf9bc8949031027a9ac12420b99e" - integrity sha512-yg60QUi38VA0XGXdBy9SRYJhs8xJHE97Z1CaB/hFyByBlh5k1i+avFNBvvw66MsoT/aiml6y9scIG6sC8R5mfg== +"@changesets/git@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@changesets/git/-/git-1.3.2.tgz#336051d9a6d965806b1bc473559a9a2cc70773a6" + integrity sha512-p5UL+urAg0Nnpt70DLiBe2iSsMcDubTo9fTOD/61krmcJ466MGh71OHwdAwu1xG5+NKzeysdy1joRTg8CXcEXA== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" is-subdir "^1.1.1" spawndamnit "^2.0.0" @@ -1773,51 +1761,56 @@ dependencies: chalk "^2.1.0" -"@changesets/parse@^0.3.12": - version "0.3.12" - resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.12.tgz#60569bb39ad4ffe47fc01d431613ce5c42e6590f" - integrity sha512-FOBz2L1dT9PcvyQU1Qp2sQ0B4Jw7EgRDAKFVzAQwhzXqCq03TcE7vgKU6VSksCJAioMYDowdVVHNnv/Uak6yZQ== +"@changesets/parse@^0.3.13": + version "0.3.13" + resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.13.tgz#82788c1fc18da4750b07357a7a06142d0d975aa1" + integrity sha512-wh9Ifa0dungY6d2nMz6XxF6FZ/1I7j+mEgPAqrIyKS64nifTh1Ua82qKKMMK05CL7i4wiB2NYc3SfnnCX3RVeA== dependencies: - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" js-yaml "^3.13.1" -"@changesets/pre@^1.0.10": - version "1.0.10" - resolved "https://registry.npmjs.org/@changesets/pre/-/pre-1.0.10.tgz#e677031f271cdab8443b21e0b3cda036a3919c30" - integrity sha512-cZC1C1wTSC17/TcTWivAQ4LAXz5jEYDuy3UeZiBz1wnTTzMHyTHLLwJi60juhl4hawXunDLw0mwZkcpS8Ivitg== +"@changesets/pre@^1.0.11": + version "1.0.11" + resolved "https://registry.npmjs.org/@changesets/pre/-/pre-1.0.11.tgz#46a56790fdceabd03407559bbf91340c8e83fb6a" + integrity sha512-CXZnt4SV9waaC9cPLm7818+SxvLKIDHUxaiTXnJYDp1c56xIexx1BNfC1yMuOdzO2a3rAIcZua5Odxr3dwSKfg== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" "@manypkg/get-packages" "^1.1.3" fs-extra "^7.0.1" -"@changesets/read@^0.5.4": - version "0.5.4" - resolved "https://registry.npmjs.org/@changesets/read/-/read-0.5.4.tgz#c6dc6ab00c4f70f2ce6766433d92eebde1b00e7a" - integrity sha512-12dTx+p5ztFs9QgJDGHRHR6HzTIbHct9S4lK2I/i6Qkz1cNfAPVIbdoMCdbPIWeLank9muMUjiiFmCWJD7tQIg== +"@changesets/read@^0.5.5": + version "0.5.5" + resolved "https://registry.npmjs.org/@changesets/read/-/read-0.5.5.tgz#9ed90ef3e9f1ba3436ba5580201854a3f4163058" + integrity sha512-bzonrPWc29Tsjvgh+8CqJ0apQOwWim0zheeD4ZK44ApSa/GudnZJTODtA3yNOOuQzeZmL0NUebVoHIurtIkA7w== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/git" "^1.3.1" + "@changesets/git" "^1.3.2" "@changesets/logger" "^0.0.5" - "@changesets/parse" "^0.3.12" - "@changesets/types" "^4.1.0" + "@changesets/parse" "^0.3.13" + "@changesets/types" "^5.0.0" chalk "^2.1.0" fs-extra "^7.0.1" p-filter "^2.1.0" -"@changesets/types@^4.0.1", "@changesets/types@^4.1.0": +"@changesets/types@^4.0.1": version "4.1.0" resolved "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz#fb8f7ca2324fd54954824e864f9a61a82cb78fe0" integrity sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw== -"@changesets/write@^0.1.7": - version "0.1.7" - resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.7.tgz#671def0d871cf5970c5b2f766f0ac4b19ecf1ddb" - integrity sha512-6r+tc6u2l5BBIwEAh7ivRYWFir+XKiw0q/6Hx6NJA4dSN5fNu9uyWRQ+IMHCllD9dBcsh+e79sOepc+xT8l28g== +"@changesets/types@^5.0.0": + version "5.0.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-5.0.0.tgz#d5eb52d074bc0358ce47d54bca54370b907812a0" + integrity sha512-IT1kBLSbAgTS4WtpU6P5ko054hq12vk4tgeIFRVE7Vnm4a/wgbNvBalgiKP0MjEXbCkZbItiGQHkCGxYWR55sA== + +"@changesets/write@^0.1.8": + version "0.1.8" + resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.8.tgz#feed408f644c496bc52afc4dd1353670b4152ecb" + integrity sha512-oIHeFVMuP6jf0TPnKPpaFpvvAf3JBc+s2pmVChbeEgQTBTALoF51Z9kqxQfG4XONZPHZnqkmy564c7qohhhhTQ== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/types" "^4.1.0" + "@changesets/types" "^5.0.0" fs-extra "^7.0.1" human-id "^1.0.2" prettier "^1.19.1" @@ -2012,9 +2005,9 @@ "@codemirror/text" "^0.19.0" "@codemirror/stream-parser@^0.19.0", "@codemirror/stream-parser@^0.19.2", "@codemirror/stream-parser@^0.19.6": - version "0.19.7" - resolved "https://registry.npmjs.org/@codemirror/stream-parser/-/stream-parser-0.19.7.tgz#99a9696307b90677f6e3abe29ea6d77c1c0a8db5" - integrity sha512-4ExcbKksmU4PIT8s11/dPESgMNLQqlMlbQzRQ1CyMcmygcuQk+58zQ84nv/X17OCUd2ephZ1DKEaHHACifzCiA== + version "0.19.9" + resolved "https://registry.npmjs.org/@codemirror/stream-parser/-/stream-parser-0.19.9.tgz#34955ea91a8047cf72abebd5ce28f0d332aeca48" + integrity sha512-WTmkEFSRCetpk8xIOvV2yyXdZs3DgYckM0IP7eFi4ewlxWnJO/H4BeJZLs4wQaydWsAqTQoDyIwNH1BCzK5LUQ== dependencies: "@codemirror/highlight" "^0.19.0" "@codemirror/language" "^0.19.0" @@ -2199,6 +2192,16 @@ resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz#30aa825f11d438671d585bd44e7fd564535fc210" integrity sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw== +"@gitbeaker/browser@^35.6.0": + version "35.6.0" + resolved "https://registry.npmjs.org/@gitbeaker/browser/-/browser-35.6.0.tgz#a639ae57eeb4d4829b73a634f757ef5841ad4116" + integrity sha512-cMkMG3r1HfVV7AUxVfp+PzDpISJYDQK5TnFfVT9vwVfW2uUDYALxMWYFwBLPkn3otqrOY1TkHXl/I9WgwsZMfg== + dependencies: + "@gitbeaker/core" "^35.6.0" + "@gitbeaker/requester-utils" "^35.6.0" + delay "^5.0.0" + ky "0.28.7" + "@gitbeaker/core@^35.5.0", "@gitbeaker/core@^35.6.0": version "35.6.0" resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-35.6.0.tgz#c46e15986081ef1e647434c450964eed5fb0e390" @@ -2231,21 +2234,6 @@ qs "^6.10.1" xcase "^2.0.1" -"@google-cloud/common@^3.8.1": - version "3.10.0" - resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.10.0.tgz#454d1155bb512109cd83c6183aabbd39f9aabda7" - integrity sha512-XMbJYMh/ZSaZnbnrrOFfR/oQrb0SxG4qh6hDisWCoEbFcBHV0qHQo4uXfeMCzolx2Mfkh6VDaOGg+hyJsmxrlw== - dependencies: - "@google-cloud/projectify" "^2.0.0" - "@google-cloud/promisify" "^2.0.0" - arrify "^2.0.1" - duplexify "^4.1.1" - ent "^2.2.0" - extend "^3.0.2" - google-auth-library "^7.14.0" - retry-request "^4.2.2" - teeny-request "^7.0.0" - "@google-cloud/container@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@google-cloud/container/-/container-3.0.0.tgz#b28d086152ac19f22f2574b7e0a39774379fb885" @@ -2282,12 +2270,12 @@ integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw== "@google-cloud/storage@^5.6.0", "@google-cloud/storage@^5.8.0": - version "5.18.3" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.18.3.tgz#becacb909b2abf4bb54500a0efd65ceb51ef8eab" - integrity sha512-573qJ0ECoy3nkY5YaMWcVf4/46n/zdvfNgAyjaLQywl/eL38uxDhs7YVJd3pcgslaMUwKKsd/eD3St+Pq2iPew== + version "5.19.1" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.19.1.tgz#7252c4eb65e6014bf525a76f3af0774caa3a946d" + integrity sha512-bRTf/AD00+lPTamJdpihXC3AFtAnJFWNh/zQAor972VpuATF7u4V1anwWp0V6rKuKE3BwNM+xWxuuW/nAwEgTA== dependencies: - "@google-cloud/common" "^3.8.1" "@google-cloud/paginator" "^3.0.7" + "@google-cloud/projectify" "^2.0.0" "@google-cloud/promisify" "^2.0.0" abort-controller "^3.0.0" arrify "^2.0.0" @@ -2296,23 +2284,26 @@ configstore "^5.0.0" date-and-time "^2.0.0" duplexify "^4.0.0" + ent "^2.2.0" extend "^3.0.2" gaxios "^4.0.0" get-stream "^6.0.0" - google-auth-library "^7.0.0" + google-auth-library "^7.14.1" hash-stream-validation "^0.2.2" mime "^3.0.0" mime-types "^2.0.8" p-limit "^3.0.1" pumpify "^2.0.0" + retry-request "^4.2.2" snakeize "^0.1.0" stream-events "^1.0.4" + teeny-request "^7.1.3" xdg-basedir "^4.0.0" -"@graphiql/toolkit@^0.4.2": - version "0.4.2" - resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.2.tgz#34de819add64672f3f7d4830dffb2094fb8d5366" - integrity sha512-14uG67QrONbRrhXwvBJFsMfcQfexmGhj7dgkputesx9xuPUkcCDNmVULnVA8sGYt8P/rSvjkfQYx3rtfW+GhAQ== +"@graphiql/toolkit@^0.4.3": + version "0.4.3" + resolved "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.4.3.tgz#2653d045693d902f7faaf87b9047e8786397ec48" + integrity sha512-L0l6BezvTXmWZhtdmZxirhYwKzcZToAciQY0A13KRAWSpJ9bb/ZdkBpcz3fOXrsjuJHf0wBr6Vk9kztJ6lV7uA== dependencies: "@n1ru4l/push-pull-async-iterable-iterator" "^3.1.0" meros "^1.1.4" @@ -3495,6 +3486,11 @@ underscore "^1.9.1" ws "^7.3.1" +"@leichtgewicht/ip-codec@^2.0.1": + version "2.0.3" + resolved "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0" + integrity sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg== + "@lerna/add@4.0.0": version "4.0.0" resolved "https://registry.npmjs.org/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f" @@ -4251,9 +4247,9 @@ react-transition-group "^4.4.0" "@material-ui/icons@^4.11.2", "@material-ui/icons@^4.9.1": - version "4.11.2" - resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" - integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== + version "4.11.3" + resolved "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz#b0693709f9b161ce9ccde276a770d968484ecff1" + integrity sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA== dependencies: "@babel/runtime" "^7.4.4" @@ -4349,48 +4345,39 @@ dependencies: "@types/gapi.client" "*" -"@microsoft/api-documenter@^7.17.0": - version "7.17.0" - resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.17.0.tgz#7d2b7775b37775fc7491b94cb5fa13ee8271fcc3" - integrity sha512-rIZcfDkBsWsflvN1n0tVgIUdivV7slBIsN0WyK8gUG1MjK7dv+rbAB5QPLhg3dPvbXLgPLanJCFwoO2yFdxs+A== +"@microsoft/api-documenter@^7.17.5": + version "7.17.5" + resolved "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.17.5.tgz#cdaec54ad04d66b6f4c6f797f4120ff8c844a82d" + integrity sha512-zyJS3qORQP33S8EU0YPZb/JLIbMoslzj1bg8VmgfYCnxhX+3/FmlrTqDCwA9h2TR4bu1r7aQ26AfjUbBUz9+EA== dependencies: - "@microsoft/api-extractor-model" "7.16.0" + "@microsoft/api-extractor-model" "7.16.1" "@microsoft/tsdoc" "0.13.2" - "@rushstack/node-core-library" "3.45.1" - "@rushstack/ts-command-line" "4.10.7" + "@rushstack/node-core-library" "3.45.2" + "@rushstack/ts-command-line" "4.10.8" colors "~1.2.1" js-yaml "~3.13.1" resolve "~1.17.0" -"@microsoft/api-extractor-model@7.15.3": - version "7.15.3" - resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.15.3.tgz#cf76deeeb2733d974da678f530c2dbaceb18a065" - integrity sha512-NkSjolmSI7NGvbdz0Y7kjQfdpD+j9E5CwXTxEyjDqxd10MI7GXV8DnAsQ57GFJcgHKgTjf2aUnYfMJ9w3aMicw== +"@microsoft/api-extractor-model@7.16.1", "@microsoft/api-extractor-model@^7.16.1": + version "7.16.1" + resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.1.tgz#f6e53614e90c8a6be47bad5b91d49cc6d8c5c2bf" + integrity sha512-+1mlvy/ji+mUuH7WdVZ6fTo/aCKfS6m37aAFVOFWLfkMvmR+I9SjPfiv9qOg83If7GOrk2HPiHHibv6kA80VTg== dependencies: "@microsoft/tsdoc" "0.13.2" "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.0" + "@rushstack/node-core-library" "3.45.2" -"@microsoft/api-extractor-model@7.16.0", "@microsoft/api-extractor-model@^7.16.0": - version "7.16.0" - resolved "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.16.0.tgz#3db7360897115f26a857f1f684fb5af82b0ef9f6" - integrity sha512-0FOrbNIny8mzBrzQnSIkEjAXk0JMSnPmWYxt3ZDTPVg9S8xIPzB6lfgTg9+Mimu0RKCpGKBpd+v2WcR5vGzyUQ== +"@microsoft/api-extractor@^7.21.2": + version "7.21.2" + resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.21.2.tgz#0f524e7e1cf7b000924772fb8f6b5f60cf42fba8" + integrity sha512-m0+YPaXVou01O/V9swugZG7Gn4mw6HSWY+uisf0j2JPRZcoEDyoYe4hg0ERKXOEf0hByOnMLT28nQ82v8ig9Yw== dependencies: + "@microsoft/api-extractor-model" "7.16.1" "@microsoft/tsdoc" "0.13.2" "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.1" - -"@microsoft/api-extractor@^7.19.4": - version "7.19.4" - resolved "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.19.4.tgz#95d43d410a1dfb28a02062c4693bcb9c52afe9eb" - integrity sha512-iehC6YA3DGJvxTUaK7HUtQmP6hAQU07+Q/OR8TG4dVR6KpqCi9UPEVk8AgCvQkiK+6FbVEFQTx0qLuYk4EeuHg== - dependencies: - "@microsoft/api-extractor-model" "7.15.3" - "@microsoft/tsdoc" "0.13.2" - "@microsoft/tsdoc-config" "~0.15.2" - "@rushstack/node-core-library" "3.45.0" - "@rushstack/rig-package" "0.3.7" - "@rushstack/ts-command-line" "4.10.6" + "@rushstack/node-core-library" "3.45.2" + "@rushstack/rig-package" "0.3.9" + "@rushstack/ts-command-line" "4.10.8" colors "~1.2.1" lodash "~4.17.15" resolve "~1.17.0" @@ -4404,9 +4391,9 @@ integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA== "@microsoft/microsoft-graph-types@^2.6.0": - version "2.16.0" - resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-2.16.0.tgz#5329890d230c4fdd9f57f39e26dfade0882c94f3" - integrity sha512-Qvxv9mpXb/F4xlESEkSLjREHj3dAixTkH3LVO6Ct6sllc5RWrQxPxaSGqW9IpcLU6jI49f2XNSGLotVef3Irdg== + version "2.18.0" + resolved "https://registry.npmjs.org/@microsoft/microsoft-graph-types/-/microsoft-graph-types-2.18.0.tgz#b2ebdd55c149dfc11cca41cbf1f2082a56242450" + integrity sha512-cWiK0oaz+RrcL6EKfBeoai28L8jWJ1n+nS5cGUgRR4+POk5W5/8DV3Vs3gUNaFwmAqfYrhskyb/SNyytgAC78Q== "@microsoft/tsdoc-config@~0.15.2": version "0.15.2" @@ -4418,11 +4405,16 @@ jju "~1.4.0" resolve "~1.19.0" -"@microsoft/tsdoc@0.13.2", "@microsoft/tsdoc@^0.13.2": +"@microsoft/tsdoc@0.13.2": version "0.13.2" resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.13.2.tgz#3b0efb6d3903bd49edb073696f60e90df08efb26" integrity sha512-WrHvO8PDL8wd8T2+zBGKrMwVL5IyzR3ryWUsl0PXgEV0QHup4mTLi0QcATefGI6Gx9Anu7vthPyyyLpY0EpiQg== +"@microsoft/tsdoc@^0.14.1": + version "0.14.1" + resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd" + integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw== + "@mswjs/cookies@^0.1.6", "@mswjs/cookies@^0.1.7": version "0.1.7" resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.7.tgz#d334081b2c51057a61c1dd7b76ca3cac02251651" @@ -5242,10 +5234,10 @@ estree-walker "^2.0.1" picomatch "^2.2.2" -"@rushstack/node-core-library@3.45.0": - version "3.45.0" - resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.0.tgz#8c86b39271b6d84260b1e70db87e1e265b54f620" - integrity sha512-YMuIJl19vQT1+g/OU9mLY6T5ZBT9uDlmeXExDQACpGuxTJW+LHNbk/lRX+eCApQI2eLBlaL4U68r3kZlqwbdmw== +"@rushstack/node-core-library@3.45.2": + version "3.45.2" + resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.2.tgz#68fc89c5bea4007359fa4ff203bf3eca27037f40" + integrity sha512-MJKdB6mxOoIkks3htGVCo7aiTzllm2I6Xua+KbTSb0cp7rBp8gTCOF/4d8R4HFMwpRdEdwzKgqMM6k9rAK73iw== dependencies: "@types/node" "12.20.24" colors "~1.2.1" @@ -5257,43 +5249,18 @@ timsort "~0.3.0" z-schema "~5.0.2" -"@rushstack/node-core-library@3.45.1": - version "3.45.1" - resolved "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.45.1.tgz#787361b61a48d616eb4b059641721a3dc138f001" - integrity sha512-BwdssTNe007DNjDBxJgInHg8ePytIPyT0La7ZZSQZF9+rSkT42AygXPGvbGsyFfEntjr4X37zZSJI7yGzL16cQ== - dependencies: - "@types/node" "12.20.24" - colors "~1.2.1" - fs-extra "~7.0.1" - import-lazy "~4.0.0" - jju "~1.4.0" - resolve "~1.17.0" - semver "~7.3.0" - timsort "~0.3.0" - z-schema "~5.0.2" - -"@rushstack/rig-package@0.3.7": - version "0.3.7" - resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.7.tgz#3fa564b1d129d28689dd4309502792b15e84bf81" - integrity sha512-pzMsTSeTC8IiZ6EJLr53gGMvhT4oLWH+hxD7907cHyWuIUlEXFtu/2pK25vUQT13nKp5DJCWxXyYoGRk/h6rtA== +"@rushstack/rig-package@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.3.9.tgz#5e10ada5a8348f886b6ebe3eed436492d6ccf70c" + integrity sha512-z3Oxpfb4n9mGXwseX+ifpkmUf9B8Fy8oieVwg8eFgpCbzllkgOwEiwLKEnRWVQ8owFcd46NCKz+7ICH35CRsAw== dependencies: resolve "~1.17.0" strip-json-comments "~3.1.1" -"@rushstack/ts-command-line@4.10.6": - version "4.10.6" - resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.6.tgz#5669e481e4339ceb4e1428183eb0937d3bc3841b" - integrity sha512-Y3GkUag39sTIlukDg9mUp8MCHrrlJ27POrBNRQGc/uF+VVgX8M7zMzHch5zP6O1QVquWgD7Engdpn2piPYaS/g== - dependencies: - "@types/argparse" "1.0.38" - argparse "~1.0.9" - colors "~1.2.1" - string-argv "~0.3.1" - -"@rushstack/ts-command-line@4.10.7": - version "4.10.7" - resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.7.tgz#21e3757a756cbd4f7eeab8f89ec028a64d980efc" - integrity sha512-CjS+DfNXUSO5Ab2wD1GBGtUTnB02OglRWGqfaTcac9Jn45V5MeUOsq/wA8wEeS5Y/3TZ2P1k+IWdVDiuOFP9Og== +"@rushstack/ts-command-line@4.10.8": + version "4.10.8" + resolved "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.10.8.tgz#f4bec690e7f4838e6f91028d8a557e67c3a75f68" + integrity sha512-G7CQYY/m3aZU5fVxbebv35yDeua7sSumrDAB2pJp0d60ZEsxGkUQW8771CeMcGWwSKqT9PxPzKpmIakiWv54sA== dependencies: "@types/argparse" "1.0.38" argparse "~1.0.9" @@ -5559,9 +5526,9 @@ pretty-format "^27.0.2" "@testing-library/jest-dom@^5.10.1": - version "5.16.3" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.3.tgz#b76851a909586113c20486f1679ffb4d8ec27bfa" - integrity sha512-u5DfKj4wfSt6akfndfu1eG06jsdyA/IUrlX2n3pyq5UXgXMhXY+NJb8eNK/7pqPWAhCKsCGWDdDO0zKMKAYkEA== + version "5.16.4" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd" + integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" @@ -5995,9 +5962,9 @@ "@types/ssh2" "*" "@types/dockerode@^3.2.7", "@types/dockerode@^3.3.0": - version "3.3.7" - resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.7.tgz#e5a1083c026bc8fef4b3f7dbb05ee98f72c79732" - integrity sha512-vxRyHVyYJOkLdquTZLNtKDPnPM4+6P/kXZRRb0axbx6WcBVZycmzeXvnllspKXvkjR5mG02IX3IFmkO9RfR0fw== + version "3.3.8" + resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz#afb844b8e8cf54160e1686832903a1444912ff79" + integrity sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw== dependencies: "@types/docker-modem" "*" "@types/node" "*" @@ -6237,10 +6204,10 @@ dependencies: "@types/node" "*" -"@types/jest-when@^2.7.2": - version "2.7.2" - resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.2.tgz#619fbc5f623bcd0b29efde0e4993c7f0d50d026d" - integrity sha512-vOtj0cev6vO1VX7Jbfg/qvy+sfLI64STsHbKVkggK+1kd11rcMGzFpZKBxUvQfsm4JRULCBISu+qrfs7fYZFGg== +"@types/jest-when@^3.5.0": + version "3.5.0" + resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-3.5.0.tgz#6a573cd521da131e6801f0991b4f1d4dee2ebab5" + integrity sha512-rNUuZ3Mn/HDzpImPXDeOtW18zqyerPoOS2aKU0zUFbirWgJ7sN7LnRv73RmbBQ/uzw28sxf/nUofxbhJ5DWB0w== dependencies: "@types/jest" "*" @@ -6282,6 +6249,11 @@ ast-types "^0.14.1" recast "^0.20.3" +"@types/json-buffer@~3.0.0": + version "3.0.0" + resolved "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64" + integrity sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ== + "@types/json-schema-merge-allof@^0.6.0": version "0.6.1" resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.1.tgz#c476c2aeed04d8a14882ff872de9ddeca5608e0b" @@ -6333,9 +6305,9 @@ "@types/node" "*" "@types/lodash@^4.14.151", "@types/lodash@^4.14.173", "@types/lodash@^4.14.175": - version "4.14.178" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" - integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + version "4.14.181" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz#d1d3740c379fda17ab175165ba04e2d03389385d" + integrity sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag== "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.1" @@ -6353,9 +6325,9 @@ integrity sha512-j4x4XJwZvorEUbA519VdQ5b9AOU9TSvfi8tvxMAfP8XzNLtFex7A8vFQwqOx3WACbV0KMXbACV3cZl4/gynQ7g== "@types/luxon@^2.0.4", "@types/luxon@^2.0.5", "@types/luxon@^2.0.9": - version "2.3.0" - resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.0.tgz#0f4d912c385e47890374cb694da9bc93bacbe2b0" - integrity sha512-mWXdRlg+5dWvxU+uaijB2RY5NrJtMEXR6j+D6W66hPuezSVXrQqQvWa/JNHntgEYgjzeoVRrQVmMWAbKjUJiFQ== + version "2.3.1" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz#e34763178b46232e4c5f079f1706e18692415519" + integrity sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ== "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.3" @@ -6423,10 +6395,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^16.9.2": - version "16.11.6" - resolved "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae" - integrity sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w== +"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^16.11.26", "@types/node@^16.9.2": + version "16.11.26" + resolved "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz#63d204d136c9916fb4dcd1b50f9740fe86884e47" + integrity sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ== "@types/node@12.20.24", "@types/node@^12.7.1": version "12.20.24" @@ -6448,11 +6420,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-15.14.9.tgz#bc43c990c3c9be7281868bbc7b8fdd6e2b57adfa" integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== -"@types/node@^16.11.26": - version "16.11.26" - resolved "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz#63d204d136c9916fb4dcd1b50f9740fe86884e47" - integrity sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ== - "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -6553,9 +6520,9 @@ integrity sha512-QzSuZMBuG5u8HqYz01qtMdg/Jfctlnvj1z/lYnIDXs/golxw0fxtRAHd9KrzjR7Yxz1qVeI00o0kiO3PmVdJ9w== "@types/prop-types@*", "@types/prop-types@^15.7.3": - version "15.7.4" - resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" - integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + version "15.7.5" + resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" + integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== "@types/puppeteer@^5.4.4": version "5.4.5" @@ -6574,12 +6541,12 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== -"@types/react-dom@*", "@types/react-dom@>=16.9.0": - version "17.0.11" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" - integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== +"@types/react-dom@*", "@types/react-dom@>=16.9.0", "@types/react-dom@^17": + version "17.0.15" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.15.tgz#f2c8efde11521a4b7991e076cb9c70ba3bb0d156" + integrity sha512-Tr9VU9DvNoHDWlmecmcsE5ZZiUkYx+nKBzum4Oxe1K0yJVyBlfbq7H3eXjxXqJczBKqPGq3EgfTru4MgKb9+Yw== dependencies: - "@types/react" "*" + "@types/react" "^17" "@types/react-helmet@^6.1.0": version "6.1.5" @@ -6647,10 +6614,10 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0": - version "17.0.43" - resolved "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55" - integrity sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A== +"@types/react@*", "@types/react@>=16.9.0", "@types/react@^16.13.1 || ^17.0.0", "@types/react@^17": + version "17.0.44" + resolved "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz#c3714bd34dd551ab20b8015d9d0dbec812a51ec7" + integrity sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -6843,9 +6810,9 @@ "@types/node" "*" "@types/supertest@^2.0.8": - version "2.0.11" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.11.tgz#2e70f69f220bc77b4f660d72c2e1a4231f44a77d" - integrity sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q== + version "2.0.12" + resolved "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz#ddb4a0568597c9aadff8dbec5b2e8fddbe8692fc" + integrity sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ== dependencies: "@types/superagent" "*" @@ -6989,10 +6956,10 @@ dependencies: "@types/node" "*" -"@types/ws@^8.0.0", "@types/ws@^8.2.2": - version "8.2.2" - resolved "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz#7c5be4decb19500ae6b3d563043cd407bf366c21" - integrity sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg== +"@types/ws@^8.0.0", "@types/ws@^8.5.1": + version "8.5.3" + resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== dependencies: "@types/node" "*" @@ -7553,9 +7520,9 @@ ansi-regex@^2.0.0: integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + version "3.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== ansi-regex@^4.1.0: version "4.1.0" @@ -7860,7 +7827,7 @@ array-flatten@1.1.1: resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= -array-flatten@^2.1.0: +array-flatten@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== @@ -8517,17 +8484,15 @@ body-parser@1.19.2, body-parser@^1.19.0: raw-body "2.4.3" type-is "~1.6.18" -bonjour@^3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" - integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= +bonjour-service@^1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.11.tgz#5418e5c1ac91c89a406f853a942e7892829c0d89" + integrity sha512-drMprzr2rDTCtgEE3VgdA9uUFaUHF+jXduwYSThHJnKMYM+FhI9Z3ph+TX3xy0LtgYHae6CHYPJ/2UnK8nQHcA== dependencies: - array-flatten "^2.1.0" - deep-equal "^1.0.1" + array-flatten "^2.1.2" dns-equal "^1.0.0" - dns-txt "^2.0.2" - multicast-dns "^6.0.1" - multicast-dns-service-types "^1.1.0" + fast-deep-equal "^3.1.3" + multicast-dns "^7.2.4" boolbase@^1.0.0: version "1.0.0" @@ -8734,11 +8699,6 @@ buffer-indexof-polyfill@~1.0.0: resolved "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== -buffer-indexof@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" - integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== - buffer-writer@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" @@ -9641,6 +9601,14 @@ component-inherit@0.0.3: resolved "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM= +compress-brotli@^1.3.6: + version "1.3.6" + resolved "https://registry.npmjs.org/compress-brotli/-/compress-brotli-1.3.6.tgz#64bd6f21f4f3e9841dbac392f4c29218caf5e9d9" + integrity sha512-au99/GqZtUtiCBliqLFbWlhnCxn+XSYjwZ77q6mKN4La4qOXDoLVPZ50iXr0WmAyMxl8yqoq3Yq4OeQNPPkyeQ== + dependencies: + "@types/json-buffer" "~3.0.0" + json-buffer "~3.0.1" + compress-commons@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.0.tgz#25ec7a4528852ccd1d441a7d4353cd0ece11371b" @@ -10069,10 +10037,10 @@ cron@^1.8.2: dependencies: moment-timezone "^0.5.x" -cronstrue@^1.122.0: - version "1.125.0" - resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.125.0.tgz#8030816d033d00caade9b2a9f9b71e69175bcf42" - integrity sha512-qkC5mVbVGuuyBVXmam5anaRtbLcgfBUKajoyZqCdf/XBdgF43PsLSEm8eEi2dsI3YbqDPbLSH2mWNzM1dVqHgQ== +cronstrue@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-2.2.0.tgz#8e02b8ef0fa70a9eab9999f1f838df4bd378b471" + integrity sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw== cross-env@^7.0.0: version "7.0.3" @@ -10755,7 +10723,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -10863,18 +10831,6 @@ dedent@^0.7.0: resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-equal@^1.0.1: - version "1.1.1" - resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" - integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== - dependencies: - is-arguments "^1.0.4" - is-date-object "^1.0.1" - is-regex "^1.0.4" - object-is "^1.0.1" - object-keys "^1.1.1" - regexp.prototype.flags "^1.2.0" - deep-extend@0.6.0, deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -11068,10 +11024,10 @@ detect-port-alt@^1.1.6: address "^1.0.1" debug "^2.6.0" -devtools-protocol@0.0.960912: - version "0.0.960912" - resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.960912.tgz#411c1fa355eddb72f06c4a8743f2808766db6245" - integrity sha512-I3hWmV9rWHbdnUdmMKHF2NuYutIM2kXz2mdXW8ha7TbRlGTVs+PF+PsB5QWvpCek4Fy9B+msiispCfwlhG5Sqg== +devtools-protocol@0.0.969999: + version "0.0.969999" + resolved "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.969999.tgz#3d6be0a126b3607bb399ae2719b471dda71f3478" + integrity sha512-6GfzuDWU0OFAuOvBokXpXPLxjOJ5DZ157Ue3sGQQM3LgAamb8m0R0ruSfN0DDu+XG5XJgT50i6zZ/0o8RglreQ== dezalgo@1.0.3, dezalgo@^1.0.0: version "1.0.3" @@ -11149,20 +11105,12 @@ dns-equal@^1.0.0: resolved "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= -dns-packet@^1.3.1: - version "1.3.4" - resolved "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz#e3455065824a2507ba886c55a89963bb107dec6f" - integrity sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA== +dns-packet@^5.2.2: + version "5.3.1" + resolved "https://registry.npmjs.org/dns-packet/-/dns-packet-5.3.1.tgz#eb94413789daec0f0ebe2fcc230bdc9d7c91b43d" + integrity sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw== dependencies: - ip "^1.1.0" - safe-buffer "^5.0.1" - -dns-txt@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" - integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= - dependencies: - buffer-indexof "^1.0.0" + "@leichtgewicht/ip-codec" "^2.0.1" docker-compose@^0.23.17: version "0.23.17" @@ -11610,7 +11558,7 @@ es-abstract@^1.17.0-next.1, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.1" -es-module-lexer@^0.9.0: +es-module-lexer@^0.9.0, es-module-lexer@^0.9.3: version "0.9.3" resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== @@ -12173,6 +12121,7 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/app-defaults" "^1.0.1-next.1" "@backstage/catalog-model" "^1.0.1-next.0" "@backstage/cli" "^0.17.0-next.1" + "@backstage/config" "^1.0.0" "@backstage/core-app-api" "^1.0.1-next.0" "@backstage/core-components" "^0.9.3-next.0" "@backstage/core-plugin-api" "^1.0.0" @@ -12211,8 +12160,10 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: "@backstage/plugin-scaffolder" "^1.0.1-next.1" "@backstage/plugin-search" "^0.7.5-next.0" "@backstage/plugin-search-common" "^0.3.3-next.1" + "@backstage/plugin-search-react" "^0.0.0" "@backstage/plugin-sentry" "^0.3.42-next.0" "@backstage/plugin-shortcuts" "^0.2.5-next.0" + "@backstage/plugin-stack-overflow" "^0.1.0-next.0" "@backstage/plugin-tech-insights" "^0.1.14-next.0" "@backstage/plugin-tech-radar" "^0.5.11-next.1" "@backstage/plugin-techdocs" "^1.0.1-next.1" @@ -12383,7 +12334,7 @@ express-xml-bodyparser@^0.3.0: dependencies: xml2js "^0.4.11" -express@^4.17.1: +express@^4.17.1, express@^4.17.3: version "4.17.3" resolved "https://registry.npmjs.org/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== @@ -13454,10 +13405,10 @@ globby@^7.1.1: pify "^3.0.0" slash "^1.0.0" -google-auth-library@^7.0.0, google-auth-library@^7.14.0, google-auth-library@^7.6.1: - version "7.14.0" - resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.0.tgz#9d6a20592f7b4d4c463cd3e93934c4b1711d5dc6" - integrity sha512-or8r7qUqGVI3W8lVSdPh0ZpeFyQHeE73g5c0p+bLNTTUFXJ+GSeDQmZRZ2p4H8cF/RJYa4PNvi/A1ar1uVNLFA== +google-auth-library@^7.14.1, google-auth-library@^7.6.1: + version "7.14.1" + resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" + integrity sha512-5Rk7iLNDFhFeBYc3s8l1CqzbEBcdhwR193RlD4vSNFajIcINKI8W8P0JLmBpwymHqqWbX34pJDQu39cSy/6RsA== dependencies: arrify "^2.0.0" base64-js "^1.3.0" @@ -13540,11 +13491,11 @@ grapheme-splitter@^1.0.4: integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== graphiql@^1.5.12: - version "1.8.3" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.8.3.tgz#4696755cbba851c93aea9deacbcf4229044a705d" - integrity sha512-3qb3jdlzg8nqQCRMnch6lG11royrE6etP2eoDKLxdrSpdaxI0PgNNttbMAJJYfsqEJ0XHcXtUDt1jH6jkKOK4Q== + version "1.8.4" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.8.4.tgz#3a447eae85d839eb207a07fc3aabc6f3a10df46d" + integrity sha512-mEYtfympvlZ4E1VMEMZ0BG3NCLA9L69EEbnpHpAA12oWz3aul4HXWl59JS0aHZftyiPx1/gHjSsjctkhbazT9g== dependencies: - "@graphiql/toolkit" "^0.4.2" + "@graphiql/toolkit" "^0.4.3" codemirror "^5.58.2" codemirror-graphql "^1.2.15" copy-to-clipboard "^3.2.0" @@ -14064,7 +14015,7 @@ http-parser-js@>=0.5.1: resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== -http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1: +http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== @@ -14082,7 +14033,7 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-proxy-middleware@^2.0.0: +http-proxy-middleware@^2.0.0, http-proxy-middleware@^2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.4.tgz#03af0f4676d172ae775cb5c33f592f40e1a4e07a" integrity sha512-m/4FxX17SUvz4lJ5WPXOHDUuCwIqXLfLHs1s0uZ3oYjhoXlx9csYxaOa0ElDEJ+h8Q4iJ1s+lTMbiCa4EXIJqg== @@ -14238,6 +14189,13 @@ ignore-walk@^4.0.1: dependencies: minimatch "^3.0.4" +ignore-walk@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-5.0.1.tgz#5f199e23e1288f518d90358d461387788a154776" + integrity sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw== + dependencies: + minimatch "^5.0.1" + ignore@^3.3.5: version "3.3.10" resolved "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -14514,7 +14472,7 @@ ioredis@^4.28.5: redis-parser "^3.0.0" standard-as-callback "^2.1.0" -ip@^1.1.0, ip@^1.1.5: +ip@^1.1.5: version "1.1.5" resolved "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= @@ -14955,7 +14913,7 @@ is-reference@^1.2.1: dependencies: "@types/estree" "*" -is-regex@^1.0.4, is-regex@^1.1.4: +is-regex@^1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -15940,7 +15898,7 @@ json-buffer@3.0.0: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= -json-buffer@3.0.1, json-buffer@^3.0.1: +json-buffer@3.0.1, json-buffer@^3.0.1, json-buffer@~3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== @@ -16309,10 +16267,11 @@ keyv@^3.0.0: json-buffer "3.0.0" keyv@^4.0.0, keyv@^4.0.3: - version "4.1.1" - resolved "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz#02c538bfdbd2a9308cc932d4096f05ae42bfa06a" - integrity sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ== + version "4.2.2" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.2.2.tgz#4b6f602c0228ef4d8214c03c520bef469ed6b768" + integrity sha512-uYS0vKTlBIjNCAUqrjlxmruxOEiZxZIHXyp32sdcGmP+ukFrmWUnE//RcPXJH3Vxrni1H2gsQbjHE0bH7MtMQQ== dependencies: + compress-brotli "^1.3.6" json-buffer "3.0.1" kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: @@ -16373,6 +16332,11 @@ kuler@^2.0.0: resolved "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== +ky@0.28.7: + version "0.28.7" + resolved "https://registry.npmjs.org/ky/-/ky-0.28.7.tgz#10c42be863fb96c1846d6e71e229263ffb72eb15" + integrity sha512-a23i6qSr/ep15vdtw/zyEQIDLoUaKDg9Jf04CYl/0ns/wXNYna26zJpI+MeIFaPeDvkrjLPrKtKOiiI3IE53RQ== + language-subtag-registry@~0.3.2: version "0.3.21" resolved "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" @@ -17952,7 +17916,7 @@ minimatch@3.0.4: dependencies: brace-expansion "^1.1.7" -minimatch@5.0.1, minimatch@^5.0.0: +minimatch@5.0.1, minimatch@^5.0.0, minimatch@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== @@ -18138,9 +18102,9 @@ moment-timezone@^0.5.x: moment ">= 2.9.0" "moment@>= 2.9.0", moment@>=2.14.0, moment@^2.27.0, moment@^2.29.1: - version "2.29.1" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + version "2.29.2" + resolved "https://registry.npmjs.org/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== morgan@^1.10.0: version "1.10.0" @@ -18230,17 +18194,12 @@ msw@^0.36.3: type-fest "^1.2.2" yargs "^17.3.0" -multicast-dns-service-types@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" - integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= - -multicast-dns@^6.0.1: - version "6.2.3" - resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" - integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== +multicast-dns@^7.2.4: + version "7.2.4" + resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz#cf0b115c31e922aeb20b64e6556cbeb34cf0dd19" + integrity sha512-XkCYOU+rr2Ft3LI6w4ye51M3VK31qJXFIxu0XLw169PtKG0Zx47OrXeVW/GCYOfpC9s1yyyf1S+L8/4LY0J9Zw== dependencies: - dns-packet "^1.3.1" + dns-packet "^5.2.2" thunky "^1.0.2" multimatch@^5.0.0: @@ -18429,10 +18388,10 @@ node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node- dependencies: whatwg-url "^5.0.0" -node-forge@^1.0.0, node-forge@^1.2.0: - version "1.3.0" - resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.0.tgz#37a874ea723855f37db091e6c186e5b67a01d4b2" - integrity sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA== +node-forge@^1, node-forge@^1.0.0: + version "1.3.1" + resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== node-gyp@^5.0.2: version "5.1.0" @@ -18617,10 +18576,10 @@ normalize-url@^6.0.1: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== -npm-bundled@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== +npm-bundled@^1.1.1, npm-bundled@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" + integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ== dependencies: npm-normalize-package-bin "^1.0.1" @@ -18679,6 +18638,16 @@ npm-packlist@^3.0.0: npm-bundled "^1.1.1" npm-normalize-package-bin "^1.0.1" +npm-packlist@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-5.0.0.tgz#74795ebbbf91bd5a2db6ecff4d6fe1f1c1a07e11" + integrity sha512-uU20UwM4Hogfab1Q7htJbhcyafM9lGHxOrDjkKvR2S3z7Ds0uRaESk0cXctczk+ABT4DZWNwjB10xlurFdEwZg== + dependencies: + glob "^7.2.0" + ignore-walk "^5.0.1" + npm-bundled "^1.1.2" + npm-normalize-package-bin "^1.0.1" + npm-pick-manifest@^6.0.0, npm-pick-manifest@^6.1.0, npm-pick-manifest@^6.1.1: version "6.1.1" resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-6.1.1.tgz#7b5484ca2c908565f43b7f27644f36bb816f5148" @@ -18829,11 +18798,6 @@ object-inspect@^1.11.0, object-inspect@^1.12.0, object-inspect@^1.9.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== -object-is@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" - integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ== - object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -19167,6 +19131,13 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" + integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== + dependencies: + yocto-queue "^1.0.0" + p-locate@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" @@ -20680,13 +20651,13 @@ pupa@^2.1.1: escape-goat "^2.0.0" puppeteer@^13.1.1: - version "13.3.2" - resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-13.3.2.tgz#4ff1cf6e2009df29fd80038bc702dc067776f79d" - integrity sha512-TIt8/R0eaUwY1c0/O0sCJpSglvGEWVoWFfGZ2dNtxX3eHuBo1ln9abaWfxTjZfsrkYATLSs8oqEdRZpMNnCsvg== + version "13.5.2" + resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-13.5.2.tgz#73ae84969cbf514aeee871a05ec4549d67f6abee" + integrity sha512-DJAyXODBikZ3xPs8C35CtExEw78LZR9RyelGDAs0tX1dERv3OfW7qpQ9VPBgsfz+hG2HiMTO/Tyf7BuMVWsrxg== dependencies: cross-fetch "3.1.5" - debug "4.3.3" - devtools-protocol "0.0.960912" + debug "4.3.4" + devtools-protocol "0.0.969999" extract-zip "2.0.1" https-proxy-agent "5.0.0" pkg-dir "4.2.0" @@ -20973,9 +20944,9 @@ react-helmet@6.1.0: react-side-effect "^2.1.0" react-hook-form@^7.12.2, react-hook-form@^7.13.0: - version "7.28.1" - resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.28.1.tgz#95fc37be6c6b9d57212eb7eca055fb36d079b3b7" - integrity sha512-mgwxvXuvt3FMY/mdnWbPc++Zf1U5xYzkhOaL05mtFMLvXc9MvUhMUlKtUVuO12sOrgT3nPXBgVFawtiJ4ONrgg== + version "7.29.0" + resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.29.0.tgz#5e7e41a483b70731720966ed8be52163ea1fecf1" + integrity sha512-NcJqWRF6el5HMW30fqZRt27s+lorvlCCDbTpAyHoodQeYWXgQCvZJJQLC1kRMKdrJknVH0NIg3At6TUzlZJFOQ== react-hot-loader@^4.13.0: version "4.13.0" @@ -21594,7 +21565,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.1: +regexp.prototype.flags@^1.3.1: version "1.4.1" resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz#b3f4c0059af9e47eca9f3f660e51d81307e72307" integrity sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ== @@ -22035,11 +22006,13 @@ rollup-plugin-dts@^4.0.1: "@babel/code-frame" "^7.16.7" rollup-plugin-esbuild@^4.7.2: - version "4.7.2" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-4.7.2.tgz#1a496a9f96257cdf5ed800e818932859232471f8" - integrity sha512-rBS2hTedtG+wL/yyIWQ84zju5rtfF15gkaCLN0vsWGmBdRd0UPm52meAwkmrsPQf3mB/H2o+k9Q8Ce8A66SE5A== + version "4.9.1" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-4.9.1.tgz#369d137e2b1542c8ee459495fd4f10de812666aa" + integrity sha512-qn/x7Wz9p3Xnva99qcb+nopH0d2VJwVnsxJTGEg+Sh2Z3tqQl33MhOwzekVo1YTKgv+yAmosjcBRJygMfGrtLw== dependencies: "@rollup/pluginutils" "^4.1.1" + debug "^4.3.3" + es-module-lexer "^0.9.3" joycon "^3.0.1" jsonc-parser "^3.0.0" @@ -22261,12 +22234,12 @@ select-hose@^2.0.0: resolved "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selfsigned@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.0.tgz#e927cd5377cbb0a1075302cff8df1042cc2bce5b" - integrity sha512-cUdFiCbKoa1mZ6osuJs2uDHrs0k0oprsKveFiiaBKCNq3SYyb5gs2HxhQyDNLCmL51ZZThqi4YNDpCK6GOP1iQ== +selfsigned@^2.0.0, selfsigned@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/selfsigned/-/selfsigned-2.0.1.tgz#8b2df7fa56bf014d19b6007655fff209c0ef0a56" + integrity sha512-LmME957M1zOsUhG+67rAjKfiWFox3SBxE/yymatMZsAx+oMrJ0YQ8AToOnyCm7xbeg2ep37IHLxdu0o2MavQOQ== dependencies: - node-forge "^1.2.0" + node-forge "^1" semver-diff@^3.1.1: version "3.1.1" @@ -23303,7 +23276,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.0, strip-ansi@^7.0.1: +strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== @@ -23746,12 +23719,12 @@ tdigest@^0.1.1: react-router-dom "6.0.0-beta.0" react-use "^17.2.4" -teeny-request@^7.0.0: - version "7.0.1" - resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz#bdd41fdffea5f8fbc0d29392cb47bec4f66b2b4c" - integrity sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw== +teeny-request@^7.1.3: + version "7.2.0" + resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.2.0.tgz#41347ece068f08d741e7b86df38a4498208b2633" + integrity sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw== dependencies: - http-proxy-agent "^4.0.0" + http-proxy-agent "^5.0.0" https-proxy-agent "^5.0.0" node-fetch "^2.6.1" stream-events "^1.0.5" @@ -25151,38 +25124,37 @@ webpack-dev-middleware@^5.3.1: schema-utils "^4.0.0" webpack-dev-server@^4.7.3: - version "4.7.4" - resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.7.4.tgz#d0ef7da78224578384e795ac228d8efb63d5f945" - integrity sha512-nfdsb02Zi2qzkNmgtZjkrMOcXnYZ6FLKcQwpxT7MvmHKc+oTtDsBju8j+NMyAygZ9GW1jMEUpy3itHtqgEhe1A== + version "4.8.1" + resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.8.1.tgz#58f9d797710d6e25fa17d6afab8708f958c11a29" + integrity sha512-dwld70gkgNJa33czmcj/PlKY/nOy/BimbrgZRaR9vDATBQAYgLzggR0nxDtPLJiLrMgZwbE6RRfJ5vnBBasTyg== dependencies: "@types/bonjour" "^3.5.9" "@types/connect-history-api-fallback" "^1.3.5" "@types/express" "^4.17.13" "@types/serve-index" "^1.9.1" "@types/sockjs" "^0.3.33" - "@types/ws" "^8.2.2" + "@types/ws" "^8.5.1" ansi-html-community "^0.0.8" - bonjour "^3.5.0" + bonjour-service "^1.0.11" chokidar "^3.5.3" colorette "^2.0.10" compression "^1.7.4" connect-history-api-fallback "^1.6.0" default-gateway "^6.0.3" - del "^6.0.0" - express "^4.17.1" + express "^4.17.3" graceful-fs "^4.2.6" html-entities "^2.3.2" - http-proxy-middleware "^2.0.0" + http-proxy-middleware "^2.0.3" ipaddr.js "^2.0.1" open "^8.0.9" p-retry "^4.5.0" portfinder "^1.0.28" + rimraf "^3.0.2" schema-utils "^4.0.0" - selfsigned "^2.0.0" + selfsigned "^2.0.1" serve-index "^1.9.1" sockjs "^0.3.21" spdy "^4.0.2" - strip-ansi "^7.0.0" webpack-dev-middleware "^5.3.1" ws "^8.4.2" @@ -25851,6 +25823,11 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yocto-queue@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" + integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + yup@^0.32.9: version "0.32.11" resolved "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" @@ -25895,9 +25872,9 @@ zip-stream@^4.1.0: readable-stream "^3.6.0" zod@^3.11.6, zod@^3.9.5: - version "3.14.2" - resolved "https://registry.npmjs.org/zod/-/zod-3.14.2.tgz#0b4ed79085c471adce0e7f2c0a4fbb5ddc516ba2" - integrity sha512-iF+wrtzz7fQfkmn60PG6XFxaWBhYYKzp2i+nv24WbLUWb2JjymdkHlzBwP0erpc78WotwP5g9AAu7Sk8GWVVNw== + version "3.14.4" + resolved "https://registry.npmjs.org/zod/-/zod-3.14.4.tgz#e678fe9e5469f4663165a5c35c8f3dc66334a5d6" + integrity sha512-U9BFLb2GO34Sfo9IUYp0w3wJLlmcyGoMd75qU9yf+DrdGA4kEx6e+l9KOkAlyUO0PSQzZCa3TR4qVlcmwqSDuw== zustand@3.6.9: version "3.6.9"