diff --git a/.changeset/2800.md b/.changeset/2800.md new file mode 100644 index 0000000000..0e051b0557 --- /dev/null +++ b/.changeset/2800.md @@ -0,0 +1,15 @@ +--- +'@backstage/core': minor +'@backstage/core-api': minor +--- + +Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the +default scope is configurable when overwriting the Core Api in the app. + +``` +GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user', 'repo'], +}), +``` diff --git a/.changeset/2874.md b/.changeset/2874.md new file mode 100644 index 0000000000..693962314f --- /dev/null +++ b/.changeset/2874.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +The way that wiring together a catalog happens, has changed drastically. Now +there is a new class `CatalogBuilder` that does almost all of the heavy lifting +of how to augment/replace pieces of catalog functionality, such as adding +support for custom entities or adding additional processors. + +As the builder was added, a lot of the static methods and builders for default +setups have been removed from classes deep in the hierarchy. Instead, the +builder contains the knowledge of what the defaults are. diff --git a/.changeset/beige-apes-serve.md b/.changeset/beige-apes-serve.md new file mode 100644 index 0000000000..54ee39a85e --- /dev/null +++ b/.changeset/beige-apes-serve.md @@ -0,0 +1,5 @@ +--- +'example-app': patch +--- + +Add Pull Request tab to components view. diff --git a/.changeset/cli-parallel-build-options.md b/.changeset/cli-parallel-build-options.md new file mode 100644 index 0000000000..95f9360738 --- /dev/null +++ b/.changeset/cli-parallel-build-options.md @@ -0,0 +1,12 @@ +--- +'@backstage/cli': minor +--- + +Adds a new `BACKSTAGE_CLI_BUILD_PARELLEL` environment variable to control +parallelism for some build steps. + +This is useful in CI to help avoid out of memory issues when using `terser`. The +`BACKSTAGE_CLI_BUILD_PARELLEL` environment variable can be set to +`true | false | [integer]` to override the default behaviour. See +[terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin#parallel) +for more details. diff --git a/.changeset/eighty-lamps-smell.md b/.changeset/eighty-lamps-smell.md new file mode 100644 index 0000000000..85dfba4f82 --- /dev/null +++ b/.changeset/eighty-lamps-smell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +handle the case where no entities are available to show diff --git a/.changeset/flat-bugs-do.md b/.changeset/flat-bugs-do.md new file mode 100644 index 0000000000..672daf657d --- /dev/null +++ b/.changeset/flat-bugs-do.md @@ -0,0 +1,8 @@ +--- +'@backstage/core': patch +'@backstage/plugin-graphiql': patch +'@backstage/plugin-tech-radar': patch +'@backstage/plugin-techdocs': patch +--- + +add test case for Progress component diff --git a/.changeset/happy-ads-behave.md b/.changeset/happy-ads-behave.md new file mode 100644 index 0000000000..f8b889f85c --- /dev/null +++ b/.changeset/happy-ads-behave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Allow templates to be located on non-default branch diff --git a/.changeset/heavy-berries-hear.md b/.changeset/heavy-berries-hear.md new file mode 100644 index 0000000000..66aa6d93e1 --- /dev/null +++ b/.changeset/heavy-berries-hear.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Update ItemCard headers to pass color contrast standards. diff --git a/.changeset/lovely-suits-flash.md b/.changeset/lovely-suits-flash.md new file mode 100644 index 0000000000..f093290c85 --- /dev/null +++ b/.changeset/lovely-suits-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +update the test cases of CodeSnippet component diff --git a/.changeset/old-falcons-jump.md b/.changeset/old-falcons-jump.md new file mode 100644 index 0000000000..107d75ff88 --- /dev/null +++ b/.changeset/old-falcons-jump.md @@ -0,0 +1,25 @@ +--- +'@backstage/plugin-newrelic': minor +--- + +The New Relic plugin now uses the Backstage proxy to communicate with New Relic's API. + +Please update your `app-config.yaml` as follows: + +```yaml +# Old Config +newrelic: + api: + baseUrl: 'https://api.newrelic.com/v2' + key: NEW_RELIC_REST_API_KEY +``` + +```yaml +# New Config +proxy: + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY +``` diff --git a/.changeset/olive-bikes-jog.md b/.changeset/olive-bikes-jog.md new file mode 100644 index 0000000000..fdbf1e0d9f --- /dev/null +++ b/.changeset/olive-bikes-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +add test cases for Status components diff --git a/.changeset/raghu-is-great.md b/.changeset/raghu-is-great.md new file mode 100644 index 0000000000..805e49f3e8 --- /dev/null +++ b/.changeset/raghu-is-great.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Renamed the `LocationProcessor` class to `CatalogProcessor`. + +Likewise, renamed `LocationProcessorResult`, `LocationProcessorLocationResult`, +`LocationProcessorDataResult`, `LocationProcessorEntityResult`, +`LocationProcessorErrorResult`, and `LocationProcessorEmit` to their `Catalog*` +counterparts. diff --git a/.changeset/red-games-float.md b/.changeset/red-games-float.md new file mode 100644 index 0000000000..6eabba26a4 --- /dev/null +++ b/.changeset/red-games-float.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +- getProjectDailyCost and getGroupDailyCost no longer accept a metric as a parameter +- getDailyMetricData added to API for fetching daily metric data for given interval +- dailyCost removed as configurable metric +- default field added to metric configuration for displaying comparison metric data in top panel +- Metric.kind can no longer be null +- MetricData type added diff --git a/.changeset/selfish-bats-perform.md b/.changeset/selfish-bats-perform.md new file mode 100644 index 0000000000..e4d05c088c --- /dev/null +++ b/.changeset/selfish-bats-perform.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances diff --git a/.changeset/slow-experts-peel.md b/.changeset/slow-experts-peel.md new file mode 100644 index 0000000000..ed9c7af21e --- /dev/null +++ b/.changeset/slow-experts-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +fix the horizontal scrolling issue in the RadarPage component diff --git a/.changeset/spotty-apples-visit.md b/.changeset/spotty-apples-visit.md new file mode 100644 index 0000000000..ba481fa052 --- /dev/null +++ b/.changeset/spotty-apples-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Better presentation of authentication errors diff --git a/.changeset/swift-peas-argue.md b/.changeset/swift-peas-argue.md new file mode 100644 index 0000000000..d78d844d45 --- /dev/null +++ b/.changeset/swift-peas-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +give aria-label attribute to Status Ok, Warning and Error diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index c20a9862b0..0cbd0ffa05 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -68,6 +68,7 @@ graphviz Hackathons haproxy heroku +Heroku horizontalpodautoscalers Hostname http @@ -105,6 +106,7 @@ Monorepo monorepos msw namespace +namespaces Namespaces neuro newrelic @@ -196,6 +198,7 @@ validators Voi Wealthsimple Weaveworks +Webpack xyz yaml Zalando diff --git a/.gitignore b/.gitignore index e7d294d55a..5c27601791 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ -.idea/ +# macOS .DS_Store + +# IntelliJ +.idea/ +*.iml + +# Visual Studio Code .vscode/ .vsls.json diff --git a/.prettierignore b/.prettierignore index 4b1acbb594..c4e675a5d7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ coverage templates plugins/scaffolder-backend/sample-templates .vscode +dist-types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85834076d4..7edbc6d17b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ We use [changesets](https://github.com/atlassian/changesets) to help us prepare 1. Run `yarn changeset` 2. Select which packages you want to include a changeset for 3. Select impact of change that you're introducing (minor, major or patch) -4. Add generated changset to Git +4. Add generated changeset to Git 5. Push the commit with your changeset to the branch associated with your PR 6. Accept our gratitude for making the release process easier on the maintainer diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 1aebbeb0eb..0000000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,27 +0,0 @@ -# Deploying Backstage - -## Heroku - -Deploying to heroku is relatively easy following these steps. - -First, make sure you have the [heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log into it as well as login into Heroku's [container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). - -```bash -$ heroku login -$ heroku container:login -``` - -You _might_ also need to set your Heroku app's stack to `container` - -```bash -$ heroku stack:set container -a -``` - -We can now build/push the Docker image to Heroku's container registry and release it to the `web` worker. - -```bash -$ heroku container:push web -a -$ heroku container:release web -a -``` - -With that, you should have Backstage up and running! diff --git a/app-config.yaml b/app-config.yaml index 228eafc588..715d564feb 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -1,6 +1,7 @@ app: title: Backstage Example App baseUrl: http://localhost:7000 + googleAnalyticsTrackingId: # UA-000000-0 backend: baseUrl: http://localhost:7000 @@ -34,8 +35,14 @@ proxy: $env: TRAVISCI_AUTH_TOKEN travis-api-version: 3 + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY + organization: - name: Spotify + name: My Company techdocs: storageUrl: http://localhost:7000/api/techdocs/static/docs @@ -44,23 +51,20 @@ techdocs: techdocs: 'docker' sentry: - organization: spotify + organization: my-company rollbar: - organization: spotify + organization: my-company accountToken: $env: ROLLBAR_ACCOUNT_TOKEN -newrelic: - api: - baseUrl: 'https://api.newrelic.com/v2' - key: NEW_RELIC_REST_API_KEY - lighthouse: baseUrl: http://localhost:3003 kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: [] integrations: @@ -145,6 +149,7 @@ catalog: scaffolder: github: + host: https://github.com token: $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' @@ -236,10 +241,11 @@ costInsights: name: Big Query icon: search metrics: - dailyCost: - name: Your Company's Daily Cost DAU: - name: Cost Per DAU + name: Daily Active Users + default: true + MSC: + name: Monthly Subscribers homepage: clocks: - label: UTC diff --git a/catalog-info.yaml b/catalog-info.yaml index 937a55bfca..405c3f31b4 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -7,6 +7,7 @@ metadata: annotations: github.com/project-slug: spotify/backstage backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git + lighthouse.com/website-url: https://backstage.io spec: type: library owner: Spotify diff --git a/contrib/chart/backstage/Chart.yaml b/contrib/chart/backstage/Chart.yaml index 7d2a489199..24c6f2a4d7 100644 --- a/contrib/chart/backstage/Chart.yaml +++ b/contrib/chart/backstage/Chart.yaml @@ -5,7 +5,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 0.1.0 +version: 0.1.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 4f247252b1..9d1c93765b 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -62,13 +62,30 @@ backstage-ingress * 123.1.2.3 80 17m > **NOTE**: this is not a production ready deployment. -## Caveats - -The current implementation does not generate certificates for the ingress which means the browser will alert that the -site is insecure and using self-signed certificates. - ## Customization +### Issue certificates + +These charts can install or reuse a `clusterIssuer` to generate certificates for the backstage `ingress`. To do that: + +1. [Install][install-cert-manager] or make sure [cert-manager][cert-manager] is installed in the cluster. +2. Enable the issuer in the charts. This will first check if there is a `letsencrypt` issuer already deployed in your + cluster and deploy one if it doesn't exist. + +To enable it you need to provide a valid email address in the chart's values: + +``` +issuer: + email: me@example.com + clusterIssuer: "letsencrypt-prod" +``` + +By default, the charts use `letsencrypt-staging` so in the above example we instruct helm to use the production issuer +instead. + +[cert-manager]: https://cert-manager.io/docs/ +[install-cert-manager]: https://cert-manager.io/docs/installation/kubernetes/#installing-with-helm + ### Custom PostgreSQL instance Configuring a connection to an existing PostgreSQL instance is possible through the chart's values. diff --git a/contrib/chart/backstage/templates/backend-secret.yaml b/contrib/chart/backstage/templates/backend-secret.yaml index 63eddc2742..dc65f5777a 100644 --- a/contrib/chart/backstage/templates/backend-secret.yaml +++ b/contrib/chart/backstage/templates/backend-secret.yaml @@ -18,4 +18,5 @@ stringData: GITHUB_TOKEN: {{ .Values.auth.githubToken }} GITLAB_TOKEN: {{ .Values.auth.gitlabToken }} AZURE_TOKEN: {{ .Values.auth.azure.api.token }} + NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }} {{- end }} diff --git a/contrib/chart/backstage/templates/backstage-app-config.yaml b/contrib/chart/backstage/templates/backstage-app-config.yaml index 061fc3285b..f06e47feab 100644 --- a/contrib/chart/backstage/templates/backstage-app-config.yaml +++ b/contrib/chart/backstage/templates/backstage-app-config.yaml @@ -13,6 +13,7 @@ metadata: data: APP_CONFIG_app_baseUrl: {{ .Values.appConfig.app.baseUrl | quote | quote }} APP_CONFIG_app_title: {{ .Values.appConfig.app.title | quote | quote }} + APP_CONFIG_app_googleAnalyticsTrackingId: {{ .Values.appConfig.app.googleAnalyticsTrackingId | quote | quote }} APP_CONFIG_backend_baseUrl: {{ .Values.appConfig.backend.baseUrl | quote | quote }} APP_CONFIG_backend_cors_origin: {{ .Values.appConfig.backend.cors.origin | quote | quote }} APP_CONFIG_techdocs_storageUrl: {{ .Values.appConfig.techdocs.storageUrl | quote | quote }} diff --git a/contrib/chart/backstage/templates/ingress.yaml b/contrib/chart/backstage/templates/ingress.yaml index 034862ef75..7231afda13 100644 --- a/contrib/chart/backstage/templates/ingress.yaml +++ b/contrib/chart/backstage/templates/ingress.yaml @@ -5,16 +5,25 @@ apiVersion: networking.k8s.io/v1beta1 kind: Ingress metadata: name: {{ include "backstage.fullname" . }}-ingress - {{- with .Values.ingress.annotations }} annotations: + {{- if .Values.issuer.email }} + cert-manager.io/cluster-issuer: {{ .Values.issuer.clusterIssuer }} + {{- end }} + kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/ssl-redirect: "false" nginx.ingress.kubernetes.io/configuration-snippet: | if ($scheme = https) { add_header Strict-Transport-Security "max-age=0;"; } - {{- toYaml . | nindent 4 }} - {{- end }} + {{- toYaml .Values.ingress.annotations | nindent 4 }} spec: + tls: + - secretName: {{ include "backstage.fullname" . }}-tls + hosts: + - {{ $frontendUrl.host }} + - {{ $backendUrl.host }} + - {{ $lighthouseUrl.host }} + rules: - host: {{ $frontendUrl.host }} http: @@ -56,17 +65,22 @@ apiVersion: networking.k8s.io/v1beta1 kind: Ingress metadata: name: {{ include "backstage.fullname" . }}-ingress-lighthouse - {{- with .Values.ingress.annotations }} annotations: + {{- if .Values.issuer.email }} + cert-manager.io/cluster-issuer: {{ .Values.issuer.clusterIssuer }} + {{- end }} nginx.ingress.kubernetes.io/rewrite-target: /$2 nginx.ingress.kubernetes.io/ssl-redirect: "false" nginx.ingress.kubernetes.io/configuration-snippet: | if ($scheme = https) { add_header Strict-Transport-Security "max-age=0;"; } - {{- toYaml . | nindent 4 }} - {{- end }} + {{- toYaml .Values.ingress.annotations | nindent 4 }} spec: + tls: + - secretName: {{ include "backstage.fullname" . }}-tls + hosts: + - {{ $lighthouseUrl.host }} rules: - host: {{ $frontendUrl.host }} http: diff --git a/contrib/chart/backstage/templates/issuer.yaml b/contrib/chart/backstage/templates/issuer.yaml new file mode 100644 index 0000000000..d129c8c701 --- /dev/null +++ b/contrib/chart/backstage/templates/issuer.yaml @@ -0,0 +1,19 @@ +{{- if (and (.Capabilities.APIVersions.Has "cert-manager.io/v1alpha2") .Values.issuer.email ) -}} +{{/* Only install issuer if it doesn't already exist in the cluster */}} +{{- if not ( lookup "cert-manager.io/v1alpha2" "ClusterIssuer" "" .Values.issuer.clusterIssuer ) }} +apiVersion: cert-manager.io/v1alpha2 +kind: ClusterIssuer +metadata: + name: {{ .Values.issuer.clusterIssuer }} +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: {{ required "expected a valid .Values.issuer.email to enable ClusterIssuer" .Values.issuer.email }} + privateKeySecretRef: + name: {{ required "expected .Values.issuer.cluster-issuer to not be empty (letsencrypt-prod | letsencrypt-staging)" .Values.issuer.clusterIssuer }} + solvers: + - http01: + ingress: + class: nginx +{{- end -}} +{{- end -}} diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index 3ce45c5330..f92e8aad47 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -61,6 +61,10 @@ ingress: annotations: kubernetes.io/ingress.class: nginx +issuer: + email: + clusterIssuer: 'letsencrypt-staging' + global: postgresql: postgresqlUsername: backend-user @@ -82,6 +86,7 @@ appConfig: app: baseUrl: https://demo.example.com title: Backstage + googleAnalyticsTrackingId: backend: baseUrl: https://demo.example.com listen: @@ -243,3 +248,4 @@ auth: # Used by the scaffolder to create GitHub repos. Must have 'repo' scope. githubToken: g gitlabToken: g + newRelicRestApiKey: r diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 855608c8b1..5d633544e3 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -9,7 +9,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -25,7 +24,7 @@ const ExampleComponent: FC<{}> = () => { const profile = identityApi.getProfile(); return ( - +
**NOTE**: this is not a production ready deployment. For more information on how to customize the deployment check the -[README][charts-readme]. - -[backstage-helm-charts]: - (https://github.com/spotify/backstage/tree/master/contrib/chart/backstage) -[charts-readme]: - (https://github.com/spotify/backstage/tree/master/contrib/chart/backstage/README.md) +[README](https://github.com/spotify/backstage/tree/master/contrib/chart/backstage/README.md). diff --git a/docs/getting-started/deployment-k8s.md b/docs/getting-started/deployment-k8s.md index ddcbd290c6..69aa59a600 100644 --- a/docs/getting-started/deployment-k8s.md +++ b/docs/getting-started/deployment-k8s.md @@ -4,4 +4,10 @@ title: Kubernetes description: Documentation on Kubernetes and K8s Deployment --- -Coming soon! +Backstage itself provides tooling up to the point of building docker images. +Beyond that point we do not have an opinionated way to deploy Backstage within +Kubernetes, as each cluster has its own unique set of tooling and patterns. + +We do provide examples to help you get started though. Check out +[this example](https://github.com/spotify/backstage/tree/master/contrib/kubernetes/plain_single_backend_deplyoment/) +for a basic single-deployment setup. diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index 509bbdc978..3e06e17c77 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -17,3 +17,33 @@ $ docker run --rm -it -p 7000:7000 -e APP_ENV=production -e NODE_ENV=development ``` Then open http://localhost/ on your browser. + +## Heroku + +Deploying to Heroku is relatively easy following these steps. + +First, make sure you have the +[heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log +into it as well as login into Heroku's +[container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). + +```bash +$ heroku login +$ heroku container:login +``` + +You _might_ also need to set your Heroku app's stack to `container` + +```bash +$ heroku stack:set container -a +``` + +We can now build/push the Docker image to Heroku's container registry and +release it to the `web` worker. + +```bash +$ heroku container:push web -a +$ heroku container:release web -a +``` + +With that, you should have Backstage up and running! diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 3b11d2b8e1..0000000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -id: installation -title: Installation -description: Documentation on Installation ---- - -Coming soon! diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 59505aa915..51f09347ce 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -47,40 +47,29 @@ guidelines to get started. ### Ongoing work ๐Ÿšง -- **[Plugins for managing micro services end-2-end](https://github.com/spotify/backstage/milestone/14)** - +- **[Plugins for managing micro services end-2-end](https://github.com/spotify/backstage/milestone/14)** Out of the box Backstage will ship with a set of plugins (Overview, CI, API and Docs) that will demonstrate how a user can manage a micro service and follow a change all the way out in production. Completing this work will make it much easier to see how a plugin can be built that integrates with the Backstage Service Catalog. -- **[Kubernetes support](https://github.com/spotify/backstage/milestone/20)** - - Native support for Kubernetes, making it easier for developers to see and - manage their services running in k8s. - -- **[Helm charts](https://github.com/spotify/backstage/issues/2540)** - Provide - Helm charts for easy deployments of Backstage and its subsystems on - Kubernetes. +- **[Users and teams](https://github.com/spotify/backstage/issues/1807)** + Ownership is a central concept in Backstage. It should be easy to import your + existing organizational data, such as users and groups/teams, into Backstage. + A user the logs into Backstage should see software components owned by the + team(s) they are in. - **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** - The platform APIs and features are stable and can be depended on for production use. After this plugins will require little to no maintenance. -- Further improvements to platform documentation +* **[Improved Kubernetes plugin](https://github.com/spotify/backstage/issues/2857)** - + Native support for Kubernetes, making it easier for developers to see and + manage their services running in k8s. -### Plugins - -Building and maintaining [plugins](https://backstage.io/plugins) is the work of -the entire Backstage community. - -A list of plugins that are in development is -[available here](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aplugin+sort%3Areactions-%2B1-desc). -We strongly recommend to upvote ๐Ÿ‘ plugins you are interested in. This helps us -and the community prioritize what plugins to build. - -Are you missing a plugin for your favorite tool? Please -[suggest a new one](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). -Chances are that someone will jump in and help build it. +- Further improvements to platform documentation. Examples include a Golden Path + for plugin development. ### Future work ๐Ÿ”ฎ @@ -107,8 +96,24 @@ Chances are that someone will jump in and help build it. query. Plugins can easily query this API as well as extend the model where needed. +### Plugins + +Building and maintaining [plugins](https://backstage.io/plugins) is the work of +the entire Backstage community. + +A list of plugins that are in development is +[available here](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aplugin+sort%3Areactions-%2B1-desc). +We strongly recommend to upvote ๐Ÿ‘ plugins you are interested in. This helps us +and the community prioritize what plugins to build. + +Are you missing a plugin for your favorite tool? Please +[suggest a new one](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). +Chances are that someone will jump in and help build it. + ### Completed milestones โœ… +- [Kubernetes plugin - v1](https://github.com/spotify/backstage/tree/master/plugins/kubernetes) +- [Helm charts](https://github.com/spotify/backstage/tree/master/contrib/chart/backstage) - [Backstage Design System ๐Ÿ’…](https://backstage.io/blog/2020/09/30/backstage-design-system) - [Cost Insights plugin ๐Ÿ’ธ](https://engineering.atspotify.com/2020/09/29/managing-clouds-from-the-ground-up-cost-engineering-at-spotify/) - [Donate Backstage to the CNCF ๐ŸŽ‰](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox) diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md new file mode 100644 index 0000000000..277ca3929f --- /dev/null +++ b/docs/support/project-structure.md @@ -0,0 +1,239 @@ +--- +id: project-structure +title: Backstage Project Structure +description: + Introduction to files and folders in the Backstage Project repository +--- + +Backstage is a complex project, and the GitHub repository contains many +different files and folders. This document aims to clarify what purpose of those +files and folders are. + +## General purpose files and folders + +In the project root, there are a set of files and folders which are not part of +the project as such, and may or may not be familiar to someone looking through +the code. + +- [`.changeset/`](https://github.com/spotify/backstage/tree/master/.changeset) - + This folder contains files outlining which changes occurred in the project + since the last release. These files are added manually, but managed by + [changesets](https://github.com/atlassian/changesets) and will be removed at + every new release. They are essentially building-blocks of a CHANGELOG. + +- [`.github/`](https://github.com/spotify/backstage/tree/master/.github) - + Standard GitHub folder. It contains - amongst other things - our workflow + definitions and templates. Worth noting is the + [styles](https://github.com/spotify/backstage/tree/master/.github/styles) + folder which is used for a markdown spellchecker. + +- [`.yarn/`](https://github.com/spotify/backstage/tree/master/.yarn) - Backstage + ships with it's own `yarn` implementation. This allows us to have better + control over our `yarn.lock` file and hopefully avoid problems due to yarn + versioning differences. + +- [`docker/`](https://github.com/spotify/backstage/tree/master/docker) - Files + related to our root Dockerfile. We are planning to refactor this, so expect + this folder to be moved in the future. + +- [`contrib/`](https://github.com/spotify/backstage/tree/master/contrib) - + Collection of examples or resources provided by the community. We really + appreciate contributions in here and encourage them being kept up to date. + +- [`docs/`](https://github.com/spotify/backstage/tree/master/docs) - This is + where we keep all of our documentation markdown files. These ends up on + http://backstage.io/docs. Just keep in mind that changes to + [this](https://github.com/spotify/backstage/blob/master/microsite/sidebars.json) + file also needs to be updated. + +- [`.editorconfig`](https://github.com/spotify/backstage/tree/master/.editorconfig) - + A configuration file used by most common code editors. + +- [`.imgbotconfig`](https://github.com/spotify/backstage/tree/master/.imgbotconfig) - + Configuration for a [bot](https://imgbot.net/) + +## Monorepo packages + +Every folder in both `packages/` and `plugins/` is within our monorepo setup, as +defined in +[`package.json`](https://github.com/spotify/backstage/blob/master/package.json): + +```json + "workspaces": { + "packages": [ + "packages/*", + "plugins/*" + ] + }, +``` + +Let's look at them individually. + +### `packages/` + +These are all the packages that is we use within the project. +[Plugins](#plugins) are separated out into their own folder, see further down. + +- [`app/`](https://github.com/spotify/backstage/tree/master/packages/app) - This + is our take on how an App could look like, bringing together a set of packages + and plugins into a working Backstage App. This is not a published package, and + the main goals is to provide a demo of what an App could look like, and also + enabling local development. + +- [`backend/`](https://github.com/spotify/backstage/tree/master/packages/backend) - + Every standalone backstage project will have both an `app` _and_ a `backend` + package. The `backend` uses plugins to construct a working backend that the + frontend (`app`) can use. + +- [`backend-common/`](https://github.com/spotify/backstage/tree/master/packages/backend-common) - + There are no "core" packages in the backend. Instead we have `backend-common` + which contains helper middlewareยดs and other utils. + +- [`catalog-model/`](https://github.com/spotify/backstage/tree/master/packages/catalog-model) - + You can considers this to be a library for working with the catalog of sorts. + It contains the definition of an + [Entity](https://backstage.io/docs/features/software-catalog/references#docsNav), + as well as validation an other logic related to it. This package can be used + in both the frontend and the backend. + +- [`cli/`](https://github.com/spotify/backstage/tree/master/packages/cli) - One + of the biggest packages in our project, the `cli` is used to build, serve, + diff, create-plugins and more. In the early days of this project, we started + out with calling tools directly - such as `eslint` - through package.json. But + as it was tricky to have a good development experience around that when we + change named tooling, we opted for wrapping those in our own cli. That way + everything looks the same in package.json. Much like + [react-scripts](https://github.com/facebook/create-react-app/tree/master/packages/react-scripts). + +- [`cli-common/`](https://github.com/spotify/backstage/tree/master/packages/cli-common) - + This package mainly handles path resolving. It is a separate package to reduce + bugs in [cli](https://github.com/spotify/backstage/tree/master/packages/cli). + We also want as few dependencies as possible to reduce download time when + running the cli which is another reason this is a separate package. + +* [`config/`](https://github.com/spotify/backstage/tree/master/packages/config) - + The way we read configuration data. This package can take a bunch of config + objects and merge them together. + [app-config.yaml](https://github.com/spotify/backstage/blob/master/app-config.yaml) + is an example of an config object. + +* [`config-loader/`](https://github.com/spotify/backstage/tree/master/packages/config-loader) - + This package is used to read config objects. It does not know how to merge, + this only reads files and passes them on to the config. As this part os only + used by the backend, we chose to separate `config` and `config-loader` into + two different packages. + +- [`core/`](https://github.com/spotify/backstage/tree/master/packages/core) - + This package contains our visual React components, some of which you can find + [here](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data). + Apart from that it re-exports everything from [`core-api`] so that users only + need to rely on one package. + +* [`core-api/`](https://github.com/spotify/backstage/tree/master/packages/core-api) - + This package contains apis and definitions of such. It is it's own package + because we needed to split our `test-utils` package. It's an implementation + detail that we try to hide from our users, and no-one should have to depend on + it directly. + +* [`test-utils/`](https://github.com/spotify/backstage/tree/master/packages/test-utils) - + This package contains specific testing facilities used when testing + `core-api`. + +* [`test-utils-core/`](https://github.com/spotify/backstage/tree/master/packages/test-utils-core) - + This package contains more general purpose testing facilities for testing an + Backstage App. + +* [`create-app/`](https://github.com/spotify/backstage/tree/master/packages/create-app) - + An CLI to specifically scaffold a new Backstage App. It does so by using a + [template](https://github.com/spotify/backstage/tree/master/packages/create-app/templates/default-app). + +- [`dev-utils/`](https://github.com/spotify/backstage/tree/master/packages/dev-utils) - + Helps you setup a plugin for isolated development so that it can be served + separately. + +* [`docgen/`](https://github.com/spotify/backstage/tree/master/packages/docgen) - + Uses the + [Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API) + to read out definitions and generate documentation for it. + +* [`e2e-test/`](https://github.com/spotify/backstage/tree/master/packages/e2e-test) - + Another CLI that can be run to try out what would happen if you built all the + packages, publish them, created a new app, and the run it. CI uses this for + e2e-tests. + +* [`storybook/`](https://github.com/spotify/backstage/tree/master/packages/storybook) - + This folder contains only the storybook config. Stories are within the core + package. The Backstage Storybook is found + [here](https://backstage.io/storybook) + +* [`techdocs-cli/`](https://github.com/spotify/backstage/tree/master/packages/techdocs-cli) - + Used for verifying TechDocs locally. + +* [`techdocs-container/`](https://github.com/spotify/backstage/tree/master/packages/techdocs-container) - + Used by the `techdocs-cli` + +* [`test-utils-core/`](https://github.com/spotify/backstage/tree/master/packages/test-utils-core) + +* [`test-utils/`](https://github.com/spotify/backstage/tree/master/packages/test-utils) + +* [`theme/`](https://github.com/spotify/backstage/tree/master/packages/theme) - + Holds the Backstage Theme. + +### `plugins/` + +Most of the functionality of an Backstage App comes from plugins. Even core +features can be plugins, take the +[catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) as +an example. + +We can categorize plugins into three different types; **Frontend**, **Backend** +and **GraphQL**. We differentiate these types of plugins when we name them, with +a dash-suffix. `-backend` means itโ€™s a backend plugin and so on. + +One reason for splitting a plugin is because of to it's dependencies. Another +reason is for clear separation of concerns. + +Take a look at our [Plugin Gallery](https://backstage.io/plugins) or browse +through the +[`plugins/`](https://github.com/spotify/backstage/tree/master/plugins) folder. + +## Packages outside of the monorepo + +For convenience we include packages in our project that is not part of our +monorepo setup. + +- [`microsite/`](https://github.com/spotify/backstage/blob/master/microsite) - + This folder contains the source code for backstage.io. It is built with + [Docusaurus](https://docusaurus.io/). This folder is not part of the monorepo + due to dependency reasons. Look at the + [README](https://github.com/spotify/backstage/blob/master/microsite/README.md) + for instructions on how to run it locally. + +## Root files specifically used by the `app` + +These files are kept in the root of the project mostly by historical reasons. +Some of these files may be subject to be moved out of the root sometime in the +future. + +- [`.npmrc`](https://github.com/spotify/backstage/tree/master/.npmrc) - It's + common for companies to have their own npm registry, this files makes sure + that this folder use the public registry. + +- [`.vale.ini`](https://github.com/spotify/backstage/tree/master/.vale.ini) - + [Spell checker](https://github.com/errata-ai/vale) for markdown files + +- [`.yarnrc`](https://github.com/spotify/backstage/tree/master/.yarnrc) - + Enforces "our" version of yarn. + +- [`app-config.yaml`](https://github.com/spotify/backstage/tree/master/app-config.yaml) - + Configuration for the app, both frontend and backend + +- [`app-config.development.yaml`](https://github.com/spotify/backstage/tree/master/app-config.development.yaml) - + Used for overriding configuration when developing locally. + +- [`catalog-info.yaml`](https://github.com/spotify/backstage/tree/master/catalog-info.yaml) - + Description of Backstage in the Backstage Entity format. + +- [`lerna.json`](https://github.com/spotify/backstage/tree/master/lerna.json) - + [lerna](https://github.com/lerna/lerna) monorepo config. We are using + `yarn workspaces`, so this will only be used for executing scripts. diff --git a/docs/overview/support.md b/docs/support/support.md similarity index 100% rename from docs/overview/support.md rename to docs/support/support.md diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js index a8255bcd0d..66f3a9776d 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -44,7 +44,7 @@ const Plugins = () => ( - + {pluginMetadata.map( ({ iconUrl, @@ -57,26 +57,25 @@ const Plugins = () => ( }) => (
- {title} -

{title}

-

- by {author} -

- {category} +
+ {title} +
+
+

{title}

+

+ by {author} +

+ {category} +

{truncate(description)}

- - - - Explore - - - +
), )} diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 7081e2bb1c..64fdd2f420 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -28,6 +28,7 @@ "label": "Deployment", "ids": [ "getting-started/deployment-k8s", + "getting-started/deployment-helm", "getting-started/deployment-other" ] } @@ -163,7 +164,7 @@ "architecture-decisions/adrs-adr009" ], "Contribute": ["../CONTRIBUTING"], - "Support": ["overview/support"], + "Support": ["support/support", "support/project-structure"], "FAQ": ["FAQ"] } } diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 6599aef6a6..6308513276 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -1088,6 +1088,13 @@ code { .nav-footer .copyright { width: 600px; color: #fff; - margin: auto; + margin: 0 auto; font-size: 10pt; } + +@media only screen and (max-width: 485px) { + .nav-footer .copyright { + width: auto; + margin: 0 1.5em; + } +} diff --git a/microsite/static/css/plugins.css b/microsite/static/css/plugins.css index 9a8bbafa46..8a0d843dc9 100644 --- a/microsite/static/css/plugins.css +++ b/microsite/static/css/plugins.css @@ -6,7 +6,7 @@ flex-direction: column; } -.grid { +.PluginGrid { display: grid; grid-gap: 1rem; grid-template-columns: repeat(4, 1fr); @@ -15,17 +15,23 @@ } @media (max-width: 1200px) { - .grid { + .PluginGrid { grid-template-columns: repeat(3, 1fr); } } @media only screen and (max-width: 815px) { - .grid { + .PluginGrid { grid-template-columns: repeat(2, 1fr); } } +@media only screen and (max-width: 485px) { + .PluginGrid { + grid-template-columns: 1fr; + } +} + .PluginCard img { float: left; margin: 0px 16px 8px 0px; @@ -34,14 +40,32 @@ } .PluginCardHeader { + display: flex; + flex-direction: row; + align-items: center; max-height: fit-content; min-height: fit-content; } +.PluginCardImage { + width: 80px; + height: 80px; + margin-right: 16px; +} + +.PluginCardImage img { + width: 100%; + max-width: 100%; +} + .PluginCardTitle { color: white; vertical-align: top; - margin: 8px 0px 0px 16px; + margin: 8px 0 0; +} + +.PluginCardInfo { + flex: 1; } .PluginAddNewButton { @@ -50,11 +74,16 @@ right: 0px; } +@media only screen and (max-width: 485px) { + .PluginAddNewButton { + bottom: -4px; + } +} + .ButtonFilled { padding: 4px 8px; border-radius: 4px; color: #69ddc7; - margin-top: 36px; } .ButtonFilled:hover { @@ -62,7 +91,7 @@ background-color: transparent; } -.ChipOutlined { +.PluginCardChipOutlined { font-size: small; border-radius: 16px; padding: 2px 8px; @@ -70,11 +99,16 @@ color: #69ddc7; } -.PluginCardLink { +.PluginCardFooter { + display: flex; + justify-content: flex-end; + align-items: flex-end; + margin-top: auto; + min-height: 2em; +} + +.PluginCardFooter a { padding: 2px 8px; - position: absolute; - bottom: 0; - right: 0; } .PluginPageLayout { @@ -95,18 +129,13 @@ padding-top: 8px; } -.PluginCardFooter { - position: relative; - min-height: 2em; -} - -.Author, -.Author a { +.PluginCardAuthor, +.PluginCardAuthor a { margin-bottom: 0.25em; color: rgba(255, 255, 255, 0.6); } -.Author a:hover { +.PluginCardAuthor a:hover { color: white; } diff --git a/mkdocs.yml b/mkdocs.yml index 4fbec4650d..8a4a78ddf1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,6 @@ nav: - Getting started: - Getting Started: 'getting-started/index.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' - - Installation: 'getting-started/installation.md' - Local development: 'getting-started/development-environment.md' - Demo deployment: https://backstage-demo.roadie.io - Production deployments: @@ -23,6 +22,7 @@ nav: - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - Deployment scenarios: - Kubernetes: 'getting-started/deployment-k8s.md' + - Kubernetes and Helm: 'getting-started/deployment-helm.md' - Other: 'getting-started/deployment-other.md' - Features: - Software Catalog: @@ -107,7 +107,9 @@ nav: - ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md' - ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md' - Contribute: '../CONTRIBUTING.md' - - Support: 'overview/support.md' + - Support: + - 'support/support.md' + - 'support/project-structure.md' - FAQ: FAQ.md plugins: diff --git a/package.json b/package.json index 371646074c..96e02e33f9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@changesets/cli": "^2.11.0", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^8.0.0", + "command-exists": "^1.2.9", "concurrently": "^5.2.0", "fs-extra": "^9.0.0", "husky": "^4.2.3", @@ -65,7 +66,7 @@ "prettier --write" ], "*.md": [ - "vale" + "node ./scripts/check-docs-quality" ] }, "jest": { diff --git a/packages/app/package.json b/packages/app/package.json index 15b5cbed6e..11c47769a8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -34,8 +34,8 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", - "@roadiehq/backstage-plugin-github-insights": "^0.2.2", - "@roadiehq/backstage-plugin-github-pull-requests": "^0.4.3", + "@roadiehq/backstage-plugin-github-insights": "^0.2.7", + "@roadiehq/backstage-plugin-github-pull-requests": "^0.5.1", "@roadiehq/backstage-plugin-travis-ci": "^0.2.3", "dayjs": "^1.9.1", "history": "^5.0.0", diff --git a/packages/app/public/index.html b/packages/app/public/index.html index ea9208ca57..77e5c01e19 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -48,6 +48,23 @@ } <%= app.title %> + + <% if (app.googleAnalyticsTrackingId && typeof app.googleAnalyticsTrackingId + === 'string') { %> + + + <% } %> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 16c105ca0b..f30e7ff34f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -61,6 +61,11 @@ import { LastLighthouseAuditCard, isPluginApplicableToEntity as isLighthouseAvailable, } from '@backstage/plugin-lighthouse/'; +import { + Router as PullRequestsRouter, + isPluginApplicableToEntity as isPullRequestsAvailable, + PullRequestsStatsCard, +} from '@roadiehq/backstage-plugin-github-pull-requests'; const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -143,6 +148,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( )} + {isPullRequestsAvailable(entity) && ( + + + + )} ); @@ -178,6 +188,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( title="Kubernetes" element={} /> + } + /> ( title="Kubernetes" element={} /> + } + /> { + ): Promise { const aggregation = aggregationFor( durationOf(intervals), - metric ? 0.3 : 8_000, - ); - const groupDailyCost: Cost = await this.request( - { group, metric, intervals }, + 100_000, + ).map(entry => ({ ...entry, amount: Math.round(entry.amount) })); + + const cost: MetricData = await this.request( + { metric, intervals }, + { + format: 'number', + aggregation: aggregation, + change: changeOf(aggregation), + trendline: trendlineOf(aggregation), + }, + ); + + return cost; + } + + async getGroupDailyCost(group: string, intervals: string): Promise { + const aggregation = aggregationFor(durationOf(intervals), 8_000); + const groupDailyCost: Cost = await this.request( + { group, intervals }, { - id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel aggregation: aggregation, change: changeOf(aggregation), trendline: trendlineOf(aggregation), @@ -138,17 +153,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return groupDailyCost; } - async getProjectDailyCost( - project: string, - metric: string | null, - intervals: string, - ): Promise { - const aggregation = aggregationFor( - durationOf(intervals), - metric ? 0.1 : 1_500, - ); + async getProjectDailyCost(project: string, intervals: string): Promise { + const aggregation = aggregationFor(durationOf(intervals), 1_500); const projectDailyCost: Cost = await this.request( - { project, metric, intervals }, + { project, intervals }, { id: 'project-a', aggregation: aggregation, diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts index c9a4107686..eb26013b4b 100644 --- a/packages/backend-common/src/database/config.test.ts +++ b/packages/backend-common/src/database/config.test.ts @@ -17,7 +17,7 @@ import { mergeDatabaseConfig } from './config'; describe('config', () => { - describe(mergeDatabaseConfig, () => { + describe('mergeDatabaseConfig', () => { it('does not mutate the input object', () => { const input = { original: 'key', diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 2cc3a54e06..de0b0e40dc 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -26,7 +26,7 @@ describe('database connection', () => { }, ]); - describe(createDatabaseClient, () => { + describe('createDatabaseClient', () => { it('returns a postgres connection', () => { expect( createDatabaseClient( diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts index 82161dc53c..8d139e4481 100644 --- a/packages/backend-common/src/database/postgres.test.ts +++ b/packages/backend-common/src/database/postgres.test.ts @@ -44,7 +44,7 @@ describe('postgres', () => { }, ]); - describe(buildPgDatabaseConfig, () => { + describe('buildPgDatabaseConfig', () => { it('builds a postgres config', () => { const mockConnection = createMockConnection(); @@ -125,7 +125,7 @@ describe('postgres', () => { }); }); - describe(getPgConnectionConfig, () => { + describe('getPgConnectionConfig', () => { it('returns the connection object back', () => { const mockConnection = createMockConnection(); const config = createConfig(mockConnection); @@ -163,7 +163,7 @@ describe('postgres', () => { }); }); - describe(createPgDatabaseClient, () => { + describe('createPgDatabaseClient', () => { it('creates a postgres knex instance', () => { expect( createPgDatabaseClient( @@ -188,8 +188,8 @@ describe('postgres', () => { }); }); - describe(parsePgConnectionString, () => { - it('parses a connection string uri ', () => { + describe('parsePgConnectionString', () => { + it('parses a connection string uri', () => { expect( parsePgConnectionString( 'postgresql://postgres:pass@foobar:5432/dbname?ssl=true', diff --git a/packages/backend-common/src/database/postgres.ts b/packages/backend-common/src/database/postgres.ts index 82e971f081..04bd3615b6 100644 --- a/packages/backend-common/src/database/postgres.ts +++ b/packages/backend-common/src/database/postgres.ts @@ -57,7 +57,7 @@ export function buildPgDatabaseConfig( * Gets the postgres connection config * * @param dbConfig The database config - * @param parseConnectionString Flag to explictly control connection string parsing + * @param parseConnectionString Flag to explicitly control connection string parsing */ export function getPgConnectionConfig( dbConfig: Config, diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts index dbff5ee354..a3ab331c2d 100644 --- a/packages/backend-common/src/database/sqlite3.test.ts +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -32,7 +32,7 @@ describe('sqlite3', () => { }, ]); - describe(buildSqliteDatabaseConfig, () => { + describe('buildSqliteDatabaseConfig', () => { it('buidls a string connection', () => { expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({ client: 'sqlite3', @@ -72,7 +72,7 @@ describe('sqlite3', () => { }); }); - describe(createSqliteDatabaseClient, () => { + describe('createSqliteDatabaseClient', () => { it('creates an in memory knex instance', () => { expect( createSqliteDatabaseClient( diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts index 402c0e2252..262be366f6 100644 --- a/packages/backend-common/src/paths.ts +++ b/packages/backend-common/src/paths.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -/* eslint-disable @typescript-eslint/camelcase */ - import { resolve as resolvePath } from 'path'; /** diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index b8e99e6398..3c205a3800 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -14,37 +14,21 @@ * limitations under the License. */ +import { useHotCleanup } from '@backstage/backend-common'; import { + CatalogBuilder, createRouter, - DatabaseEntitiesCatalog, - DatabaseLocationsCatalog, - DatabaseManager, - HigherOrderOperations, - LocationReaders, runPeriodically, } from '@backstage/plugin-catalog-backend'; import { PluginEnvironment } from '../types'; -import { useHotCleanup } from '@backstage/backend-common'; -export default async function createPlugin({ - logger, - config, - reader, - database, -}: PluginEnvironment) { - const locationReader = new LocationReaders({ logger, reader, config }); - - const db = await DatabaseManager.createDatabase(await database.getClient(), { - logger, - }); - const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); - const higherOrderOperation = new HigherOrderOperations( +export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { entitiesCatalog, locationsCatalog, - locationReader, - logger, - ); + higherOrderOperation, + } = await builder.build(); useHotCleanup( module, @@ -55,6 +39,6 @@ export default async function createPlugin({ entitiesCatalog, locationsCatalog, higherOrderOperation, - logger, + logger: env.logger, }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 90667bfb16..24bd4e942f 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -68,7 +68,12 @@ export default async function createPlugin({ ) as RepoVisibilityOptions; const githubToken = githubConfig.getString('token'); - const githubClient = new Octokit({ auth: githubToken }); + const githubHost = + githubConfig.getOptionalString('host') ?? 'https://github.com'; + const githubClient = new Octokit({ + auth: githubToken, + baseUrl: githubHost, + }); const githubPublisher = new GithubPublisher({ client: githubClient, token: githubToken, diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 6e36c26f39..dea31c763b 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -21,9 +21,7 @@ import { Generators, LocalPublish, TechdocsGenerator, - GithubPreparer, - GitlabPreparer, - AzurePreparer, + CommonGitPreparer, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -38,14 +36,13 @@ export default async function createPlugin({ generators.register('techdocs', techdocsGenerator); const preparers = new Preparers(); - const githubPreparer = new GithubPreparer(logger); - const gitlabPreparer = new GitlabPreparer(logger); - const azurePreparer = new AzurePreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + const directoryPreparer = new DirectoryPreparer(logger); preparers.register('dir', directoryPreparer); - preparers.register('github', githubPreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('azure/api', azurePreparer); + preparers.register('github', commonGitPreparer); + preparers.register('gitlab', commonGitPreparer); + preparers.register('azure/api', commonGitPreparer); const publisher = new LocalPublish(logger); diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/swapi-graphql.yaml index 152d9c0afa..bc5e8e60ac 100644 --- a/packages/catalog-model/examples/swapi-graphql.yaml +++ b/packages/catalog-model/examples/swapi-graphql.yaml @@ -5,6 +5,8 @@ metadata: description: SWAPI GraphQL Schema spec: type: graphql + lifecycle: production + owner: yoda@dagobah.space definition: | schema { query: Root diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 8fc4484d33..5ba2b6efdf 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -22,11 +22,11 @@ "dependencies": { "@backstage/config": "^0.1.1-alpha.25", "@types/json-schema": "^7.0.5", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "json-schema": "^0.2.5", "lodash": "^4.17.15", "uuid": "^8.0.0", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts new file mode 100644 index 0000000000..cd869f798c --- /dev/null +++ b/packages/catalog-model/src/EntityPolicies.test.ts @@ -0,0 +1,110 @@ +import { Entity } from './entity'; +/* + * Copyright 2020 Spotify AB + * + * 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 { EntityPolicies } from './EntityPolicies'; +import { EntityPolicy } from './types'; + +describe('EntityPolicies', () => { + const p1: jest.Mocked = { enforce: jest.fn() }; + const p2: jest.Mocked = { enforce: jest.fn() }; + const entity1: Entity = { + apiVersion: 'a1', + kind: 'k1', + metadata: { name: 'n1' }, + }; + const entity2: Entity = { + apiVersion: 'a2', + kind: 'k2', + metadata: { name: 'n2' }, + }; + + afterEach(() => jest.resetAllMocks()); + + describe('allOf', () => { + it('resolves when no policies', async () => { + const policy = EntityPolicies.allOf([]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('resolves when all resolve', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when any rejects', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockRejectedValue(new Error('a')); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow('a'); + }); + + it('rejects when any ignores', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(undefined); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow( + /did not return a result/, + ); + }); + + it('passes through transforms properly', async () => { + p1.enforce.mockResolvedValue(entity2); + p2.enforce.mockResolvedValue(entity2); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity2); + expect(p1.enforce).toBeCalledWith(entity1); + expect(p2.enforce).toBeCalledWith(entity2); + }); + }); + + describe('oneOf', () => { + it('rejects when no policies', async () => { + const policy = EntityPolicies.oneOf([]); + await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/); + }); + + it('resolves when one resolves', async () => { + p1.enforce.mockResolvedValue(undefined); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when one rejects first', async () => { + p1.enforce.mockRejectedValue(new Error('a')); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow('a'); + }); + + it('resolves first resolution when several resolve', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(entity2); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when all ignore', async () => { + p1.enforce.mockResolvedValue(undefined); + p2.enforce.mockResolvedValue(undefined); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/); + }); + }); +}); diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index acdfb4df2a..1f304f8f27 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -14,22 +14,7 @@ * limitations under the License. */ -import { - DefaultNamespaceEntityPolicy, - Entity, - FieldFormatEntityPolicy, - NoForeignRootFieldsEntityPolicy, - ReservedFieldsEntityPolicy, - SchemaValidEntityPolicy, -} from './entity'; -import { - apiEntityV1alpha1Policy, - componentEntityV1alpha1Policy, - groupEntityV1alpha1Policy, - locationEntityV1alpha1Policy, - templateEntityV1alpha1Policy, - userEntityV1alpha1Policy, -} from './kinds'; +import { Entity } from './entity'; import { EntityPolicy } from './types'; // Helper that requires that all of a set of policies can be successfully @@ -68,42 +53,11 @@ class AnyEntityPolicy implements EntityPolicy { } } -export class EntityPolicies implements EntityPolicy { - private readonly policy: EntityPolicy; - - static defaultPolicies(): EntityPolicy { - return EntityPolicies.allOf([ - EntityPolicies.allOf([ - new SchemaValidEntityPolicy(), - new DefaultNamespaceEntityPolicy(), - new NoForeignRootFieldsEntityPolicy(), - new FieldFormatEntityPolicy(), - new ReservedFieldsEntityPolicy(), - ]), - EntityPolicies.anyOf([ - componentEntityV1alpha1Policy, - groupEntityV1alpha1Policy, - userEntityV1alpha1Policy, - locationEntityV1alpha1Policy, - templateEntityV1alpha1Policy, - apiEntityV1alpha1Policy, - ]), - ]); - } - - static allOf(policies: EntityPolicy[]): EntityPolicy { +export const EntityPolicies = { + allOf(policies: EntityPolicy[]) { return new AllEntityPolicies(policies); - } - - static anyOf(policies: EntityPolicy[]): EntityPolicy { + }, + oneOf(policies: EntityPolicy[]) { return new AnyEntityPolicy(policies); - } - - constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) { - this.policy = policy; - } - - enforce(entity: Entity): Promise { - return this.policy.enforce(entity); - } -} + }, +}; diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index a6d376b4ac..4ff73e9a49 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -18,24 +18,26 @@ import * as yup from 'yup'; import { EntityPolicy } from '../../types'; import { Entity } from '../Entity'; -const DEFAULT_ENTITY_SCHEMA = yup.object({ - apiVersion: yup.string().required(), - kind: yup.string().required(), - metadata: yup - .object({ - uid: yup.string().notRequired().min(1), - etag: yup.string().notRequired().min(1), - generation: yup.number().notRequired().integer().min(1), - name: yup.string().required(), - namespace: yup.string().notRequired(), - description: yup.string().notRequired(), - labels: yup.object>().notRequired(), - annotations: yup.object>().notRequired(), - tags: yup.array().notRequired(), - }) - .required(), - spec: yup.object({}).notRequired(), -}); +const DEFAULT_ENTITY_SCHEMA = yup + .object({ + apiVersion: yup.string().required(), + kind: yup.string().required(), + metadata: yup + .object({ + uid: yup.string().notRequired().min(1), + etag: yup.string().notRequired().min(1), + generation: yup.number().notRequired().integer().min(1), + name: yup.string().required(), + namespace: yup.string().notRequired(), + description: yup.string().notRequired(), + labels: yup.object>().notRequired(), + annotations: yup.object>().notRequired(), + tags: yup.array().notRequired(), + }) + .required(), + spec: yup.object({}).notRequired(), + }) + .required(); /** * Ensures that the entity spec is valid according to a schema. diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 57d6d47388..11434937cd 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -101,4 +101,24 @@ describe('ComponentV1alpha1Policy', () => { (entity as any).spec.owner = ''; await expect(policy.enforce(entity)).rejects.toThrow(/owner/); }); + + it('accepts missing implementsApis', async () => { + delete (entity as any).spec.implementsApis; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects empty implementsApis', async () => { + (entity as any).spec.implementsApis = ['']; + await expect(policy.enforce(entity)).rejects.toThrow(/implementsApis/); + }); + + it('rejects undefined implementsApis', async () => { + (entity as any).spec.implementsApis = [undefined]; + await expect(policy.enforce(entity)).rejects.toThrow(/implementsApis/); + }); + + it('accepts no implementsApis', async () => { + (entity as any).spec.implementsApis = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 9efc491ca9..662ce28b52 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -29,7 +29,7 @@ const schema = yup.object>({ type: yup.string().required().min(1), lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), - implementsApis: yup.array(yup.string()).notRequired(), + implementsApis: yup.array(yup.string().required()).notRequired(), }) .required(), }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts index dc0ecd3fa3..9137727c6e 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -90,9 +90,14 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); }); - it('accepts empty ancestors', async () => { + it('rejects empty ancestors', async () => { (entity as any).spec.ancestors = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + }); + + it('rejects undefined ancestors', async () => { + (entity as any).spec.ancestors = [undefined]; + await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); }); it('accepts no ancestors', async () => { @@ -105,9 +110,14 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).rejects.toThrow(/children/); }); - it('accepts empty children', async () => { + it('rejects empty children', async () => { (entity as any).spec.children = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(policy.enforce(entity)).rejects.toThrow(/children/); + }); + + it('rejects undefined children', async () => { + (entity as any).spec.children = [undefined]; + await expect(policy.enforce(entity)).rejects.toThrow(/children/); }); it('accepts no children', async () => { @@ -120,9 +130,14 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); }); - it('accepts empty descendants', async () => { + it('rejects empty descendants', async () => { (entity as any).spec.descendants = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + }); + + it('rejects undefined descendants', async () => { + (entity as any).spec.descendants = [undefined]; + await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); }); it('accepts no descendants', async () => { diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 073332d707..f0e91ddce8 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -30,21 +30,23 @@ const schema = yup.object>({ parent: yup.string().notRequired().min(1), // Use these manual tests because yup .required() requires at least // one element and there is no simple workaround -_- - ancestors: yup.array(yup.string()).test({ + // the cast is there to convince typescript that the array itself is + // required without using .required() + ancestors: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'ancestors must be defined', test: v => Boolean(v), - }), - children: yup.array(yup.string()).test({ + }) as yup.ArraySchema, + children: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'children must be defined', test: v => Boolean(v), - }), - descendants: yup.array(yup.string()).test({ + }) as yup.ArraySchema, + descendants: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'descendants must be defined', test: v => Boolean(v), - }), + }) as yup.ArraySchema, }) .required(), }); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts index a259eaeca8..865ceda53e 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -93,6 +93,11 @@ describe('LocationV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); + it('accepts empty targets', async () => { + (entity as any).spec.targets = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + it('rejects wrong targets', async () => { (entity as any).spec.targets = 7; await expect(policy.enforce(entity)).rejects.toThrow(/targets/); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 6d1f1f5515..dc2485f11b 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -28,7 +28,7 @@ const schema = yup.object>({ .object({ type: yup.string().required().min(1), target: yup.string().notRequired().min(1), - targets: yup.array(yup.string()).notRequired(), + targets: yup.array(yup.string().required()).notRequired(), }) .required(), }); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index ff4edcc8a2..6086b3776e 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -35,11 +35,13 @@ const schema = yup.object>({ .notRequired(), // Use this manual test because yup .required() requires at least one // element and there is no simple workaround -_- - memberOf: yup.array(yup.string()).test({ + // the cast is there to convince typescript that the array itself is + // required without using .required() + memberOf: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'memberOf must be defined', test: v => Boolean(v), - }), + }) as yup.ArraySchema, }) .required(), }); diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index 5fad47bdd0..b1b91edd83 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -22,7 +22,8 @@ export const locationSpecSchema = yup type: yup.string().required(), target: yup.string().required(), }) - .noUnknown(); + .noUnknown() + .required(); export const locationSchema = yup .object({ @@ -30,4 +31,5 @@ export const locationSchema = yup type: yup.string().required(), target: yup.string().required(), }) - .noUnknown(); + .noUnknown() + .required(); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 457ef64611..129fdd2b62 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -113,7 +113,7 @@ describe('CommonValidatorFunctions', () => { [{}, true], [{ a: 1 }, true], [{ a: undefined }, false], - ] as [any, boolean][])(`isJsonSafe %p ? %p`, (value, result) => { + ] as [unknown, boolean][])(`isJsonSafe %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isJsonSafe(value)).toBe(result); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 349d6d7f72..90b4e1a611 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -31,7 +31,7 @@ export class CommonValidatorFunctions { * @param isValidSuffix Checks that the part after the separator (or the entire value if there is no separator) is valid */ static isValidPrefixAndOrSuffix( - value: any, + value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean, @@ -55,7 +55,7 @@ export class CommonValidatorFunctions { * * @param value The value to check */ - static isJsonSafe(value: any): boolean { + static isJsonSafe(value: unknown): boolean { try { return lodash.isEqual(value, JSON.parse(JSON.stringify(value))); } catch { @@ -69,7 +69,7 @@ export class CommonValidatorFunctions { * @param value The value to check * @see https://tools.ietf.org/html/rfc1123 */ - static isValidDnsSubdomain(value: any): boolean { + static isValidDnsSubdomain(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -84,7 +84,7 @@ export class CommonValidatorFunctions { * @param value The value to check * @see https://tools.ietf.org/html/rfc1123 */ - static isValidDnsLabel(value: any): boolean { + static isValidDnsLabel(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && diff --git a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts index fa938f5fcb..ada0aa71ff 100644 --- a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts @@ -25,7 +25,7 @@ import { CommonValidatorFunctions } from './CommonValidatorFunctions'; * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set */ export class KubernetesValidatorFunctions { - static isValidApiVersion(value: any): boolean { + static isValidApiVersion(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -34,7 +34,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidKind(value: any): boolean { + static isValidKind(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -43,7 +43,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidObjectName(value: any): boolean { + static isValidObjectName(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -52,11 +52,11 @@ export class KubernetesValidatorFunctions { ); } - static isValidNamespace(value: any): boolean { + static isValidNamespace(value: unknown): boolean { return CommonValidatorFunctions.isValidDnsLabel(value); } - static isValidLabelKey(value: any): boolean { + static isValidLabelKey(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -65,13 +65,13 @@ export class KubernetesValidatorFunctions { ); } - static isValidLabelValue(value: any): boolean { + static isValidLabelValue(value: unknown): boolean { return ( value === '' || KubernetesValidatorFunctions.isValidObjectName(value) ); } - static isValidAnnotationKey(value: any): boolean { + static isValidAnnotationKey(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -80,7 +80,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidAnnotationValue(value: any): boolean { + static isValidAnnotationValue(value: unknown): boolean { return typeof value === 'string'; } } diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index 14706485ca..a4475c4809 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -15,13 +15,13 @@ */ export type Validators = { - isValidApiVersion(value: any): boolean; - isValidKind(value: any): boolean; - isValidEntityName(value: any): boolean; - isValidNamespace(value: any): boolean; - isValidLabelKey(value: any): boolean; - isValidLabelValue(value: any): boolean; - isValidAnnotationKey(value: any): boolean; - isValidAnnotationValue(value: any): boolean; - isValidTag(value: any): boolean; + isValidApiVersion(value: unknown): boolean; + isValidKind(value: unknown): boolean; + isValidEntityName(value: unknown): boolean; + isValidNamespace(value: unknown): boolean; + isValidLabelKey(value: unknown): boolean; + isValidLabelValue(value: unknown): boolean; + isValidAnnotationKey(value: unknown): boolean; + isValidAnnotationValue(value: unknown): boolean; + isValidTag(value: unknown): boolean; }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 5bd732f1d2..dd9e82c325 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,9 @@ "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^8.1.0", "@rollup/plugin-yaml": "^2.1.1", - "@spotify/eslint-config": "^7.0.1", + "@spotify/eslint-config-base": "^8.0.0", + "@spotify/eslint-config-react": "^8.0.0", + "@spotify/eslint-config-typescript": "^8.0.0", "@sucrase/webpack-loader": "^2.0.0", "@svgr/plugin-jsx": "5.4.x", "@svgr/plugin-svgo": "5.4.x", @@ -47,6 +49,8 @@ "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^2.5.0", + "@typescript-eslint/eslint-plugin": "^v3.10.1", + "@typescript-eslint/parser": "^v3.10.1", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", @@ -56,9 +60,14 @@ "diff": "^4.0.2", "esbuild": "^0.7.7", "eslint": "^7.1.0", + "eslint-config-prettier": "^6.0.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-import": "^2.20.2", + "eslint-plugin-jest": "^24.1.0", + "eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-monorepo": "^0.2.1", + "eslint-plugin-react": "^7.12.4", + "eslint-plugin-react-hooks": "^4.0.0", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", @@ -76,7 +85,7 @@ "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", "rollup": "2.23.x", - "rollup-plugin-dts": "1.4.11", + "rollup-plugin-dts": "1.4.13", "rollup-plugin-esbuild": "^2.0.0", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^3.1.1", @@ -84,11 +93,12 @@ "rollup-pluginutils": "^2.8.2", "start-server-webpack-plugin": "^2.2.5", "style-loader": "^1.2.1", - "sucrase": "^3.14.1", + "sucrase": "^3.16.0", "tar": "^6.0.1", + "terser-webpack-plugin": "^1.4.3", "ts-jest": "^26.0.0", "ts-loader": "^7.0.4", - "typescript": "^3.9.3", + "typescript": "^4.0.3", "url-loader": "^4.1.0", "webpack": "^4.41.6", "webpack-dev-server": "^3.11.0", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index 9713acd233..ae67616515 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -19,6 +19,7 @@ import { loadConfig } from '@backstage/config-loader'; import { ConfigReader } from '@backstage/config'; import { paths } from '../../lib/paths'; import { buildBundle } from '../../lib/bundler'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; export default async (cmd: Command) => { const appConfigs = await loadConfig({ @@ -27,6 +28,7 @@ export default async (cmd: Command) => { }); await buildBundle({ entry: 'src/index', + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), statsJsonEnabled: cmd.stats, config: ConfigReader.fromConfigs(appConfigs), appConfigs, diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index b6e9da7b39..b6ae0dd579 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -14,12 +14,13 @@ * limitations under the License. */ +import { Command } from 'commander'; import fs from 'fs-extra'; import { join as joinPath, relative as relativePath } from 'path'; import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { run } from '../../lib/run'; -import { Command } from 'commander'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; const PKG_PATH = 'package.json'; @@ -41,6 +42,7 @@ export default async (cmd: Command) => { ...appConfigs, { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, ], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), skeleton: 'skeleton.tar', }); console.log(`Dist workspace ready at ${tempDistWorkspace}`); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index cbaf956bdb..057e36ceb9 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -224,9 +224,11 @@ export default async (cmd: Command) => { } const answers: Answers = await inquirer.prompt(questions); + const pluginId = cmd.backend ? `${answers.id}-backend` : answers.id; + const name = cmd.scope - ? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}` - : `plugin-${answers.id}`; + ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}` + : `plugin-${pluginId}`; const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : ''; const privatePackage = cmd.private === false ? false : true; const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); @@ -236,10 +238,10 @@ export default async (cmd: Command) => { ? 'templates/default-backend-plugin' : 'templates/default-plugin', ); - const tempDir = resolvePath(os.tmpdir(), answers.id); + const tempDir = resolvePath(os.tmpdir(), pluginId); const pluginDir = isMonoRepo - ? paths.resolveTargetRoot('plugins', answers.id) - : paths.resolveTargetRoot(answers.id); + ? paths.resolveTargetRoot('plugins', pluginId) + : paths.resolveTargetRoot(pluginId); const ownerIds = parseOwnerIds(answers.owner); const { version } = isMonoRepo ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) @@ -267,7 +269,7 @@ export default async (cmd: Command) => { }); Task.section('Moving to final location'); - await movePlugin(tempDir, pluginDir, answers.id); + await movePlugin(tempDir, pluginDir, pluginId); Task.section('Building the plugin'); await buildPlugin(pluginDir); @@ -277,13 +279,13 @@ export default async (cmd: Command) => { await addPluginDependencyToApp(paths.targetRoot, name, version); Task.section('Import plugin in app'); - await addPluginToApp(paths.targetRoot, answers.id, name); + await addPluginToApp(paths.targetRoot, pluginId, name); } if (ownerIds && ownerIds.length) { await addCodeownersEntry( codeownersPath!, - `/plugins/${answers.id}`, + `/plugins/${pluginId}`, ownerIds, ); } diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 3ffa8d63cf..a5e2af5974 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -32,7 +32,6 @@ import { removePluginFromCodeOwners, } from './removePlugin'; -// Some constant variables const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; @@ -68,8 +67,8 @@ const createTestPluginFile = async ( .split('-') .map(name => capitalize(name)) .join(''); - const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - addExportStatement(testFilePath, exportStatement); + const exportStatement = `export { plugin as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + await addExportStatement(testFilePath, exportStatement); }; const mkTestPluginDir = (testDirPath: string) => { @@ -78,15 +77,21 @@ const mkTestPluginDir = (testDirPath: string) => { fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); }; -beforeAll(() => { - // Create temporary directory for all tests - createTemporaryPluginFolder(tempDir); -}); - describe('removePlugin', () => { + beforeAll(() => { + // Create temporary directory for all tests + createTemporaryPluginFolder(tempDir); + }); + + afterAll(() => { + // Remove temporary directory + fse.removeSync(tempDir); + }); + describe('Remove Plugin Dependencies', () => { const appPath = paths.resolveTargetRoot('packages', 'app'); const githubDir = paths.resolveTargetRoot('.github'); + it('removes plugin references from /packages/app/package.json', async () => { // Set up test const packageFilePath = path.join(appPath, 'package.json'); @@ -105,7 +110,8 @@ describe('removePlugin', () => { fse.removeSync(testFilePath); } }); - it('removes plugin exports from /packages/app/src/packacge.json', async () => { + + it('removes plugin exports from /packages/app/src/package.json', async () => { const testFilePath = path.join(tempDir, 'test.ts'); const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); createTestPluginFile(testFilePath, pluginsFilePaths); @@ -122,6 +128,7 @@ describe('removePlugin', () => { fse.removeSync(testFilePath); } }); + it('removes codeOwners references', async () => { const testFilePath = path.join(tempDir, 'test'); const codeownersPath = path.join(githubDir, 'CODEOWNERS'); @@ -144,12 +151,14 @@ describe('removePlugin', () => { } }); }); + describe('Remove files', () => { const testDirPath = path.join( paths.resolveTargetRoot(), 'plugins', testPluginName, ); + describe('Removes Plugin Directory', () => { it('removes plugin directory from /plugins', async () => { try { @@ -162,6 +171,7 @@ describe('removePlugin', () => { } }); }); + describe('Removes System Link', () => { it('removes system link from @backstage', async () => { const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); @@ -183,8 +193,3 @@ describe('removePlugin', () => { }); }); }); - -afterAll(() => { - // Remove temporary directory - fse.removeSync(tempDir); -}); diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 1395384022..643bd599d7 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -20,7 +20,6 @@ import inquirer, { Answers, Question } from 'inquirer'; import { getCodeownersFilePath } from '../../lib/codeowners'; import { paths } from '../../lib/paths'; import { Task } from '../../lib/tasks'; -// import os from 'os'; const BACKSTAGE = '@backstage'; diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 00a555eebe..34b9f498ad 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -111,6 +111,9 @@ export async function createConfig( app: { title: options.config.getString('app.title'), baseUrl: validBaseUrl.href, + googleAnalyticsTrackingId: options.config.getOptionalString( + 'app.googleAnalyticsTrackingId', + ), }, }, }), diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index acd6a937d9..665eb10b21 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -15,7 +15,9 @@ */ import { Options } from 'webpack'; +import TerserPlugin from 'terser-webpack-plugin'; import { BundlingOptions } from './types'; +import { isParallelDefault } from '../parallel'; export const optimization = ( options: BundlingOptions, @@ -24,6 +26,16 @@ export const optimization = ( return { minimize: !isDev, + // Only configure when parallel is explicitly overriden from the default + ...(!isParallelDefault(options.parallel) + ? { + minimizer: [ + new TerserPlugin({ + parallel: options.parallel, + }), + ], + } + : {}), runtimeChunk: 'single', splitChunks: { automaticNameDelimiter: '-', diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 03d68d9bb8..827d517fef 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -16,6 +16,7 @@ import { AppConfig, Config } from '@backstage/config'; import { BundlingPathsOptions } from './paths'; +import { ParallelOption } from '../parallel'; export type BundlingOptions = { checksEnabled: boolean; @@ -23,6 +24,7 @@ export type BundlingOptions = { config: Config; appConfigs: AppConfig[]; baseUrl: URL; + parallel?: ParallelOption; }; export type BackendBundlingOptions = Omit & { @@ -37,6 +39,7 @@ export type ServeOptions = BundlingPathsOptions & { export type BuildOptions = BundlingPathsOptions & { statsJsonEnabled: boolean; + parallel?: ParallelOption; config: Config; appConfigs: AppConfig[]; }; diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 564317e46a..56f1ee453c 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -20,10 +20,11 @@ import { resolve as resolvePath, relative as relativePath, } from 'path'; +import { tmpdir } from 'os'; +import tar, { CreateOptions } from 'tar'; import { paths } from '../paths'; import { run } from '../run'; -import tar, { CreateOptions } from 'tar'; -import { tmpdir } from 'os'; +import { ParallelOption } from '../parallel'; type LernaPackage = { name: string; @@ -58,6 +59,11 @@ type Options = { */ buildDependencies?: boolean; + /** + * Enable (true/false) or control amount of (number) parallelism in some build steps. + */ + parallel?: ParallelOption; + /** * If set, creates a skeleton tarball that contains all package.json files * with the same structure as the workspace dir. @@ -85,7 +91,12 @@ export async function createDistWorkspace( if (options.buildDependencies) { const scopeArgs = targets.flatMap(target => ['--scope', target.name]); - await run('yarn', ['lerna', 'run', ...scopeArgs, 'build'], { + const lernaArgs = + options.parallel && Number.isInteger(options.parallel) + ? ['--concurrency', options.parallel.toString()] + : []; + + await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { cwd: paths.targetRoot, }); } diff --git a/packages/cli/src/lib/parallel.test.ts b/packages/cli/src/lib/parallel.test.ts new file mode 100644 index 0000000000..8047774a6e --- /dev/null +++ b/packages/cli/src/lib/parallel.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { isParallelDefault, parseParallel } from './parallel'; + +describe('parallel', () => { + describe('parseParallel', () => { + it('coerces "false" string to boolean', () => { + expect(parseParallel('false')).toBeFalsy(); + }); + + it('coerces "true" to boolean', () => { + expect(parseParallel('true')).toBeTruthy(); + }); + + it('coerces number string to number', () => { + expect(parseParallel('2')).toBe(2); + }); + it.each([[true], [false], [2]])('returns itself for %p', value => { + expect(parseParallel(value as any)).toEqual(value); + }); + + it.each([[undefined], [null]])('returns true for %p', value => { + expect(parseParallel(value as any)).toBe(true); + }); + + it.each([['on'], [2.5], ['2.5']])('throws error for %p', value => { + expect(() => parseParallel(value as any)).toThrowError( + `Parallel option value '${value}' is not a boolean or integer`, + ); + }); + }); + + describe('isParallelDefault', () => { + it('returns true if default value', () => { + expect(isParallelDefault(undefined)).toBeTruthy(); + expect(isParallelDefault(true)).toBeTruthy(); + }); + + it('returns false if not default value', () => { + expect(isParallelDefault(false)).toBeFalsy(); + expect(isParallelDefault(2)).toBeFalsy(); + expect(isParallelDefault('true' as any)).toBeFalsy(); + }); + }); +}); diff --git a/packages/cli/src/lib/parallel.ts b/packages/cli/src/lib/parallel.ts new file mode 100644 index 0000000000..b6926115aa --- /dev/null +++ b/packages/cli/src/lib/parallel.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 const PARALLEL_ENV_VAR = 'BACKSTAGE_CLI_BUILD_PARALLEL'; + +export type ParallelOption = boolean | number | undefined; + +export function isParallelDefault(parallel: ParallelOption) { + return parallel === undefined || parallel === true; +} + +export function parseParallel( + parallel: boolean | string | number | undefined, +): ParallelOption { + if (parallel === undefined || parallel === null) { + return true; + } else if (typeof parallel === 'boolean') { + return parallel; + } else if (typeof parallel === 'number' && Number.isInteger(parallel)) { + return parallel; + } else if (typeof parallel === 'string') { + if (parallel === 'true') { + return true; + } else if (parallel === 'false') { + return false; + } else if (Number.isInteger(parseFloat(parallel.toString()))) { + return Number(parallel); + } + } + + throw Error( + `Parallel option value '${parallel}' is not a boolean or integer`, + ); +} diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts index 94136f1dbc..828819ad17 100644 --- a/packages/cli/src/types.d.ts +++ b/packages/cli/src/types.d.ts @@ -29,3 +29,5 @@ declare module '@svgr/rollup' { } declare module '@rollup/plugin-yaml'; + +declare module 'terser-webpack-plugin'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs index 79adaefaa8..e08f1650d5 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -4,7 +4,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -13,7 +12,7 @@ import { import ExampleFetchComponent from '../ExampleFetchComponent'; const ExampleComponent: FC<{}> = () => ( - +
@@ -37,5 +36,5 @@ const ExampleComponent: FC<{}> = () => ( ); - + export default ExampleComponent; diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 0a95dc8c86..eead016c8f 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -33,13 +33,13 @@ "@backstage/config": "^0.1.1-alpha.25", "fs-extra": "^9.0.0", "yaml": "^1.9.2", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { "@types/jest": "^26.0.7", "@types/mock-fs": "^4.10.0", "@types/node": "^12.0.0", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "mock-fs": "^4.13.0" }, "files": [ diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts index 4c3812d527..97f0de2940 100644 --- a/packages/config-loader/src/lib/secrets.ts +++ b/packages/config-loader/src/lib/secrets.ts @@ -58,7 +58,7 @@ const secretLoaderSchemas = { }; // The top-level secret schema, which figures out what type of secret it is. -const secretSchema = yup.lazy(value => { +const secretSchema = yup.lazy(value => { if (typeof value !== 'object' || value === null) { return yup.object().required().label('secret'); } diff --git a/packages/core-api/src/apis/definitions/AlertApi.ts b/packages/core-api/src/apis/definitions/AlertApi.ts index 123cd47651..91641ac35b 100644 --- a/packages/core-api/src/apis/definitions/AlertApi.ts +++ b/packages/core-api/src/apis/definitions/AlertApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { createApiRef, ApiRef } from '../system'; import { Observable } from '../../types'; export type AlertMessage = { @@ -38,7 +38,7 @@ export type AlertApi = { alert$(): Observable; }; -export const alertApiRef = createApiRef({ +export const alertApiRef: ApiRef = createApiRef({ id: 'core.alert', description: 'Used to report alerts and forward them to the app', }); diff --git a/packages/core-api/src/apis/definitions/AppThemeApi.ts b/packages/core-api/src/apis/definitions/AppThemeApi.ts index 3f580450f0..515e8df082 100644 --- a/packages/core-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-api/src/apis/definitions/AppThemeApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { BackstageTheme } from '@backstage/theme'; import { Observable } from '../../types'; import { SvgIconProps } from '@material-ui/core'; @@ -77,7 +77,7 @@ export type AppThemeApi = { setActiveThemeId(themeId?: string): void; }; -export const appThemeApiRef = createApiRef({ +export const appThemeApiRef: ApiRef = createApiRef({ id: 'core.apptheme', description: 'API Used to configure the app theme, and enumerate options', }); diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 1fa6e70d1f..2ce972af0e 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Config } from '@backstage/config'; // Using interface to make the ConfigApi name show up in docs export type ConfigApi = Config; -export const configApiRef = createApiRef({ +export const configApiRef: ApiRef = createApiRef({ id: 'core.config', description: 'Used to access runtime configuration', }); diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts index b0773086c7..9777835ec8 100644 --- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; /** * The discovery API is used to provide a mechanism for plugins to @@ -41,7 +41,7 @@ export type DiscoveryApi = { getBaseUrl(pluginId: string): Promise; }; -export const discoveryApiRef = createApiRef({ +export const discoveryApiRef: ApiRef = createApiRef({ id: 'core.discovery', description: 'Provides service discovery of backend plugins', }); diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index 7f9676ea5a..edd6966968 100644 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; /** @@ -62,7 +62,7 @@ export type ErrorApi = { error$(): Observable<{ error: Error; context?: ErrorContext }>; }; -export const errorApiRef = createApiRef({ +export const errorApiRef: ApiRef = createApiRef({ id: 'core.error', description: 'Used to report errors and forward them to the app', }); diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts index dd9657d7d8..6e8c8cd8fc 100644 --- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { UserFlags, FeatureFlagsRegistry } from '../../app/FeatureFlags'; import { FeatureFlagName } from '../../plugin'; @@ -57,7 +57,7 @@ export interface FeatureFlagsRegistryItem { name: FeatureFlagName; } -export const featureFlagsApiRef = createApiRef({ +export const featureFlagsApiRef: ApiRef = createApiRef({ id: 'core.featureflags', description: 'Used to toggle functionality in features across Backstage', }); diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 2684422b1e..5cfc4723e0 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { ProfileInfo } from './auth'; /** @@ -51,7 +51,7 @@ export type IdentityApi = { signOut(): Promise; }; -export const identityApiRef = createApiRef({ +export const identityApiRef: ApiRef = createApiRef({ id: 'core.identity', description: 'Provides access to the identity of the signed in user', }); diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts index 6fefbd1b9c..7514a88dc1 100644 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -16,7 +16,7 @@ import { IconComponent } from '../../icons'; import { Observable } from '../../types'; -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; /** * Information about the auth provider that we're requesting a login towards. @@ -127,7 +127,7 @@ export type OAuthRequestApi = { authRequest$(): Observable; }; -export const oauthRequestApiRef = createApiRef({ +export const oauthRequestApiRef: ApiRef = createApiRef({ id: 'core.oauthrequest', description: 'An API for implementing unified OAuth flows in Backstage', }); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 920ee56811..d0f71ea3a4 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; import { ErrorApi } from './ErrorApi'; @@ -65,7 +65,7 @@ export interface StorageApi { observe$(key: string): Observable>; } -export const storageApiRef = createApiRef({ +export const storageApiRef: ApiRef = createApiRef({ id: 'core.storage', description: 'Provides the ability to store data which is unique to the user', }); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 109f15b26e..946baeabd9 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; -import { Observable } from '../..'; +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; /** * This file contains declarations for common interfaces of auth-related APIs. @@ -212,13 +212,13 @@ export type SessionApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef = createApiRef< +export const googleAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', }); @@ -229,9 +229,9 @@ export const googleAuthApiRef = createApiRef< * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ * for a full list of supported scopes. */ -export const githubAuthApiRef = createApiRef< +export const githubAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.github', description: 'Provides authentication towards GitHub APIs', }); @@ -242,13 +242,13 @@ export const githubAuthApiRef = createApiRef< * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ * for a full list of supported scopes. */ -export const oktaAuthApiRef = createApiRef< +export const oktaAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.okta', description: 'Provides authentication towards Okta APIs', }); @@ -259,9 +259,9 @@ export const oktaAuthApiRef = createApiRef< * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token * for a full list of supported scopes. */ -export const gitlabAuthApiRef = createApiRef< +export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.gitlab', description: 'Provides authentication towards GitLab APIs', }); @@ -272,9 +272,9 @@ export const gitlabAuthApiRef = createApiRef< * See https://auth0.com/docs/scopes/current/oidc-scopes * for a full list of supported scopes. */ -export const auth0AuthApiRef = createApiRef< +export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.auth0', description: 'Provides authentication towards Auth0 APIs', }); @@ -286,13 +286,13 @@ export const auth0AuthApiRef = createApiRef< * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent * - https://docs.microsoft.com/en-us/graph/permissions-reference */ -export const microsoftAuthApiRef = createApiRef< +export const microsoftAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.microsoft', description: 'Provides authentication towards Microsoft APIs and identities', }); @@ -300,13 +300,13 @@ export const microsoftAuthApiRef = createApiRef< /** * Provides authentication for custom identity providers. */ -export const oauth2ApiRef = createApiRef< +export const oauth2ApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.oauth2', description: 'Example of how to use oauth2 custom provider', }); @@ -314,9 +314,9 @@ export const oauth2ApiRef = createApiRef< /** * Provides authentication for saml based identity providers */ -export const samlAuthApiRef = createApiRef< +export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.saml', description: 'Example of how to use SAML custom provider', }); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index 1d87ff3720..eb2f375ab0 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -41,6 +41,7 @@ type CreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; environment?: string; provider?: AuthProvider & { id: string }; }; @@ -67,6 +68,7 @@ class GithubAuth implements OAuthApi, SessionApi { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, + defaultScopes = ['read:user'], }: CreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, @@ -89,7 +91,7 @@ class GithubAuth implements OAuthApi, SessionApi { const sessionManager = new StaticAuthSessionManager({ connector, - defaultScopes: new Set(['read:user']), + defaultScopes: new Set(defaultScopes), sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index c856a13668..03569f9570 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; -export { ApiRegistry } from './ApiRegistry'; -export * from './ApiRef'; -export * from './types'; -export * from './helpers'; +export * from './system'; export * from './definitions'; export * from './implementations'; diff --git a/packages/core-api/src/apis/ApiAggregator.test.ts b/packages/core-api/src/apis/system/ApiAggregator.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiAggregator.test.ts rename to packages/core-api/src/apis/system/ApiAggregator.test.ts diff --git a/packages/core-api/src/apis/ApiAggregator.ts b/packages/core-api/src/apis/system/ApiAggregator.ts similarity index 93% rename from packages/core-api/src/apis/ApiAggregator.ts rename to packages/core-api/src/apis/system/ApiAggregator.ts index da49ee6488..1587a1d10b 100644 --- a/packages/core-api/src/apis/ApiAggregator.ts +++ b/packages/core-api/src/apis/system/ApiAggregator.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; -import { ApiHolder } from './types'; +import { ApiRef, ApiHolder } from './types'; /** * An ApiHolder that queries multiple other holders from for diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiFactoryRegistry.test.ts rename to packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts similarity index 98% rename from packages/core-api/src/apis/ApiFactoryRegistry.ts rename to packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 0d36e6c550..556f63589c 100644 --- a/packages/core-api/src/apis/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -15,12 +15,12 @@ */ import { + ApiRef, ApiFactoryHolder, ApiFactory, AnyApiRef, AnyApiFactory, } from './types'; -import { ApiRef } from './ApiRef'; type ApiFactoryScope = | 'default' // Default factories registered by core and plugins diff --git a/packages/core-api/src/apis/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx similarity index 100% rename from packages/core-api/src/apis/ApiProvider.test.tsx rename to packages/core-api/src/apis/system/ApiProvider.test.tsx diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx similarity index 96% rename from packages/core-api/src/apis/ApiProvider.tsx rename to packages/core-api/src/apis/system/ApiProvider.tsx index 24610a1372..f2aa70244e 100644 --- a/packages/core-api/src/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.tsx @@ -16,8 +16,7 @@ import React, { FC, createContext, useContext, ReactNode } from 'react'; import PropTypes from 'prop-types'; -import { ApiRef } from './ApiRef'; -import { ApiHolder, TypesToApiRefs } from './types'; +import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { ApiAggregator } from './ApiAggregator'; type ApiProviderProps = { diff --git a/packages/core-api/src/apis/ApiRef.test.ts b/packages/core-api/src/apis/system/ApiRef.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiRef.test.ts rename to packages/core-api/src/apis/system/ApiRef.test.ts diff --git a/packages/core-api/src/apis/ApiRef.ts b/packages/core-api/src/apis/system/ApiRef.ts similarity index 95% rename from packages/core-api/src/apis/ApiRef.ts rename to packages/core-api/src/apis/system/ApiRef.ts index 793c1e5386..0e1eb177c8 100644 --- a/packages/core-api/src/apis/ApiRef.ts +++ b/packages/core-api/src/apis/system/ApiRef.ts @@ -14,17 +14,13 @@ * limitations under the License. */ +import type { ApiRef } from './types'; + export type ApiRefConfig = { id: string; description: string; }; -export type ApiRef = { - id: string; - description: string; - T: T; -}; - class ApiRefImpl implements ApiRef { constructor(private readonly config: ApiRefConfig) { const valid = config.id diff --git a/packages/core-api/src/apis/ApiRegistry.test.ts b/packages/core-api/src/apis/system/ApiRegistry.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiRegistry.test.ts rename to packages/core-api/src/apis/system/ApiRegistry.test.ts diff --git a/packages/core-api/src/apis/ApiRegistry.ts b/packages/core-api/src/apis/system/ApiRegistry.ts similarity index 96% rename from packages/core-api/src/apis/ApiRegistry.ts rename to packages/core-api/src/apis/system/ApiRegistry.ts index 64d97ba234..14f58268b4 100644 --- a/packages/core-api/src/apis/ApiRegistry.ts +++ b/packages/core-api/src/apis/system/ApiRegistry.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; -import { ApiHolder } from './types'; +import { ApiRef, ApiHolder } from './types'; type ApiImpl = readonly [ApiRef, T]; diff --git a/packages/core-api/src/apis/ApiResolver.test.ts b/packages/core-api/src/apis/system/ApiResolver.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiResolver.test.ts rename to packages/core-api/src/apis/system/ApiResolver.test.ts diff --git a/packages/core-api/src/apis/ApiResolver.ts b/packages/core-api/src/apis/system/ApiResolver.ts similarity index 98% rename from packages/core-api/src/apis/ApiResolver.ts rename to packages/core-api/src/apis/system/ApiResolver.ts index cb65186a37..0dfeaeedd0 100644 --- a/packages/core-api/src/apis/ApiResolver.ts +++ b/packages/core-api/src/apis/system/ApiResolver.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; import { + ApiRef, ApiHolder, ApiFactoryHolder, AnyApiRef, diff --git a/packages/core-api/src/apis/helpers.ts b/packages/core-api/src/apis/system/helpers.ts similarity index 93% rename from packages/core-api/src/apis/helpers.ts rename to packages/core-api/src/apis/system/helpers.ts index 7d616acd01..0ccd0cdb80 100644 --- a/packages/core-api/src/apis/helpers.ts +++ b/packages/core-api/src/apis/system/helpers.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiFactory, TypesToApiRefs } from './types'; -import { ApiRef } from './ApiRef'; +import { ApiRef, ApiFactory, TypesToApiRefs } from './types'; /** * Used to infer types for a standalone ApiFactory that isn't immediately passed diff --git a/packages/core-api/src/apis/system/index.ts b/packages/core-api/src/apis/system/index.ts new file mode 100644 index 0000000000..10b2e0f084 --- /dev/null +++ b/packages/core-api/src/apis/system/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ApiProvider, useApi, useApiHolder } from './ApiProvider'; +export { ApiRegistry } from './ApiRegistry'; +export { ApiResolver } from './ApiResolver'; +export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export { createApiRef } from './ApiRef'; +export * from './types'; +export * from './helpers'; diff --git a/packages/core-api/src/apis/types.ts b/packages/core-api/src/apis/system/types.ts similarity index 94% rename from packages/core-api/src/apis/types.ts rename to packages/core-api/src/apis/system/types.ts index 61c229b18e..b0c19e551c 100644 --- a/packages/core-api/src/apis/types.ts +++ b/packages/core-api/src/apis/system/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; +export type ApiRef = { + id: string; + description: string; + T: T; +}; export type AnyApiRef = ApiRef; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index c222c3c420..0588cd819c 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -54,8 +54,7 @@ import { } from '../apis'; import { useAsync } from 'react-use'; import { AppIdentity } from './AppIdentity'; -import { ApiFactoryRegistry } from '../apis/ApiFactoryRegistry'; -import { ApiResolver } from '../apis/ApiResolver'; +import { ApiResolver, ApiFactoryRegistry } from '../apis/system'; type FullAppOptions = { apis: Iterable; diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 12992f3620..dacb3668bc 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -16,7 +16,7 @@ import { ComponentType } from 'react'; import { RouteRef } from '../routing'; -import { AnyApiFactory } from '../apis'; +import { AnyApiFactory } from '../apis/system'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index dedadf0456..e735e4d371 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -96,7 +96,11 @@ export const defaultApis = [ oauthRequestApi: oauthRequestApiRef, }, factory: ({ discoveryApi, oauthRequestApi }) => - GithubAuth.create({ discoveryApi, oauthRequestApi }), + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + }), }), createApiFactory({ api: oktaAuthApiRef, diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx index 1a76f84f44..466abe6012 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.test.tsx @@ -20,10 +20,10 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { CodeSnippet } from './CodeSnippet'; -const JAVASCRIPT = `const greeting = "Hello"; -const world = "World"; - -const greet = person => gretting + " " + person + "!"; +const JAVASCRIPT = ` + const greeting = "Hello"; + const world = "World"; + const greet = person => gretting + " " + person + "!"; `; const minProps = { @@ -48,20 +48,20 @@ describe('', () => { }); it('renders with line numbers', () => { - const { queryByText } = render( + const { getByText } = render( wrapInTestApp(), ); - expect(queryByText(/1/)).toBeInTheDocument(); - expect(queryByText(/2/)).toBeInTheDocument(); - expect(queryByText(/3/)).toBeInTheDocument(); + expect(getByText('1')).toBeInTheDocument(); + expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); }); it('copy code using button', async () => { document.execCommand = jest.fn(); - const rendered = render( + const { getByTitle } = render( wrapInTestApp(), ); - const button = rendered.getByTitle('Text copied to clipboard'); + const button = getByTitle('Text copied to clipboard'); fireEvent.click(button); expect(document.execCommand).toHaveBeenCalled(); }); diff --git a/packages/core/src/components/EmptyState/EmptyState.tsx b/packages/core/src/components/EmptyState/EmptyState.tsx index 1af5ff6dd5..fdd9738735 100644 --- a/packages/core/src/components/EmptyState/EmptyState.tsx +++ b/packages/core/src/components/EmptyState/EmptyState.tsx @@ -33,7 +33,7 @@ const useStyles = makeStyles(theme => ({ type Props = { title: string; - description?: string; + description?: string | JSX.Element; missing: 'field' | 'info' | 'content' | 'data'; action?: JSX.Element; }; diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx index d8847fdf47..1c27ba27d3 100644 --- a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -48,11 +48,17 @@ const useStyles = makeStyles(theme => ({ export const MissingAnnotationEmptyState = ({ annotation }: Props) => { const classes = useStyles(); + const description = ( + + The {annotation} annotation is missing. You need to add the + annotation to your component if you want to enable this tool. + + ); return ( diff --git a/packages/core/src/components/Progress/Progress.test.tsx b/packages/core/src/components/Progress/Progress.test.tsx new file mode 100644 index 0000000000..46a162e8b3 --- /dev/null +++ b/packages/core/src/components/Progress/Progress.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { renderInTestApp } from '@backstage/test-utils'; +import { act } from 'react-dom/test-utils'; + +import { Progress } from './Progress'; + +describe('', () => { + it('renders without exploding', async () => { + jest.useFakeTimers(); + const { getByTestId, queryByTestId } = await renderInTestApp(); + expect(queryByTestId('progress')).not.toBeInTheDocument(); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(getByTestId('progress')).toBeInTheDocument(); + jest.useRealTimers(); + }); +}); diff --git a/packages/core/src/components/Progress/Progress.tsx b/packages/core/src/components/Progress/Progress.tsx index e66220e4e7..80f4f38cc9 100644 --- a/packages/core/src/components/Progress/Progress.tsx +++ b/packages/core/src/components/Progress/Progress.tsx @@ -28,6 +28,6 @@ export const Progress: FC = props => { return isVisible ? ( ) : ( -
+
); }; diff --git a/packages/core/src/components/Select/Select.tsx b/packages/core/src/components/Select/Select.tsx index a2efcc7725..1c691340b2 100644 --- a/packages/core/src/components/Select/Select.tsx +++ b/packages/core/src/components/Select/Select.tsx @@ -14,25 +14,23 @@ * limitations under the License. */ -import React, { useEffect, useState } from 'react'; +import { + Checkbox, + Chip, + ClickAwayListener, + FormControl, + InputBase, + MenuItem, + Select, + Typography, +} from '@material-ui/core'; import { createStyles, makeStyles, - withStyles, Theme, + withStyles, } from '@material-ui/core/styles'; - -import { - FormControl, - Select, - MenuItem, - InputBase, - Chip, - Typography, - Checkbox, - ClickAwayListener, -} from '@material-ui/core'; - +import React, { useEffect, useState } from 'react'; import ClosedDropdown from './static/ClosedDropdown'; import OpenedDropdown from './static/OpenedDropdown'; @@ -111,7 +109,7 @@ export const SelectComponent = (props: SelectProps) => { const [value, setValue] = useState( multiple ? [] : '', ); - const [canOpen, setCanOpen] = React.useState(false); + const [isOpen, setOpen] = useState(false); useEffect(() => { setValue(multiple ? [] : ''); @@ -122,19 +120,17 @@ export const SelectComponent = (props: SelectProps) => { onChange(event.target.value); }; - const selectHandleOnOpen = () => { - setCanOpen(previous => { - if (multiple) { + const handleClick = (event: React.ChangeEvent) => { + setOpen(previous => { + if (multiple && !(event.target instanceof HTMLElement)) { return true; } return !previous; }); }; - const handleClickAway = (event: React.ChangeEvent) => { - if (event.target.id !== 'menu-item') { - setCanOpen(false); - } + const handleClickAway = () => { + setOpen(false); }; const handleDelete = (selectedValue: string | number) => () => { @@ -154,8 +150,8 @@ export const SelectComponent = (props: SelectProps) => { displayEmpty multiple={multiple} onChange={handleChange} - onClick={selectHandleOnOpen} - open={canOpen} + onClick={handleClick} + open={isOpen} input={} renderValue={selected => multiple && (value as any[]).length !== 0 ? ( @@ -181,7 +177,7 @@ export const SelectComponent = (props: SelectProps) => { ) } IconComponent={() => - !canOpen ? : + !isOpen ? : } MenuProps={{ anchorOrigin: { @@ -200,7 +196,7 @@ export const SelectComponent = (props: SelectProps) => { )} {items && items.map(item => ( - + {multiple && ( ', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status ok')).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status warning')).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status error')).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status pending')).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status running')).toBeInTheDocument(); + }); +}); + +describe('', () => { + it('renders without exploding', async () => { + const { getByLabelText } = await renderInTestApp(); + expect(getByLabelText('Status aborted')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Status/Status.tsx b/packages/core/src/components/Status/Status.tsx index 9a5ce5b218..6a2e753f1f 100644 --- a/packages/core/src/components/Status/Status.tsx +++ b/packages/core/src/components/Status/Status.tsx @@ -65,20 +65,34 @@ const useStyles = makeStyles(theme => ({ export const StatusOK: FC<{}> = props => { const classes = useStyles(props); - return ; + return ( + + ); }; export const StatusWarning: FC<{}> = props => { const classes = useStyles(props); return ( - + ); }; export const StatusError: FC<{}> = props => { const classes = useStyles(props); return ( - + ); }; diff --git a/packages/core/src/components/Table/Filters.tsx b/packages/core/src/components/Table/Filters.tsx index c8f468559a..fdff04e478 100644 --- a/packages/core/src/components/Table/Filters.tsx +++ b/packages/core/src/components/Table/Filters.tsx @@ -50,7 +50,7 @@ const useSubvalueCellStyles = makeStyles(theme => ({ }, })); -type Without = Pick>; +export type Without = Pick>; export type Filter = { type: 'select' | 'checkbox-tree' | 'multiple-select'; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 5d0b7d8344..87ad50e58f 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -45,7 +45,9 @@ import MTable, { Options, } from 'material-table'; import React, { forwardRef, useCallback, useEffect, useState } from 'react'; -import { Filters, SelectedFilters } from './Filters'; +import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree'; +import { SelectProps } from '../Select/Select'; +import { Filter, Filters, SelectedFilters, Without } from './Filters'; const tableIcons = { Add: forwardRef((props, ref: React.Ref) => ( @@ -101,6 +103,23 @@ const tableIcons = { )), }; +// TODO: Material table might already have such a function internally that we can use? +function extractValueByField(data: any, field: string): any | undefined { + const path = field.split('.'); + let value = data[path[0]]; + + for (let i = 1; i < path.length; ++i) { + if (value === undefined) { + return value; + } + + const f = path[i]; + value = value[f]; + } + + return value; +} + const useHeaderStyles = makeStyles(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), @@ -234,11 +253,21 @@ export function Table({ el => !!Object.entries(selectedFilters) .filter(([, value]) => !!value.length) - .every(([key, value]) => { - if (Array.isArray(value)) { - return value.includes(el[getFieldByTitle(key)]); + .every(([key, filterValue]) => { + const fieldValue = extractValueByField( + el, + getFieldByTitle(key) as string, + ); + + if (Array.isArray(fieldValue) && Array.isArray(filterValue)) { + return fieldValue.some(v => filterValue.includes(v)); + } else if (Array.isArray(fieldValue)) { + return fieldValue.includes(filterValue); + } else if (Array.isArray(filterValue)) { + return filterValue.includes(fieldValue); } - return el[getFieldByTitle(key)] === value; + + return fieldValue === filterValue; }), ); setTableData(newData); @@ -248,29 +277,61 @@ export function Table({ setSelectedFiltersLength(selectedFiltersArray.flat().length); }, [data, selectedFilters, getFieldByTitle]); - const constructFilters = (filterConfig: TableFilter[], dataValue: any[]) => { - const extractColumnData = (column: string | keyof T) => - dataValue.map(el => ({ label: el[column], options: [] })); + const constructFilters = ( + filterConfig: TableFilter[], + dataValue: any[], + ): Filter[] => { + const extractDistinctValues = (field: string | keyof T): Set => { + const distinctValues = new Set(); + const addValue = (value: any) => { + if (value !== undefined && value !== null) { + distinctValues.add(value); + } + }; + + dataValue.forEach(el => { + const value = extractValueByField(el, getFieldByTitle(field) as string); + + if (Array.isArray(value)) { + (value as []).forEach(addValue); + } else { + addValue(value); + } + }); + + return distinctValues; + }; + + const constructCheckboxTree = ( + filter: TableFilter, + ): Without => ({ + label: filter.column, + subCategories: [...extractDistinctValues(filter.column)].map(v => ({ + label: v, + options: [], + })), + }); + + const constructSelect = ( + filter: TableFilter, + ): Without => { + return { + placeholder: 'All results', + label: filter.column, + multiple: filter.type === 'multiple-select', + items: [...extractDistinctValues(filter.column)].map(value => ({ + label: value, + value, + })), + }; + }; return filterConfig.map(filter => ({ type: filter.type, element: filter.type === 'checkbox-tree' - ? { - label: filter.column, - subCategories: extractColumnData( - getFieldByTitle(filter.column) || '', - ), - } - : { - placeholder: 'All results', - label: filter.column, - multiple: filter.type === 'multiple-select', - items: dataValue.map(el => ({ - label: el[getFieldByTitle(filter.column) || ''], - value: el[getFieldByTitle(filter.column) || ''], - })), - }, + ? constructCheckboxTree(filter) + : constructSelect(filter), })); }; diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index c10399dc59..9cb8478688 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export { Table } from './Table'; -export type { TableColumn, TableProps } from './Table'; export { SubvalueCell } from './SubvalueCell'; +export { Table } from './Table'; +export type { TableColumn, TableFilter, TableProps } from './Table'; diff --git a/packages/core/src/components/TrendLine/TrendLine.stories.tsx b/packages/core/src/components/TrendLine/TrendLine.stories.tsx index 4eb74b2f17..5d2972095a 100644 --- a/packages/core/src/components/TrendLine/TrendLine.stories.tsx +++ b/packages/core/src/components/TrendLine/TrendLine.stories.tsx @@ -67,7 +67,7 @@ const columns = [ export const Default = () => (
- + ( - +
{labels}
@@ -46,7 +46,7 @@ export const HomeWithSubtitle = () => ( ); export const Tool = () => ( - +
{labels}
@@ -54,7 +54,7 @@ export const Tool = () => ( ); export const Service = () => ( - +
{labels}
@@ -62,7 +62,7 @@ export const Service = () => ( ); export const Website = () => ( - +
{labels}
@@ -70,7 +70,7 @@ export const Website = () => ( ); export const Library = () => ( - +
{labels}
@@ -78,7 +78,7 @@ export const Library = () => ( ); export const App = () => ( - +
{labels}
@@ -86,7 +86,7 @@ export const App = () => ( ); export const Documentation = () => ( - +
{labels}
@@ -94,7 +94,7 @@ export const Documentation = () => ( ); export const Other = () => ( - +
{labels}
diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index 3bd95d0ef3..ec4a3db6e2 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode, CSSProperties, FC, useContext } from 'react'; +import React, { ReactNode, CSSProperties, FC } from 'react'; import { Helmet } from 'react-helmet'; import { Link, @@ -25,73 +25,70 @@ import { } from '@material-ui/core'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import { BackstageTheme } from '@backstage/theme'; -import { PageThemeContext } from '../Page/Page'; -const useStyles = makeStyles( - theme => ({ - header: { - gridArea: 'pageHeader', - padding: theme.spacing(3), - minHeight: 118, - width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', - position: 'relative', - zIndex: 100, - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'flex-end', - alignItems: 'center', - backgroundImage: props => props.backgroundImage, - backgroundPosition: 'center', - backgroundSize: 'cover', - }, - leftItemsBox: { - flex: '1 1 auto', - }, - rightItemsBox: { - flex: '0 1 auto', - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - marginRight: theme.spacing(1), - }, - title: { - color: theme.palette.bursts.fontColor, - lineHeight: '1.0em', - wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', - marginBottom: theme.spacing(1), - }, - subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', - }, - type: { - textTransform: 'uppercase', - fontSize: 11, - opacity: 0.8, - marginBottom: theme.spacing(1), - color: theme.palette.bursts.fontColor, - }, - breadcrumb: { - fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', - color: theme.palette.bursts.fontColor, - }, - breadcrumbType: { - fontSize: 'inherit', - opacity: 0.7, - marginRight: -theme.spacing(0.3), - marginBottom: theme.spacing(0.3), - }, - breadcrumbTitle: { - fontSize: 'inherit', - marginLeft: -theme.spacing(0.3), - marginBottom: theme.spacing(0.3), - }, - }), -); +const useStyles = makeStyles(theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(3), + minHeight: 118, + width: '100%', + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + position: 'relative', + zIndex: 100, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + backgroundImage: theme.page.backgroundImage, + backgroundPosition: 'center', + backgroundSize: 'cover', + }, + leftItemsBox: { + flex: '1 1 auto', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + marginRight: theme.spacing(1), + }, + title: { + color: theme.palette.bursts.fontColor, + lineHeight: '1.0em', + wordBreak: 'break-all', + fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + marginBottom: theme.spacing(1), + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '1.0em', + }, + type: { + textTransform: 'uppercase', + fontSize: 11, + opacity: 0.8, + marginBottom: theme.spacing(1), + color: theme.palette.bursts.fontColor, + }, + breadcrumb: { + fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', + color: theme.palette.bursts.fontColor, + }, + breadcrumbType: { + fontSize: 'inherit', + opacity: 0.7, + marginRight: -theme.spacing(0.3), + marginBottom: theme.spacing(0.3), + }, + breadcrumbTitle: { + fontSize: 'inherit', + marginLeft: -theme.spacing(0.3), + marginBottom: theme.spacing(0.3), + }, +})); type HeaderStyles = ReturnType; @@ -200,8 +197,7 @@ export const Header: FC = ({ type, typeLink, }) => { - const theme = useContext(PageThemeContext); - const classes = useStyles({ backgroundImage: theme.backgroundImage }); + const classes = useStyles(); const documentTitle = pageTitleOverride || title; const pageTitle = title || pageTitleOverride; const titleTemplate = `${documentTitle} | %s | Backstage`; diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index db73939fe7..2d425026a5 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -20,8 +20,7 @@ const useStyles = makeStyles(theme => ({ header: { color: theme.palette.common.white, padding: theme.spacing(2, 2, 6), - backgroundImage: - 'linear-gradient(-137deg, rgb(25, 230, 140) 0%, rgb(29, 127, 110) 100%)', + backgroundImage: 'linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)', }, content: { padding: theme.spacing(2), diff --git a/packages/core/src/layout/Page/Page.stories.tsx b/packages/core/src/layout/Page/Page.stories.tsx index fd2ddf3153..b3a75c19e9 100644 --- a/packages/core/src/layout/Page/Page.stories.tsx +++ b/packages/core/src/layout/Page/Page.stories.tsx @@ -21,7 +21,6 @@ import { HeaderLabel, ContentHeader, Content, - pageTheme, InfoCard, HeaderTabs, } from '../'; @@ -196,7 +195,7 @@ export const PluginWithData = () => { const [selectedTab, setSelectedTab] = useState(2); return (
- + { export const PluginWithTable = () => { return (
- + diff --git a/packages/core/src/layout/Page/Page.tsx b/packages/core/src/layout/Page/Page.tsx index a5f5e4fd2b..a14f772334 100644 --- a/packages/core/src/layout/Page/Page.tsx +++ b/packages/core/src/layout/Page/Page.tsx @@ -15,10 +15,8 @@ */ import React, { FC } from 'react'; -import { PageTheme, pageTheme } from './PageThemeProvider'; -import { makeStyles } from '@material-ui/core'; - -export const PageThemeContext = React.createContext(pageTheme.home); +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles, ThemeProvider } from '@material-ui/core'; const useStyles = makeStyles(() => ({ root: { @@ -32,14 +30,19 @@ const useStyles = makeStyles(() => ({ })); type Props = { - theme?: PageTheme; + themeId: string; }; -export const Page: FC = ({ theme = pageTheme.home, children }) => { +export const Page: FC = ({ themeId, children }) => { const classes = useStyles(); return ( - + ({ + ...baseTheme, + page: baseTheme.getPageTheme({ themeId }), + })} + >
{children}
-
+ ); }; diff --git a/packages/core/src/layout/Page/index.ts b/packages/core/src/layout/Page/index.ts index 000a565c83..987aee5cdb 100644 --- a/packages/core/src/layout/Page/index.ts +++ b/packages/core/src/layout/Page/index.ts @@ -15,5 +15,3 @@ */ export { Page } from './Page'; -export { pageTheme } from './PageThemeProvider'; -export type { PageTheme } from './PageThemeProvider'; diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 5a476e2209..2eca89f873 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -40,7 +40,7 @@ const useStyles = makeStyles(theme => { return { root: { - color: '#b5b5b5', + color: theme.palette.navigation.color, display: 'flex', flexFlow: 'row nowrap', alignItems: 'center', @@ -96,7 +96,7 @@ const useStyles = makeStyles(theme => { selected: { '&$root': { borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, - color: '#ffffff', + color: theme.palette.navigation.selectedColor, }, '&$closed': { width: drawerWidthClosed - selectedIndicatorWidth, diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 8a1ff68250..ebc9bf278f 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -52,7 +52,7 @@ export const SignInPage: FC = ({ } return ( - +
{title && } diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 7175ea0e4f..b92cdf22bd 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -3,7 +3,7 @@ app: baseUrl: http://localhost:7000 organization: - name: Acme Corporation + name: My Company backend: baseUrl: http://localhost:7000 @@ -42,7 +42,7 @@ proxy: techdocs: storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/docs + requestUrl: http://localhost:7000/api/techdocs generators: techdocs: 'docker' diff --git a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js index dde26b7b39..efcd5b8d93 100644 --- a/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js +++ b/packages/create-app/templates/default-app/packages/app/cypress/integration/app.js @@ -1,6 +1,6 @@ describe('App', () => { it('should render the catalog', () => { cy.visit('/'); - cy.contains('Backstage Service Catalog'); + cy.contains('My Company Service Catalog'); }); }); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index aee38a5fa0..10ac116ded 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -1,44 +1,28 @@ +import { useHotCleanup } from '@backstage/backend-common'; import { + CatalogBuilder, createRouter, - DatabaseEntitiesCatalog, - DatabaseLocationsCatalog, - DatabaseManager, - HigherOrderOperations, - LocationReaders, runPeriodically, } from '@backstage/plugin-catalog-backend'; import { PluginEnvironment } from '../types'; -import { useHotCleanup } from '@backstage/backend-common'; -export default async function createPlugin({ - logger, - config, - reader, - database, -}: PluginEnvironment) { - const locationReader = new LocationReaders({ logger, reader, config }); - - const db = await DatabaseManager.createDatabase(await database.getClient(), - { logger }, - ); - const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); - const higherOrderOperation = new HigherOrderOperations( +export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { entitiesCatalog, locationsCatalog, - locationReader, - logger, - ); + higherOrderOperation, + } = await builder.build(); useHotCleanup( module, - runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000), + runPeriodically(() => higherOrderOperation.refreshAllLocations(), 100000), ); return await createRouter({ entitiesCatalog, locationsCatalog, higherOrderOperation, - logger, + logger: env.logger, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index ed16d766f1..240309fbd1 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -48,7 +48,8 @@ export default async function createPlugin({ ) as RepoVisibilityOptions; const githubToken = githubConfig.getString('token'); - const githubClient = new Octokit({ auth: githubToken }); + const githubHost = githubConfig.getOptionalString('host'); + const githubClient = new Octokit({ auth: githubToken, baseUrl: githubHost }); const githubPublisher = new GithubPublisher({ client: githubClient, token: githubToken, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index b522992a75..5506228962 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -1,7 +1,7 @@ import { createRouter, DirectoryPreparer, - GithubPreparer, + CommonGitPreparer, Preparers, Generators, LocalPublish, @@ -22,10 +22,11 @@ export default async function createPlugin({ const preparers = new Preparers(); const directoryPreparer = new DirectoryPreparer(logger); - const githubPreparer = new GithubPreparer(logger); + const commonGitPreparer = new CommonGitPreparer(logger); preparers.register('dir', directoryPreparer); - preparers.register('github', githubPreparer); + preparers.register('github', commonGitPreparer); + preparers.register('gitlab', commonGitPreparer); const publisher = new LocalPublish(logger); diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 61efc0bbd4..7c0bf846bd 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -31,7 +31,7 @@ "fs-extra": "^9.0.0", "github-slugger": "^1.3.0", "ts-node": "^8.6.2", - "typescript": "^3.9.3" + "typescript": "^4.0.3" }, "devDependencies": { "@types/fs-extra": "^9.0.1", diff --git a/packages/docgen/src/index.ts b/packages/docgen/src/index.ts index f41629e7fd..4b69fdbe01 100644 --- a/packages/docgen/src/index.ts +++ b/packages/docgen/src/index.ts @@ -49,10 +49,6 @@ const main = (argv: string[]) => { process.exit(1); }); - if (!process.argv.slice(2).length) { - program.outputHelp(chalk.yellow); - } - program.parse(argv); }; diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 3794db96b1..905cb20d52 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -56,7 +56,7 @@ export async function run() { const pluginName = await createPlugin('test-plugin', appDir); print('Creating a Backstage Backend Plugin'); - await createPlugin('test-backend-plugin', appDir, ['--backend']); + await createPlugin('test-plugin', appDir, ['--backend']); print('Starting the app'); await testAppServe(pluginName, appDir); @@ -265,13 +265,18 @@ async function createPlugin( print('Waiting for plugin create script to be done'); await waitForExit(child); - const pluginDir = resolvePath(appDir, 'plugins', pluginName); + const canonicalName = options.includes('--backend') + ? `${pluginName}-backend` + : pluginName; + + const pluginDir = resolvePath(appDir, 'plugins', canonicalName); + for (const cmd of [['tsc'], ['lint'], ['test', '--no-watch']]) { print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`); await runPlain(['yarn', ...cmd], { cwd: pluginDir }); } - return pluginName; + return canonicalName; } finally { child.kill(); } @@ -296,7 +301,7 @@ async function testAppServe(pluginName: string, appDir: string) { try { const browser = new Browser(); - await waitForPageWithText(browser, '/', 'Backstage Service Catalog'); + await waitForPageWithText(browser, '/', 'My Company Service Catalog'); await waitForPageWithText( browser, `/${pluginName}`, diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index adce3dd101..30ca6e4538 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -28,11 +28,7 @@ "techdocs-cli": "bin/techdocs-cli" }, "devDependencies": { - "@spotify/eslint-config": "^7.0.0", - "@spotify/prettier-config": "^7.0.0", - "@types/serve-handler": "^6.1.0", - "eslint": "^7.1.0", - "eslint-plugin-import": "^2.22.0" + "@types/serve-handler": "^6.1.0" }, "files": [ "bin", diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 00a485bb41..b0797c6506 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -23,6 +23,7 @@ import { BackstageThemeOptions, SimpleThemeOptions, } from './types'; +import { pageTheme as defaultPageThemes } from './pageTheme'; const DEFAULT_FONT_FAMILY = '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif'; @@ -30,7 +31,16 @@ const DEFAULT_FONT_FAMILY = export function createThemeOptions( options: SimpleThemeOptions, ): BackstageThemeOptions { - const { palette, fontFamily = DEFAULT_FONT_FAMILY } = options; + const { + palette, + fontFamily = DEFAULT_FONT_FAMILY, + defaultPageTheme, + pageTheme = defaultPageThemes, + } = options; + + if (!pageTheme[defaultPageTheme]) { + throw new Error(`${defaultPageTheme} is not defined in pageTheme.`); + } return { palette, @@ -68,6 +78,9 @@ export function createThemeOptions( marginBottom: 10, }, }, + page: pageTheme[defaultPageTheme], + getPageTheme: ({ themeId }) => + pageTheme[themeId] ?? pageTheme[defaultPageTheme], }; } diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 862f9b7755..cafad3fc7c 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -17,3 +17,4 @@ export * from './themes'; export * from './baseTheme'; export * from './types'; +export * from './pageTheme'; diff --git a/packages/core/src/layout/Page/PageThemeProvider.ts b/packages/theme/src/pageTheme.ts similarity index 92% rename from packages/core/src/layout/Page/PageThemeProvider.ts rename to packages/theme/src/pageTheme.ts index 57411c8786..f86f3a6449 100644 --- a/packages/core/src/layout/Page/PageThemeProvider.ts +++ b/packages/theme/src/pageTheme.ts @@ -13,12 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export type PageTheme = { - colors: string[]; - shape: string; - backgroundImage: string; -}; +import { PageTheme } from './types'; /* # How to add a shape @@ -29,7 +24,6 @@ export type PageTheme = { with something like https://npm.runkit.com/mini-svg-data-uri 4. Wrap the output in `url("")` 5. Give it a name and paste it into the `shapes` object below. - */ export const shapes: Record = { wave: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='1368' height='400' fill='none'%3e%3cmask id='a' width='1368' height='401' x='0' y='0' maskUnits='userSpaceOnUse'%3e%3cpath fill='url(%23paint0_linear)' d='M437 116C223 116 112 0 112 0h1256v400c-82 0-225-21-282-109-112-175-436-175-649-175z'/%3e%3cpath fill='url(%23paint1_linear)' d='M1368 400V282C891-29 788 40 711 161 608 324 121 372 0 361v39h1368z'/%3e%3cpath fill='url(%23paint2_linear)' d='M1368 244v156H0V94c92-24 198-46 375 0l135 41c176 51 195 109 858 109z'/%3e%3cpath fill='url(%23paint3_linear)' d='M1252 400h116c-14-7-35-14-116-16-663-14-837-128-1013-258l-85-61C98 28 46 8 0 0v400h1252z'/%3e%3c/mask%3e%3cg mask='url(%23a)'%3e%3cpath fill='white' d='M-172-98h1671v601H-172z'/%3e%3c/g%3e%3cdefs%3e%3clinearGradient id='paint0_linear' x1='602' x2='1093.5' y1='-960.5' y2='272' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint1_linear' x1='482' x2='480' y1='1058.5' y2='70.5' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint2_linear' x1='424' x2='446.1' y1='-587.5' y2='274.6' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3clinearGradient id='paint3_linear' x1='587' x2='349' y1='-1120.5' y2='341' gradientUnits='userSpaceOnUse'%3e%3cstop stop-color='white'/%3e%3cstop offset='1' stop-color='white' stop-opacity='0'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e")`, @@ -49,24 +43,24 @@ export const colorVariants: Record = { pinkSea: ['#C8077A', '#C2297D'], }; -export const pageTheme: Record = { - home: genTheme(colorVariants.teal, shapes.wave), - documentation: genTheme(colorVariants.pinkSea, shapes.wave2), - tool: genTheme(colorVariants.purpleSky, shapes.round), - service: genTheme(colorVariants.marineBlue, shapes.wave), - website: genTheme(colorVariants.veryBlue, shapes.wave), - library: genTheme(colorVariants.rubyRed, shapes.wave), - other: genTheme(colorVariants.darkGrey, shapes.wave), - app: genTheme(colorVariants.toastyOrange, shapes.wave), -}; - // As the background shapes and colors are decorative, we place them onto // the page as a css background-image instead of an html element of its own. // Utility to not have to write colors and shapes twice. -function genTheme(colors: string[], shape: string) { +export function genPageTheme(colors: string[], shape: string): PageTheme { const gradientColors = colors.length === 1 ? [colors[0], colors[0]] : colors; const gradient = `linear-gradient(90deg, ${gradientColors.join(', ')})`; const backgroundImage = `${shape}, ${gradient}`; return { colors, shape, backgroundImage }; } + +export const pageTheme: Record = { + home: genPageTheme(colorVariants.teal, shapes.wave), + documentation: genPageTheme(colorVariants.pinkSea, shapes.wave2), + tool: genPageTheme(colorVariants.purpleSky, shapes.round), + service: genPageTheme(colorVariants.marineBlue, shapes.wave), + website: genPageTheme(colorVariants.veryBlue, shapes.wave), + library: genPageTheme(colorVariants.rubyRed, shapes.wave), + other: genPageTheme(colorVariants.darkGrey, shapes.wave), + app: genPageTheme(colorVariants.toastyOrange, shapes.wave), +}; diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 0ec3304ac5..5b233ac303 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -15,6 +15,7 @@ */ import { createTheme } from './baseTheme'; +import { pageTheme } from './pageTheme'; import { yellow } from '@material-ui/core/colors'; export const lightTheme = createTheme({ @@ -64,6 +65,8 @@ export const lightTheme = createTheme({ navigation: { background: '#171717', indicator: '#9BF0E1', + color: '#b5b5b5', + selectedColor: '#FFF', }, pinSidebarButton: { icon: '#181818', @@ -73,6 +76,8 @@ export const lightTheme = createTheme({ indicator: '#9BF0E1', }, }, + defaultPageTheme: 'home', + pageTheme, }); export const darkTheme = createTheme({ @@ -122,6 +127,8 @@ export const darkTheme = createTheme({ navigation: { background: '#424242', indicator: '#9BF0E1', + color: '#b5b5b5', + selectedColor: '#FFF', }, pinSidebarButton: { icon: '#404040', @@ -131,4 +138,6 @@ export const darkTheme = createTheme({ indicator: '#9BF0E1', }, }, + defaultPageTheme: 'home', + pageTheme, }); diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 1d244f8cd4..5acc0f75e9 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -46,6 +46,8 @@ type PaletteAdditions = { navigation: { background: string; indicator: string; + color: string; + selectedColor: string; }; tabbar: { indicator: string; @@ -72,12 +74,20 @@ type PaletteAdditions = { export type BackstagePalette = Palette & PaletteAdditions; export type BackstagePaletteOptions = PaletteOptions & PaletteAdditions; +export type PageThemeSelector = { + themeId: string; +}; + export interface BackstageTheme extends Theme { palette: BackstagePalette; + page: PageTheme; + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; } export interface BackstageThemeOptions extends ThemeOptions { palette: BackstagePaletteOptions; + page: PageTheme; + getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme; } /** @@ -85,5 +95,13 @@ export interface BackstageThemeOptions extends ThemeOptions { */ export type SimpleThemeOptions = { palette: BackstagePaletteOptions; + defaultPageTheme: string; + pageTheme?: Record; fontFamily?: string; }; + +export type PageTheme = { + colors: string[]; + shape: string; + backgroundImage: string; +}; diff --git a/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx b/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx new file mode 100644 index 0000000000..9160aca514 --- /dev/null +++ b/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/MissingImplementsApisEmptyState.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Button, makeStyles, Typography } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { CodeSnippet, EmptyState } from '@backstage/core'; + +const COMPONENT_YAML = `# Example +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example +spec: + type: service + lifecycle: production + owner: guest + implementsApis: + - example-api +`; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: `${theme.spacing(2)}px 0px`, + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, +})); + +export const MissingImplementsApisEmptyState = () => { + const classes = useStyles(); + return ( + + Components can implement APIs that are displayed on this page. You + need to fill the implementsApis field to enable this + tool. + + } + action={ + <> + + Link an API to your component as shown in the highlighted example + below: + +
+ +
+ + + } + /> + ); +}; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts similarity index 87% rename from plugins/cost-insights/src/components/CostOverviewChart/index.ts rename to plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts index 21f345a282..1b7d35c0a2 100644 --- a/plugins/cost-insights/src/components/CostOverviewChart/index.ts +++ b/plugins/api-docs/src/catalog/MissingImplementsApisEmptyState/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './CostOverviewChart'; +export { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; diff --git a/plugins/api-docs/src/catalog/Router.tsx b/plugins/api-docs/src/catalog/Router.tsx index 71640954b3..d3fe7f7553 100644 --- a/plugins/api-docs/src/catalog/Router.tsx +++ b/plugins/api-docs/src/catalog/Router.tsx @@ -17,20 +17,17 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { Route, Routes } from 'react-router'; -import { WarningPanel } from '@backstage/core'; import { catalogRoute } from '../routes'; import { EntityPageApi } from './EntityPageApi'; +import { MissingImplementsApisEmptyState } from './MissingImplementsApisEmptyState'; const isPluginApplicableToEntity = (entity: Entity) => { return ((entity.spec?.implementsApis as string[]) || []).length > 0; }; export const Router = ({ entity }: { entity: Entity }) => - // TODO(shmidt-i): move warning to a separate standardized component !isPluginApplicableToEntity(entity) ? ( - - The entity doesn't implement any APIs. - + ) : ( { const actual = jest.requireActual('react-router-dom'); @@ -71,32 +69,3 @@ describe('ApiEntityPage', () => { ); }); }); - -describe('getPageTheme', () => { - const defaultPageTheme = getPageTheme(); - it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])( - 'should select right theme for predefined type: %pย ฬฐย ', - type => { - const theme = getPageTheme(({ - spec: { - type, - }, - } as any) as Entity); - expect(theme).toBeDefined(); - expect(theme).not.toBe(defaultPageTheme); - }, - ); - - it('should select default theme for unknown/unspecified types', () => { - const theme1 = getPageTheme(({ - spec: { - type: 'unknown-type', - }, - } as any) as Entity); - const theme2 = getPageTheme(({ - spec: {}, - } as any) as Entity); - expect(theme1).toBe(defaultPageTheme); - expect(theme2).toBe(defaultPageTheme); - }); -}); diff --git a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx index b7e9996cc0..f755705ac7 100644 --- a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx +++ b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx @@ -20,8 +20,6 @@ import { errorApiRef, Header, Page, - pageTheme, - PageTheme, Progress, useApi, } from '@backstage/core'; @@ -54,11 +52,6 @@ function headerProps( }; } -export const getPageTheme = (entity?: Entity): PageTheme => { - const themeKey = entity?.spec?.type?.toString() ?? 'home'; - return pageTheme[themeKey] ?? pageTheme.home; -}; - type EntityPageTitleProps = { title: string; entity: Entity | undefined; @@ -107,7 +100,7 @@ export const ApiEntityPage = () => { ); return ( - +
} pageTitleOverride={headerTitle} diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx index a111007278..0720ecf45b 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerLayout.tsx @@ -14,22 +14,20 @@ * limitations under the License. */ -import { Header, Page, pageTheme } from '@backstage/core'; +import { Header, Page } from '@backstage/core'; import React from 'react'; type Props = { children?: React.ReactNode; }; -export const ApiExplorerLayout = ({ children }: Props) => { - return ( - -
- {children} - - ); -}; +export const ApiExplorerLayout = ({ children }: Props) => ( + +
+ {children} + +); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index e386f281e8..46cf014134 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -72,6 +72,6 @@ describe('ApiCatalogPage', () => { // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { const { findByText } = renderWrapped(); - expect(await findByText(/APIs \(2\)/)).toBeInTheDocument(); + expect(await findByText(/Backstage API Explorer/)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index b6440edc3a..c6a8d62b58 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -44,7 +44,6 @@ export const ApiExplorerPage = () => { All your APIs { wrapInTestApp( { const rendered = render( wrapInTestApp( - + , ), ); - expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument(); expect(rendered.getByText(/api1/)).toBeInTheDocument(); expect(rendered.getByText(/api2/)).toBeInTheDocument(); expect(rendered.getByText(/api3/)).toBeInTheDocument(); diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 485fcc216d..f3be9f664b 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -15,7 +15,7 @@ */ import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; -import { Table, TableColumn, useApi } from '@backstage/core'; +import { Table, TableFilter, TableColumn, useApi } from '@backstage/core'; import { Chip, Link } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; @@ -90,9 +90,27 @@ const columns: TableColumn[] = [ }, ]; +const filters: TableFilter[] = [ + { + column: 'Owner', + type: 'select', + }, + { + column: 'Type', + type: 'multiple-select', + }, + { + column: 'Lifecycle', + type: 'multiple-select', + }, + { + column: 'Tags', + type: 'checkbox-tree', + }, +]; + type ExplorerTableProps = { entities: Entity[]; - titlePreamble: string; loading: boolean; error?: any; }; @@ -101,7 +119,6 @@ export const ApiExplorerTable = ({ entities, loading, error, - titlePreamble, }: ExplorerTableProps) => { if (error) { return ( @@ -114,7 +131,7 @@ export const ApiExplorerTable = ({ } return ( - +
); }; diff --git a/plugins/api-docs/src/components/useComponentApiEntities.ts b/plugins/api-docs/src/components/useComponentApiEntities.ts index 11b5de988f..9e5cbd968e 100644 --- a/plugins/api-docs/src/components/useComponentApiEntities.ts +++ b/plugins/api-docs/src/components/useComponentApiEntities.ts @@ -16,7 +16,11 @@ import { useAsyncRetry } from 'react-use'; import { errorApiRef, useApi } from '@backstage/core'; -import { ApiEntity, ComponentEntity } from '@backstage/catalog-model'; +import { + ApiEntity, + ComponentEntity, + parseEntityName, +} from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { useComponentApiNames } from './useComponentApiNames'; @@ -43,10 +47,20 @@ export function useComponentApiEntities({ await Promise.all( apiNames.map(async name => { try { - const api = (await catalogApi.getEntityByName({ - kind: 'API', - name, - })) as ApiEntity | undefined; + const apiEntityName = parseEntityName(name, { + defaultNamespace: entity.metadata.namespace, + defaultKind: 'API', + }); + + if (apiEntityName.kind !== 'API') { + throw new Error( + `Referenced entity of kind "${apiEntityName.kind}" as an API`, + ); + } + + const api = (await catalogApi.getEntityByName(apiEntityName)) as + | ApiEntity + | undefined; if (api) { resultMap.set(api.metadata.name, api); diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts index d8f6d25d0f..f7a22e9ad9 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts @@ -16,6 +16,7 @@ import express from 'express'; import passport from 'passport'; +import { InternalOAuthError } from 'passport-oauth2'; import { executeRedirectStrategy, executeFrameHandlerStrategy, @@ -58,7 +59,11 @@ describe('PassportStrategyHelper', () => { } class MyCustomAuthErrorStrategy extends passport.Strategy { authenticate() { - this.error(new Error('MyCustomAuth error')); + this.error( + new InternalOAuthError('MyCustomAuth error', { + data: '{ "message": "Custom message" }', + }), + ); } } class MyCustomAuthRedirectStrategy extends passport.Strategy { @@ -97,7 +102,7 @@ describe('PassportStrategyHelper', () => { ); expect(spyAuthenticate).toBeCalledTimes(1); await expect(frameHandlerStrategyPromise).rejects.toThrow( - 'Authentication failed, Error: MyCustomAuth error', + 'Authentication failed, MyCustomAuth error - Custom message', ); }); diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 7bc34186f4..3264fd8faa 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -18,6 +18,7 @@ import express from 'express'; import passport from 'passport'; import jwtDecoder from 'jwt-decode'; import { ProfileInfo, RedirectInfo } from '../../providers/types'; +import { InternalOAuthError } from 'passport-oauth2'; export type PassportDoneCallback = ( err?: Error, @@ -95,8 +96,22 @@ export const executeFrameHandlerStrategy = async ( ) => { reject(new Error(`Authentication rejected, ${info.message ?? ''}`)); }; - strategy.error = (error: Error) => { - reject(new Error(`Authentication failed, ${error}`)); + strategy.error = (error: InternalOAuthError) => { + let message = `Authentication failed, ${error.message}`; + + if (error.oauthError?.data) { + try { + const errorData = JSON.parse(error.oauthError.data); + + if (errorData.message) { + message += ` - ${errorData.message}`; + } + } catch (parseError) { + message += ` - ${error.oauthError}`; + } + } + + reject(new Error(message)); }; strategy.redirect = () => { reject(new Error('Unexpected redirect')); diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 59e46a86d7..a8edc9a714 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -41,8 +41,10 @@ type SamlInfo = { export class SamlAuthProvider implements AuthProviderRouteHandlers { private readonly strategy: SamlStrategy; private readonly tokenIssuer: TokenIssuer; + private readonly appUrl: string; constructor(options: SAMLProviderOptions) { + this.appUrl = options.appUrl; this.tokenIssuer = options.tokenIssuer; this.strategy = new SamlStrategy({ ...options }, (( profile: SamlProfile, @@ -82,7 +84,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { claims: { sub: id }, }); - return postMessageResponse(res, 'http://localhost:3000', { + return postMessageResponse(res, this.appUrl, { type: 'authorization_response', response: { providerInfo: {}, @@ -91,7 +93,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { }, }); } catch (error) { - return postMessageResponse(res, 'http://localhost:3000', { + return postMessageResponse(res, this.appUrl, { type: 'authorization_response', error: { name: error.name, @@ -115,6 +117,7 @@ type SAMLProviderOptions = { issuer: string; path: string; tokenIssuer: TokenIssuer; + appUrl: string; }; export const createSamlProvider: AuthProviderFactory = ({ @@ -131,6 +134,7 @@ export const createSamlProvider: AuthProviderFactory = ({ issuer, path: `${url.pathname}/${providerId}/handler/frame`, tokenIssuer, + appUrl: globalConfig.appUrl, }; return new SamlAuthProvider(opts); diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 07f47fbb50..ecc154bc5a 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -42,7 +42,7 @@ "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", @@ -53,7 +53,7 @@ "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "msw": "^0.21.2", "supertest": "^4.0.2" }, diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 8ef685e6b0..3d1f887e1f 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -14,8 +14,9 @@ * limitations under the License. */ +import { getVoidLogger } from '@backstage/backend-common'; import type { Entity } from '@backstage/catalog-model'; -import type { Database } from '../database'; +import { Database, DatabaseManager } from '../database'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; describe('DatabaseEntitiesCatalog', () => { @@ -58,7 +59,7 @@ describe('DatabaseEntitiesCatalog', () => { db.entities.mockResolvedValue([]); db.addEntities.mockResolvedValue([{ entity }]); - const catalog = new DatabaseEntitiesCatalog(db); + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); const result = await catalog.addOrUpdateEntity(entity); expect(db.entityByName).toHaveBeenCalledTimes(1); @@ -97,7 +98,7 @@ describe('DatabaseEntitiesCatalog', () => { }); db.updateEntity.mockResolvedValue({ entity }); - const catalog = new DatabaseEntitiesCatalog(db); + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); const result = await catalog.addOrUpdateEntity(entity); expect(db.entities).toHaveBeenCalledTimes(0); @@ -149,7 +150,7 @@ describe('DatabaseEntitiesCatalog', () => { db.entityByName.mockResolvedValue({ entity: existing }); db.updateEntity.mockResolvedValue({ entity: existing }); - const catalog = new DatabaseEntitiesCatalog(db); + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); const result = await catalog.addOrUpdateEntity(added); expect(db.entityByName).toHaveBeenCalledTimes(1); @@ -180,4 +181,38 @@ describe('DatabaseEntitiesCatalog', () => { expect(result).toEqual(existing); }); }); + + describe('batchAddOrUpdateEntities', () => { + it('both adds and updates', async () => { + const catalog = new DatabaseEntitiesCatalog( + await DatabaseManager.createTestDatabase(), + getVoidLogger(), + ); + const entities: Entity[] = []; + for (let i = 0; i < 500; ++i) { + entities.push({ + apiVersion: 'a', + kind: 'k', + metadata: { name: `n${i}` }, + }); + } + + await catalog.batchAddOrUpdateEntities(entities); + const afterFirst = await catalog.entities(); + expect(afterFirst.length).toBe(500); + + entities[40].metadata.op = 'changed'; + entities.push({ + apiVersion: 'a', + kind: 'k', + metadata: { name: `n500`, op: 'added' }, + }); + + await catalog.batchAddOrUpdateEntities(entities); + const afterSecond = await catalog.entities(); + expect(afterSecond.length).toBe(501); + expect(afterSecond.find(e => e.metadata.op === 'changed')).toBeDefined(); + expect(afterSecond.find(e => e.metadata.op === 'added')).toBeDefined(); + }); + }); }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 2575411e7c..0a213dd186 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -14,19 +14,46 @@ * limitations under the License. */ -import { NotFoundError } from '@backstage/backend-common'; +import { ConflictError, NotFoundError } from '@backstage/backend-common'; +import type { Entity } from '@backstage/catalog-model'; import { - EntityName, + entityHasChanges, generateUpdatedEntity, getEntityName, LOCATION_ANNOTATION, + serializeEntityRef, } from '@backstage/catalog-model'; -import type { Entity } from '@backstage/catalog-model'; +import { chunk, groupBy } from 'lodash'; +import limiterFactory from 'p-limit'; +import { Logger } from 'winston'; import type { Database, DbEntityResponse, EntityFilters } from '../database'; +import { durationText } from '../util/timing'; import type { EntitiesCatalog } from './types'; +type BatchContext = { + kind: string; + namespace: string; + locationId?: string; +}; + +// Some locations return tens or hundreds of thousands of entities. To make +// those payloads more manageable, we break work apart in batches of this +// many entities and write them to storage per batch. +const BATCH_SIZE = 100; + +// When writing large batches, there's an increasing chance of contention in +// the form of conflicts where we compete with other writes. Each batch gets +// this many attempts at being written before giving up. +const BATCH_ATTEMPTS = 3; + +// The number of batches that may be ongoing at the same time. +const BATCH_CONCURRENCY = 3; + export class DatabaseEntitiesCatalog implements EntitiesCatalog { - constructor(private readonly database: Database) {} + constructor( + private readonly database: Database, + private readonly logger: Logger, + ) {} async entities(filters?: EntityFilters): Promise { const items = await this.database.transaction(tx => @@ -35,20 +62,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return items.map(i => i.entity); } - async entityByUid(uid: string): Promise { - const response = await this.database.transaction(tx => - this.database.entityByUid(tx, uid), - ); - return response?.entity; - } - - async entityByName(name: EntityName): Promise { - const response = await this.database.transaction(tx => - this.database.entityByName(tx, name), - ); - return response?.entity; - } - async addOrUpdateEntity( entity: Entity, locationId?: string, @@ -100,12 +113,9 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const location = entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION]; const colocatedEntities = location - ? await this.database.entities(tx, [ - { - key: `metadata.annotations.${LOCATION_ANNOTATION}`, - values: [location], - }, - ]) + ? await this.database.entities(tx, { + [`metadata.annotations.${LOCATION_ANNOTATION}`]: location, + }) : [entityResponse]; for (const dbResponse of colocatedEntities) { await this.database.removeEntityByUid( @@ -120,4 +130,139 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return undefined; }); } + + /** + * Writes a number of entities efficiently to storage. + * + * @param entities Some entities + * @param locationId The location that they all belong to + */ + async batchAddOrUpdateEntities(entities: Entity[], locationId?: string) { + // Group the entities by unique kind+namespace combinations + const entitiesByKindAndNamespace = groupBy(entities, entity => { + const name = getEntityName(entity); + return `${name.kind}:${name.namespace}`.toLowerCase(); + }); + + const limiter = limiterFactory(BATCH_CONCURRENCY); + const tasks: Promise[] = []; + + for (const groupEntities of Object.values(entitiesByKindAndNamespace)) { + const { kind, namespace } = getEntityName(groupEntities[0]); + + // Go through the new entities in reasonable chunk sizes (sometimes, + // sources produce tens of thousands of entities, and those are too large + // batch sizes to reasonably send to the database) + for (const batch of chunk(groupEntities, BATCH_SIZE)) { + tasks.push( + limiter(async () => { + const first = serializeEntityRef(batch[0]); + const last = serializeEntityRef(batch[batch.length - 1]); + this.logger.debug( + `Considering batch ${first}-${last} (${batch.length} entries)`, + ); + + // Retry the batch write a few times to deal with contention + const context = { kind, namespace, locationId }; + for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { + try { + const { toAdd, toUpdate } = await this.analyzeBatch( + batch, + context, + ); + if (toAdd.length) await this.batchAdd(toAdd, context); + if (toUpdate.length) await this.batchUpdate(toUpdate, context); + break; + } catch (e) { + if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { + this.logger.warn( + `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, + ); + } else { + throw e; + } + } + } + }), + ); + } + } + + await Promise.all(tasks); + } + + // Given a batch of entities that were just read from a location, take them + // into consideration by comparing against the existing catalog entities and + // produce the list of entities to be added, and the list of entities to be + // updated + private async analyzeBatch( + newEntities: Entity[], + { kind, namespace }: BatchContext, + ): Promise<{ + toAdd: Entity[]; + toUpdate: Entity[]; + }> { + const markTimestamp = process.hrtime(); + + const names = newEntities.map(e => e.metadata.name); + const oldEntities = await this.entities({ + kind: kind, + 'metadata.namespace': namespace, + 'metadata.name': names, + }); + + const oldEntitiesByName = new Map( + oldEntities.map(e => [e.metadata.name, e]), + ); + + const toAdd: Entity[] = []; + const toUpdate: Entity[] = []; + + for (const newEntity of newEntities) { + const oldEntity = oldEntitiesByName.get(newEntity.metadata.name); + if (!oldEntity) { + toAdd.push(newEntity); + } else if (entityHasChanges(oldEntity, newEntity)) { + // TODO(freben): This currently uses addOrUpdateEntity under the hood, + // but should probably calculate the end result entity right here + // instead and call a dedicated batch update database method instead + toUpdate.push(newEntity); + } + } + + this.logger.debug( + `Found ${toAdd.length} entities to add, ${ + toUpdate.length + } entities to update in ${durationText(markTimestamp)}`, + ); + + return { toAdd, toUpdate }; + } + + // Efficiently adds the given entities to storage, under the assumption that + // they do not conflict with any existing entities + private async batchAdd(entities: Entity[], { locationId }: BatchContext) { + const markTimestamp = process.hrtime(); + + await this.addEntities(entities, locationId); + + this.logger.debug( + `Added ${entities.length} entities in ${durationText(markTimestamp)}`, + ); + } + + // Efficiently updates the given entities into storage, under the assumption + // that there already exist entities with the same names + private async batchUpdate(entities: Entity[], { locationId }: BatchContext) { + const markTimestamp = process.hrtime(); + + // TODO(freben): Still not batched + for (const entity of entities) { + await this.addOrUpdateEntity(entity, locationId); + } + + this.logger.debug( + `Updated ${entities.length} entities in ${durationText(markTimestamp)}`, + ); + } } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 5d84cbcebc..ca73eddce9 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity, EntityName, Location } from '@backstage/catalog-model'; +import { Entity, Location } from '@backstage/catalog-model'; import type { EntityFilters } from '../database'; // @@ -23,11 +23,20 @@ import type { EntityFilters } from '../database'; export type EntitiesCatalog = { entities(filters?: EntityFilters): Promise; - entityByUid(uid: string): Promise; - entityByName(name: EntityName): Promise; addOrUpdateEntity(entity: Entity, locationId?: string): Promise; addEntities(entities: Entity[], locationId?: string): Promise; removeEntityByUid(uid: string): Promise; + + /** + * Writes a number of entities efficiently to storage. + * + * @param entities Some entities + * @param locationId The location that they all belong to + */ + batchAddOrUpdateEntities( + entities: Entity[], + locationId?: string, + ): Promise; }; // diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 840af74825..f3ca212df0 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -347,7 +347,7 @@ describe('CommonDatabase', () => { await db.transaction(async tx => { await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]); }); - const result = await db.transaction(async tx => db.entities(tx, [])); + const result = await db.transaction(async tx => db.entities(tx, {})); expect(result.length).toEqual(2); expect(result).toEqual( expect.arrayContaining([ @@ -389,10 +389,7 @@ describe('CommonDatabase', () => { await expect( db.transaction(async tx => - db.entities(tx, [ - { key: 'kind', values: ['k2'] }, - { key: 'spec.c', values: ['some'] }, - ]), + db.entities(tx, { kind: 'k2', 'spec.c': 'some' }), ), ).resolves.toEqual([ { @@ -427,10 +424,7 @@ describe('CommonDatabase', () => { }); const rows = await db.transaction(async tx => - db.entities(tx, [ - { key: 'apiVersion', values: ['a'] }, - { key: 'spec.c', values: [null, 'some'] }, - ]), + db.entities(tx, { apiVersion: 'a', 'spec.c': [null, 'some'] }), ); expect(rows.length).toEqual(3); @@ -477,10 +471,7 @@ describe('CommonDatabase', () => { }); const rows = await db.transaction(async tx => - db.entities(tx, [ - { key: 'ApiVersioN', values: ['A'] }, - { key: 'spEc.C', values: [null, 'some'] }, - ]), + db.entities(tx, { ApiVersioN: 'A', 'spEc.C': [null, 'some'] }), ); expect(rows.length).toEqual(3); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 49b36e0933..dca5072b30 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -219,15 +219,16 @@ export class CommonDatabase implements Database { let entitiesQuery = tx('entities'); - for (const filter of filters || []) { - const key = filter.key.toLowerCase().replace(/[*]/g, '%'); - const keyOp = filter.key.includes('*') ? 'like' : '='; + for (const [matchKey, matchVal] of Object.entries(filters ?? {})) { + const key = matchKey.toLowerCase().replace(/[*]/g, '%'); + const keyOp = key.includes('%') ? 'like' : '='; + const values = Array.isArray(matchVal) ? matchVal : [matchVal]; let matchNulls = false; const matchIn: string[] = []; const matchLike: string[] = []; - for (const value of filter.values) { + for (const value of values) { if (!value) { matchNulls = true; } else if (value.includes('*')) { diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts index eb307c9478..465bf654f2 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.ts @@ -46,21 +46,31 @@ export class DatabaseManager { return new CommonDatabase(knex, logger); } - public static async createInMemoryDatabase( - options: Partial = {}, - ): Promise { + public static async createInMemoryDatabase(): Promise { + const knex = await this.createInMemoryDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createInMemoryDatabaseConnection(): Promise { const knex = Knex({ client: 'sqlite3', connection: ':memory:', useNullAsDefault: true, }); + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { resource.run('PRAGMA foreign_keys = ON', () => {}); }); - return DatabaseManager.createDatabase(knex, options); + + return knex; } public static async createTestDatabase(): Promise { + const knex = await this.createTestDatabaseConnection(); + return await this.createDatabase(knex); + } + + public static async createTestDatabaseConnection(): Promise { const config: Knex.Config = { /* client: 'pg', @@ -91,11 +101,7 @@ export class DatabaseManager { knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { resource.run('PRAGMA foreign_keys = ON', () => {}); }); - await knex.migrate.latest({ - directory: migrationsDir, - }); - const { logger } = defaultOptions; - return new CommonDatabase(knex, logger); + return knex; } } diff --git a/plugins/catalog-backend/src/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts index d108820ca9..c6793cbdf4 100644 --- a/plugins/catalog-backend/src/database/search.test.ts +++ b/plugins/catalog-backend/src/database/search.test.ts @@ -24,8 +24,11 @@ describe('search', () => { const output = traverse(input); expect(output).toEqual([ { key: 'a', value: 'b' }, + { key: 'a.b', value: true }, { key: 'a', value: 'c' }, + { key: 'a.c', value: true }, { key: 'a', value: 'd' }, + { key: 'a.d', value: true }, ]); }); diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts index 863b86ee9a..9e58d2468b 100644 --- a/plugins/catalog-backend/src/database/search.ts +++ b/plugins/catalog-backend/src/database/search.ts @@ -35,7 +35,7 @@ const MAX_VALUE_LENGTH = 200; type Kv = { key: string; - value: any; + value: unknown; }; // Helper for traversing through a nested structure and outputting a list of @@ -58,15 +58,17 @@ type Kv = { // "a", 1 // "b.c", null // "b.e": "f" +// "b.e.f": true // "b.e": "g" +// "b.e.g": true // "h.i": 1 // "h.j": "k" // "h.i": 2 // "h.j": "l" -export function traverse(root: any): Kv[] { +export function traverse(root: unknown): Kv[] { const output: Kv[] = []; - function visit(path: string, current: any) { + function visit(path: string, current: unknown) { if (SPECIAL_KEYS.includes(path)) { return; } @@ -89,14 +91,29 @@ export function traverse(root: any): Kv[] { // array if (Array.isArray(current)) { for (const item of current) { - // keep the same path as currently + // NOTE(freben): The reason that these are output in two different ways, + // is to support use cases where you want to express that MORE than one + // tag is present in a list. Since the EntityFilters structure is a + // record, you can't have several entries of the same key. Therefore + // you will have to match on + // + // { "a.b": ["true"], "a.c": ["true"] } + // + // rather than + // + // { "a": ["b", "c"] } + // + // because the latter means EITHER b or c has to be present. visit(path, item); + if (typeof item === 'string') { + output.push({ key: `${path}.${item}`, value: true }); + } } return; } // object - for (const [key, value] of Object.entries(current)) { + for (const [key, value] of Object.entries(current!)) { visit(path ? `${path}.${key}` : key, value); } } @@ -113,12 +130,12 @@ export function mapToRows( ): DbEntitiesSearchRow[] { const result: DbEntitiesSearchRow[] = []; - for (let { key, value } of input) { - key = key.toLowerCase(); - if (value === undefined || value === null) { + for (const { key: rawKey, value: rawValue } of input) { + const key = rawKey.toLowerCase(); + if (rawValue === undefined || rawValue === null) { result.push({ entity_id: entityId, key, value: null }); } else { - value = String(value).toLowerCase(); + const value = String(rawValue).toLowerCase(); if (value.length <= MAX_VALUE_LENGTH) { result.push({ entity_id: entityId, key, value }); } diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 20cc123c0a..14e8d5be64 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -67,11 +67,27 @@ export type DatabaseLocationUpdateLogEvent = { message?: string; }; -export type EntityFilter = { - key: string; - values: (string | null)[]; -}; -export type EntityFilters = EntityFilter[]; +/** + * Filter matcher for a single entity field. + * + * Can be either null or a string, or an array of those. Null and the empty + * string are treated equally, and match both a present field with a null or + * empty value, as well as an absent field. + * + * A filter may contain asterisks (*) that are treated as wildcards for zero + * or more arbitrary characters. + */ +export type EntityFilter = null | string | (null | string)[]; + +/** + * A set of filter matchers used for filtering entities. + * + * The keys are full dot-separated paths into the structure of an entity, for + * example "metadata.name". You can also address any item in an array the same + * way, e.g. "a.b.c": "x" works if b is an array of objects that have a c field + * and any of those have the value x. + */ +export type EntityFilters = Record; /** * An abstraction on top of the underlying database, wrapping the basic CRUD diff --git a/plugins/catalog-backend/src/index.ts b/plugins/catalog-backend/src/index.ts index 89fd4d37ed..f621930d10 100644 --- a/plugins/catalog-backend/src/index.ts +++ b/plugins/catalog-backend/src/index.ts @@ -17,5 +17,5 @@ export * from './catalog'; export * from './database'; export * from './ingestion'; -export * from './service/router'; +export * from './service'; export * from './util'; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 15d9168732..7fe164d05f 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -15,12 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { - Entity, - ENTITY_DEFAULT_NAMESPACE, - Location, - LocationSpec, -} from '@backstage/catalog-model'; +import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { LocationUpdateStatus } from '../catalog/types'; import { DatabaseLocationUpdateLogStatus } from '../database/types'; @@ -36,11 +31,10 @@ describe('HigherOrderOperations', () => { beforeAll(() => { entitiesCatalog = { entities: jest.fn(), - entityByUid: jest.fn(), - entityByName: jest.fn(), addOrUpdateEntity: jest.fn(), addEntities: jest.fn(), removeEntityByUid: jest.fn(), + batchAddOrUpdateEntities: jest.fn(), }; locationsCatalog = { addLocation: jest.fn(), @@ -192,8 +186,7 @@ describe('HigherOrderOperations', () => { entities: [{ entity: desc, location }], errors: [], }); - entitiesCatalog.entities.mockResolvedValue([]); - entitiesCatalog.addEntities.mockResolvedValue(undefined); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue(undefined); await expect( higherOrderOperation.refreshAllLocations(), @@ -205,17 +198,8 @@ describe('HigherOrderOperations', () => { type: 'some', target: 'thing', }); - expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - { key: 'kind', values: ['Component'] }, - { key: 'metadata.namespace', values: [ENTITY_DEFAULT_NAMESPACE] }, - { key: 'metadata.name', values: ['c1'] }, - ]), - ); - expect(entitiesCatalog.addEntities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.addEntities).toHaveBeenNthCalledWith( + expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenNthCalledWith( 1, [expect.objectContaining({ metadata: { name: 'c1' } })], '123', diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 737166436c..6df6d9b4b7 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -14,17 +14,8 @@ * limitations under the License. */ -import { ConflictError, InputError } from '@backstage/backend-common'; -import { - Entity, - entityHasChanges, - getEntityName, - Location, - LocationSpec, - serializeEntityRef, -} from '@backstage/catalog-model'; -import { chunk, groupBy } from 'lodash'; -import limiterFactory from 'p-limit'; +import { InputError } from '@backstage/backend-common'; +import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; @@ -35,25 +26,6 @@ import { LocationReader, } from './types'; -type BatchContext = { - kind: string; - namespace: string; - location: Location; -}; - -// Some locations return tens or hundreds of thousands of entities. To make -// those payloads more manageable, we break work apart in batches of this -// many entities and write them to storage per batch. -const BATCH_SIZE = 100; - -// When writing large batches, there's an increasing chance of contention in -// the form of conflicts where we compete with other writes. Each batch gets -// this many attempts at being written before giving up. -const BATCH_ATTEMPTS = 3; - -// The number of batches that may be ongoing at the same time. -const BATCH_CONCURRENCY = 3; - /** * Placeholder for operations that span several catalogs and/or stretches out * in time. @@ -192,10 +164,30 @@ export class HigherOrderOperations implements HigherOrderOperation { startTimestamp = process.hrtime(); - await this.batchAddOrUpdateEntities( - readerOutput.entities.map(e => e.entity), - location, - ); + try { + await this.entitiesCatalog.batchAddOrUpdateEntities( + readerOutput.entities.map(e => e.entity), + location.id, + ); + } catch (e) { + for (const entity of readerOutput.entities) { + await this.locationsCatalog.logUpdateFailure( + location.id, + e, + entity.entity.metadata.name, + ); + } + throw e; + } + + this.logger.info(`Posting update success markers`); + + for (const entity of readerOutput.entities) { + await this.locationsCatalog.logUpdateSuccess( + location.id, + entity.entity.metadata.name, + ); + } this.logger.info( `Wrote ${readerOutput.entities.length} entities from location ${ @@ -203,149 +195,4 @@ export class HigherOrderOperations implements HigherOrderOperation { }:${location.target} in ${durationText(startTimestamp)}`, ); } - - /** - * Writes a number of entities efficiently to storage. - * - * @param entities Some entities - * @param location The location that they all belong to - */ - async batchAddOrUpdateEntities(entities: Entity[], location: Location) { - // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(entities, entity => { - const name = getEntityName(entity); - return `${name.kind}:${name.namespace}`.toLowerCase(); - }); - - const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; - - for (const groupEntities of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupEntities[0]); - - // Go through the new entities in reasonable chunk sizes (sometimes, - // sources produce tens of thousands of entities, and those are too large - // batch sizes to reasonably send to the database) - for (const batch of chunk(groupEntities, BATCH_SIZE)) { - tasks.push( - limiter(async () => { - const first = serializeEntityRef(batch[0]); - const last = serializeEntityRef(batch[batch.length - 1]); - this.logger.debug( - `Considering batch ${first}-${last} (${batch.length} entries)`, - ); - - // Retry the batch write a few times to deal with contention - const context = { kind, namespace, location }; - for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { - try { - const { toAdd, toUpdate } = await this.analyzeBatch( - batch, - context, - ); - if (toAdd.length) await this.batchAdd(toAdd, context); - if (toUpdate.length) await this.batchUpdate(toUpdate, context); - break; - } catch (e) { - if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { - this.logger.warn( - `Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`, - ); - } else { - throw e; - } - } - } - }), - ); - } - } - - await Promise.all(tasks); - } - - // Given a batch of entities that were just read from a location, take them - // into consideration by comparing against the existing catalog entities and - // produce the list of entities to be added, and the list of entities to be - // updated - private async analyzeBatch( - newEntities: Entity[], - { kind, namespace }: BatchContext, - ): Promise<{ - toAdd: Entity[]; - toUpdate: Entity[]; - }> { - const markTimestamp = process.hrtime(); - - const names = newEntities.map(e => e.metadata.name); - const oldEntities = await this.entitiesCatalog.entities([ - { key: 'kind', values: [kind] }, - { key: 'metadata.namespace', values: [namespace] }, - { key: 'metadata.name', values: names }, - ]); - - const oldEntitiesByName = new Map( - oldEntities.map(e => [e.metadata.name, e]), - ); - - const toAdd: Entity[] = []; - const toUpdate: Entity[] = []; - - for (const newEntity of newEntities) { - const oldEntity = oldEntitiesByName.get(newEntity.metadata.name); - if (!oldEntity) { - toAdd.push(newEntity); - } else if (entityHasChanges(oldEntity, newEntity)) { - toUpdate.push(newEntity); - } - } - - this.logger.debug( - `Found ${toAdd.length} entities to add, ${ - toUpdate.length - } entities to update in ${durationText(markTimestamp)}`, - ); - - return { toAdd, toUpdate }; - } - - // Efficiently adds the given entities to storage, under the assumption that - // they do not conflict with any existing entities - private async batchAdd(entities: Entity[], { location }: BatchContext) { - const markTimestamp = process.hrtime(); - - await this.entitiesCatalog.addEntities(entities, location.id); - - // TODO(freben): Still not batched - for (const entity of entities) { - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.metadata.name, - ); - } - - this.logger.debug( - `Added ${entities.length} entities in ${durationText(markTimestamp)}`, - ); - } - - // Efficiently updates the given entities into storage, under the assumption - // that there already exist entities with the same names - private async batchUpdate(entities: Entity[], { location }: BatchContext) { - const markTimestamp = process.hrtime(); - - // TODO(freben): Still not batched - for (const entity of entities) { - await this.entitiesCatalog.addOrUpdateEntity(entity); - - await this.locationsCatalog.logUpdateSuccess( - location.id, - entity.metadata.name, - ); - } - - this.logger.debug( - `Updated ${entities.length} entities in ${durationText(markTimestamp)}`, - ); - } } diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 87c75e21e5..c36b3d2b05 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -14,43 +14,25 @@ * limitations under the License. */ -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; import { Entity, - EntityPolicies, - EntityPolicy, ENTITY_DEFAULT_NAMESPACE, LocationSpec, } from '@backstage/catalog-model'; -import { Config, ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { CatalogRulesEnforcer } from './CatalogRules'; -import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEntityProcessor'; -import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; -import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; -import { CodeOwnersProcessor } from './processors/CodeOwnersProcessor'; -import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor'; -import { FileReaderProcessor } from './processors/FileReaderProcessor'; -import { GithubOrgReaderProcessor } from './processors/GithubOrgReaderProcessor'; -import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; -import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; -import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; -import { LdapOrgReaderProcessor } from './processors/LdapOrgReaderProcessor'; -import { LocationRefProcessor } from './processors/LocationEntityProcessor'; -import { PlaceholderProcessor } from './processors/PlaceholderProcessor'; import * as result from './processors/results'; -import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; import { - LocationProcessor, - LocationProcessorDataResult, - LocationProcessorEmit, - LocationProcessorEntityResult, - LocationProcessorErrorResult, - LocationProcessorLocationResult, - LocationProcessorResult, + CatalogProcessor, + CatalogProcessorDataResult, + CatalogProcessorEmit, + CatalogProcessorEntityResult, + CatalogProcessorErrorResult, + CatalogProcessorLocationResult, + CatalogProcessorResult, } from './processors/types'; -import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; -import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; // The max amount of nesting depth of generated work items @@ -58,100 +40,31 @@ const MAX_DEPTH = 10; type Options = { reader: UrlReader; - logger?: Logger; - config?: Config; - processors?: LocationProcessor[]; + logger: Logger; + config: Config; + processors: CatalogProcessor[]; + rulesEnforcer: CatalogRulesEnforcer; }; /** * Implements the reading of a location through a series of processor tasks. */ export class LocationReaders implements LocationReader { - private readonly logger: Logger; - private readonly processors: LocationProcessor[]; - private readonly rulesEnforcer: CatalogRulesEnforcer; + private readonly options: Options; - static defaultProcessors(options: { - logger: Logger; - reader: UrlReader; - config?: Config; - entityPolicy?: EntityPolicy; - }): LocationProcessor[] { - const { - logger, - config = new ConfigReader({}, 'missing-config'), - entityPolicy = new EntityPolicies(), - } = options; - - // TODO(Rugvip): These are added for backwards compatibility if config exists - // The idea is to have everyone migrate from using the old processors to the new - // integration config driven UrlReaders. In an upcoming release we can then completely - // remove support for the old processors, but still keep handling the deprecated location - // types for a while, but with a warning. - const oldProcessors = []; - const pc = config.getOptionalConfig('catalog.processors'); - if (pc?.has('github')) { - logger.warn( - `Using deprecated configuration for catalog.processors.github, move to using integrations.github instead`, - ); - oldProcessors.push(GithubReaderProcessor.fromConfig(config, logger)); - } - if (pc?.has('gitlabApi')) { - logger.warn( - `Using deprecated configuration for catalog.processors.gitlabApi, move to using integrations.gitlab instead`, - ); - oldProcessors.push(new GitlabApiReaderProcessor(config)); - oldProcessors.push(new GitlabReaderProcessor()); - } - if (pc?.has('bitbucketApi')) { - logger.warn( - `Using deprecated configuration for catalog.processors.bitbucketApi, move to using integrations.bitbucket instead`, - ); - oldProcessors.push(new BitbucketApiReaderProcessor(config)); - } - if (pc?.has('azureApi')) { - logger.warn( - `Using deprecated configuration for catalog.processors.azureApi, move to using integrations.azure instead`, - ); - oldProcessors.push(new AzureApiReaderProcessor(config)); - } - - return [ - StaticLocationProcessor.fromConfig(config), - new FileReaderProcessor(), - ...oldProcessors, - GithubOrgReaderProcessor.fromConfig(config, { logger }), - LdapOrgReaderProcessor.fromConfig(config, { logger }), - new UrlReaderProcessor(options), - new YamlProcessor(), - PlaceholderProcessor.default({ reader: options.reader }), - new CodeOwnersProcessor({ reader: options.reader }), - new EntityPolicyProcessor(entityPolicy), - new LocationRefProcessor(), - new AnnotateLocationEntityProcessor(), - ]; - } - - constructor({ - logger = getVoidLogger(), - config, - reader, - processors = LocationReaders.defaultProcessors({ logger, reader, config }), - }: Options) { - this.logger = logger; - this.processors = processors; - this.rulesEnforcer = config - ? CatalogRulesEnforcer.fromConfig(config) - : new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules); + constructor(options: Options) { + this.options = options; } async read(location: LocationSpec): Promise { + const { rulesEnforcer, logger } = this.options; + const output: ReadLocationResult = { entities: [], errors: [] }; - let items: LocationProcessorResult[] = [result.location(location, false)]; + let items: CatalogProcessorResult[] = [result.location(location, false)]; for (let depth = 0; depth < MAX_DEPTH; ++depth) { - const newItems: LocationProcessorResult[] = []; - const emit: LocationProcessorEmit = i => newItems.push(i); + const newItems: CatalogProcessorResult[] = []; + const emit: CatalogProcessorEmit = i => newItems.push(i); for (const item of items) { if (item.type === 'location') { @@ -159,7 +72,7 @@ export class LocationReaders implements LocationReader { } else if (item.type === 'data') { await this.handleData(item, emit); } else if (item.type === 'entity') { - if (this.rulesEnforcer.isAllowed(item.entity, item.location)) { + if (rulesEnforcer.isAllowed(item.entity, item.location)) { const entity = await this.handleEntity(item, emit); output.entities.push({ entity, @@ -190,16 +103,18 @@ export class LocationReaders implements LocationReader { } const message = `Max recursion depth ${MAX_DEPTH} reached for ${location.type} ${location.target}`; - this.logger.warn(message); + logger.warn(message); output.errors.push({ location, error: new Error(message) }); return output; } private async handleLocation( - item: LocationProcessorLocationResult, - emit: LocationProcessorEmit, + item: CatalogProcessorLocationResult, + emit: CatalogProcessorEmit, ) { - for (const processor of this.processors) { + const { processors, logger } = this.options; + + for (const processor of processors) { if (processor.readLocation) { try { if ( @@ -210,21 +125,23 @@ export class LocationReaders implements LocationReader { } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while reading location ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); - this.logger.warn(message); + logger.warn(message); } } } const message = `No processor was able to read location ${item.location.type} ${item.location.target}`; emit(result.inputError(item.location, message)); - this.logger.warn(message); + logger.warn(message); } private async handleData( - item: LocationProcessorDataResult, - emit: LocationProcessorEmit, + item: CatalogProcessorDataResult, + emit: CatalogProcessorEmit, ) { - for (const processor of this.processors) { + const { processors, logger } = this.options; + + for (const processor of processors) { if (processor.parseData) { try { if (await processor.parseData(item.data, item.location, emit)) { @@ -233,7 +150,7 @@ export class LocationReaders implements LocationReader { } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while parsing ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); - this.logger.warn(message); + logger.warn(message); } } } @@ -243,12 +160,14 @@ export class LocationReaders implements LocationReader { } private async handleEntity( - item: LocationProcessorEntityResult, - emit: LocationProcessorEmit, + item: CatalogProcessorEntityResult, + emit: CatalogProcessorEmit, ): Promise { + const { processors, logger } = this.options; + let current = item.entity; - for (const processor of this.processors) { + for (const processor of processors) { if (processor.processEntity) { try { current = await processor.processEntity(current, item.location, emit); @@ -261,7 +180,7 @@ export class LocationReaders implements LocationReader { const name = !current.metadata ? '' : current.metadata.name; const message = `Processor ${processor.constructor.name} threw an error while processing entity ${current.kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); - this.logger.warn(message); + logger.warn(message); } } } @@ -270,21 +189,23 @@ export class LocationReaders implements LocationReader { } private async handleError( - item: LocationProcessorErrorResult, - emit: LocationProcessorEmit, + item: CatalogProcessorErrorResult, + emit: CatalogProcessorEmit, ) { - this.logger.debug( + const { processors, logger } = this.options; + + logger.debug( `Encountered error at location ${item.location.type} ${item.location.target}, ${item.error}`, ); - for (const processor of this.processors) { + for (const processor of processors) { if (processor.handleError) { try { await processor.handleError(item.error, item.location, emit); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while handling another error at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); - this.logger.warn(message); + logger.warn(message); } } } diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts index ec7e59281e..fea70b6c5e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts @@ -16,9 +16,9 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import lodash from 'lodash'; -import { LocationProcessor } from './types'; +import { CatalogProcessor } from './types'; -export class AnnotateLocationEntityProcessor implements LocationProcessor { +export class AnnotateLocationEntityProcessor implements CatalogProcessor { async processEntity(entity: Entity, location: LocationSpec): Promise { return lodash.merge( { diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts index 2e28da1d93..9b9ba586ef 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts @@ -15,17 +15,18 @@ */ import { LocationSpec } from '@backstage/catalog-model'; + +import { Config } from '@backstage/config'; import fetch from 'cross-fetch'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; -import { Config } from '@backstage/config'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** // * NOTE: This has been replaced by packages/backend-common/src/reading * // * Don't implement new functionality here as this file will be removed * // *********************************************************************** -export class AzureApiReaderProcessor implements LocationProcessor { +export class AzureApiReaderProcessor implements CatalogProcessor { private privateToken: string; constructor(config: Config) { @@ -52,7 +53,7 @@ export class AzureApiReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'azure/api') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts index a982b4d268..337053965f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts @@ -17,15 +17,15 @@ import { LocationSpec } from '@backstage/catalog-model'; import fetch from 'cross-fetch'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; import { Config } from '@backstage/config'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** // * NOTE: This has been replaced by packages/backend-common/src/reading * // * Don't implement new functionality here as this file will be removed * // *********************************************************************** -export class BitbucketApiReaderProcessor implements LocationProcessor { +export class BitbucketApiReaderProcessor implements CatalogProcessor { private username: string; private password: string; @@ -58,7 +58,7 @@ export class BitbucketApiReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'bitbucket/api') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index c1c83b7c6c..e1bd5bcb46 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -27,7 +27,7 @@ import { resolveCodeOwner, } from './CodeOwnersProcessor'; -describe(CodeOwnersProcessor, () => { +describe('CodeOwnersProcessor', () => { const mockUrl = ({ basePath = '' } = {}): string => `https://github.com/spotify/backstage/blob/master/${basePath}catalog-info.yaml`; const mockLocation = ({ @@ -79,7 +79,7 @@ describe(CodeOwnersProcessor, () => { return data; }; - describe(buildUrl, () => { + describe('buildUrl', () => { it.each([['azure.com'], ['dev.azure.com']])( 'should throw not implemented error', source => { @@ -99,7 +99,7 @@ describe(CodeOwnersProcessor, () => { }); }); - describe(buildCodeOwnerUrl, () => { + describe('buildCodeOwnerUrl', () => { it('should build a location spec to the codeowners', () => { expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual( 'https://github.com/spotify/backstage/blob/master/docs/CODEOWNERS', @@ -116,13 +116,13 @@ describe(CodeOwnersProcessor, () => { }); }); - describe(parseCodeOwners, () => { + describe('parseCodeOwners', () => { it('should parse the codeowners file', () => { expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners()); }); }); - describe(normalizeCodeOwner, () => { + describe('normalizeCodeOwner', () => { it('should remove org from org/team format', () => { expect(normalizeCodeOwner('@acme/foo')).toBe('foo'); }); @@ -139,13 +139,13 @@ describe(CodeOwnersProcessor, () => { ); }); - describe(findPrimaryCodeOwner, () => { + describe('findPrimaryCodeOwner', () => { it('should return the primary owner', () => { expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core'); }); }); - describe(findRawCodeOwners, () => { + describe('findRawCodeOwners', () => { it('should return found codeowner', async () => { const ownersText = mockCodeOwnersText(); const read = jest @@ -184,7 +184,7 @@ describe(CodeOwnersProcessor, () => { }); }); - describe(resolveCodeOwner, () => { + describe('resolveCodeOwner', () => { it('should return found codeowner', async () => { const read = jest .fn() @@ -207,7 +207,7 @@ describe(CodeOwnersProcessor, () => { }); }); - describe(CodeOwnersProcessor, () => { + describe('CodeOwnersProcessor', () => { const setupTest = ({ kind = 'Component', spec = {} } = {}) => { const entity = { kind, spec }; const read = jest diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index e624af91ee..1802e01d99 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -16,14 +16,13 @@ import { UrlReader } from '@backstage/backend-common'; import { Entity, LocationSpec } from '@backstage/catalog-model'; -import { LocationProcessor } from './types'; import * as codeowners from 'codeowners-utils'; import { CodeOwnersEntry } from 'codeowners-utils'; -import parseGitUri from 'git-url-parse'; -import { filter, head, get, pipe, reverse } from 'lodash/fp'; - // NOTE: This can be removed when ES2021 is implemented import 'core-js/features/promise'; +import parseGitUri from 'git-url-parse'; +import { filter, get, head, pipe, reverse } from 'lodash/fp'; +import { CatalogProcessor } from './types'; const ALLOWED_LOCATION_TYPES = [ 'azure/api', @@ -38,7 +37,7 @@ type Options = { reader: UrlReader; }; -export class CodeOwnersProcessor implements LocationProcessor { +export class CodeOwnersProcessor implements CatalogProcessor { constructor(private readonly options: Options) {} async processEntity(entity: Entity, location: LocationSpec): Promise { diff --git a/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts index 67da9a6705..87543d91f4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts @@ -15,9 +15,9 @@ */ import { Entity, EntityPolicy } from '@backstage/catalog-model'; -import { LocationProcessor } from './types'; +import { CatalogProcessor } from './types'; -export class EntityPolicyProcessor implements LocationProcessor { +export class EntityPolicyProcessor implements CatalogProcessor { private readonly policy: EntityPolicy; constructor(policy: EntityPolicy) { diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts index b95e2ddb58..83fc01185b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts @@ -17,13 +17,13 @@ import { LocationSpec } from '@backstage/catalog-model'; import fs from 'fs-extra'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; -export class FileReaderProcessor implements LocationProcessor { +export class FileReaderProcessor implements CatalogProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'file') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index b8a6f6c43f..bef57a6e5c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -19,14 +19,14 @@ import { Config } from '@backstage/config'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; import * as results from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; import { getOrganizationTeams, getOrganizationUsers } from './util/github'; import { buildOrgHierarchy } from './util/org'; /** * Extracts teams and users out of a GitHub org. */ -export class GithubOrgReaderProcessor implements LocationProcessor { +export class GithubOrgReaderProcessor implements CatalogProcessor { private readonly providers: ProviderConfig[]; private readonly logger: Logger; @@ -45,7 +45,7 @@ export class GithubOrgReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, _optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'github-org') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts index 372ef5d20b..fed7f2a5c2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts @@ -20,7 +20,7 @@ import parseGitUri from 'git-url-parse'; import fetch from 'cross-fetch'; import { Logger } from 'winston'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** // * NOTE: This has been replaced by packages/backend-common/src/reading * @@ -210,7 +210,7 @@ export function readConfig(config: Config, logger: Logger): ProviderConfig[] { * A processor that adds the ability to read files from GitHub v3 APIs, such as * the one exposed by GitHub itself. */ -export class GithubReaderProcessor implements LocationProcessor { +export class GithubReaderProcessor implements CatalogProcessor { private providers: ProviderConfig[]; static fromConfig(config: Config, logger: Logger) { @@ -224,7 +224,7 @@ export class GithubReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { // The github/api type is for backward compatibility if (location.type !== 'github' && location.type !== 'github/api') { diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts index c55c203b51..51a286c7ea 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -16,16 +16,16 @@ import { LocationSpec } from '@backstage/catalog-model'; import fetch from 'cross-fetch'; -import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; import { Config } from '@backstage/config'; +import * as result from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** // * NOTE: This has been replaced by packages/backend-common/src/reading * // * Don't implement new functionality here as this file will be removed * // *********************************************************************** -export class GitlabApiReaderProcessor implements LocationProcessor { +export class GitlabApiReaderProcessor implements CatalogProcessor { private privateToken: string; constructor(config: Config) { @@ -50,7 +50,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'gitlab/api') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts index c682300706..38498b44fa 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts @@ -17,18 +17,18 @@ import { LocationSpec } from '@backstage/catalog-model'; import fetch from 'cross-fetch'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** // * NOTE: This has been replaced by packages/backend-common/src/reading * // * Don't implement new functionality here as this file will be removed * // *********************************************************************** -export class GitlabReaderProcessor implements LocationProcessor { +export class GitlabReaderProcessor implements CatalogProcessor { async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'gitlab') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts index a32c96480f..d863142169 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts @@ -24,12 +24,12 @@ import { readLdapOrg, } from './ldap'; import * as results from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; /** * Extracts teams and users out of an LDAP server. */ -export class LdapOrgReaderProcessor implements LocationProcessor { +export class LdapOrgReaderProcessor implements CatalogProcessor { private readonly providers: LdapProviderConfig[]; private readonly logger: Logger; @@ -49,7 +49,7 @@ export class LdapOrgReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, _optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'ldap-org') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts index a5c9718574..1917f58fbd 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -16,13 +16,13 @@ import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; -export class LocationRefProcessor implements LocationProcessor { +export class LocationRefProcessor implements CatalogProcessor { async processEntity( entity: Entity, _location: LocationSpec, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (entity.kind === 'Location') { const location = entity as LocationEntity; diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index c196458e2a..294cb10f29 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -21,6 +21,7 @@ import { PlaceholderResolver, ResolverParams, ResolverRead, + textPlaceholderResolver, yamlPlaceholderResolver, } from './PlaceholderProcessor'; @@ -134,9 +135,12 @@ describe('PlaceholderProcessor', () => { expect(read).not.toBeCalled(); }); - it('has builtin text support', async () => { + it('works with the text resolver', async () => { read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { text: textPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( @@ -163,11 +167,14 @@ describe('PlaceholderProcessor', () => { ); }); - it('has builtin json support', async () => { + it('works with the json resolver', async () => { read.mockResolvedValue( Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), ); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { json: jsonPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( @@ -194,9 +201,12 @@ describe('PlaceholderProcessor', () => { ); }); - it('has builtin yaml support', async () => { + it('works with the yaml resolver', async () => { read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8')); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { yaml: yamlPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( @@ -225,7 +235,10 @@ describe('PlaceholderProcessor', () => { it('resolves absolute path for absolute location', async () => { read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { text: textPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( @@ -258,7 +271,10 @@ describe('PlaceholderProcessor', () => { it('resolves absolute path for relative file location', async () => { read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { text: textPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( @@ -291,10 +307,13 @@ describe('PlaceholderProcessor', () => { it('not resolves relative file path for relative file location', async () => { // We explicitly don't support this case, as it would allow for file system - // traversel attacks. If we want to implement this, we need to have additional + // traversal attacks. If we want to implement this, we need to have additional // security measures in place! read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); - const processor = PlaceholderProcessor.default({ reader }); + const processor = new PlaceholderProcessor({ + resolvers: { text: textPlaceholderResolver }, + reader, + }); await expect( processor.processEntity( diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index 91257b68a4..3941a2bedc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -18,7 +18,7 @@ import { UrlReader } from '@backstage/backend-common'; import { Entity, LocationSpec } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; import yaml from 'yaml'; -import { LocationProcessor } from './types'; +import { CatalogProcessor } from './types'; export type ResolverRead = (url: string) => Promise; @@ -42,18 +42,7 @@ type Options = { * Traverses raw entity JSON looking for occurrences of $-prefixed placeholders * that it then fills in with actual data. */ -export class PlaceholderProcessor implements LocationProcessor { - static default({ reader }: { reader: UrlReader }) { - return new PlaceholderProcessor({ - resolvers: { - json: jsonPlaceholderResolver, - yaml: yamlPlaceholderResolver, - text: textPlaceholderResolver, - }, - reader, - }); - } - +export class PlaceholderProcessor implements CatalogProcessor { constructor(private readonly options: Options) {} async processEntity(entity: Entity, location: LocationSpec): Promise { diff --git a/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts index 6a2d1096cc..f34b672183 100644 --- a/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts @@ -15,9 +15,9 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import * as result from './results'; import { Config } from '@backstage/config'; -import { LocationProcessorEmit } from './types'; +import * as result from './results'; +import { CatalogProcessorEmit } from './types'; export class StaticLocationProcessor implements StaticLocationProcessor { static fromConfig(config: Config): StaticLocationProcessor { @@ -38,7 +38,7 @@ export class StaticLocationProcessor implements StaticLocationProcessor { async readLocation( location: LocationSpec, _optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (location.type !== 'bootstrap') { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index 751897bea9..73046f0ed1 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -15,16 +15,16 @@ */ import { UrlReaderProcessor } from './UrlReaderProcessor'; -import { - LocationProcessorDataResult, - LocationProcessorResult, - LocationProcessorErrorResult, -} from './types'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; -import { msw } from '@backstage/test-utils'; import { getVoidLogger, UrlReaders } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; +import { + CatalogProcessorDataResult, + CatalogProcessorErrorResult, + CatalogProcessorResult, +} from './types'; describe('UrlReaderProcessor', () => { const mockApiOrigin = 'http://localhost:23000'; @@ -46,9 +46,9 @@ describe('UrlReaderProcessor', () => { ), ); - const generated = (await new Promise(emit => + const generated = (await new Promise(emit => processor.readLocation(spec, false, emit), - )) as LocationProcessorDataResult; + )) as CatalogProcessorDataResult; expect(generated.type).toBe('data'); expect(generated.location).toBe(spec); @@ -70,9 +70,9 @@ describe('UrlReaderProcessor', () => { }), ); - const generated = (await new Promise(emit => + const generated = (await new Promise(emit => processor.readLocation(spec, false, emit), - )) as LocationProcessorErrorResult; + )) as CatalogProcessorErrorResult; expect(generated.type).toBe('error'); expect(generated.location).toBe(spec); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index 2bc05bf1cb..0169abbbe3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { Logger } from 'winston'; import { UrlReader } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; +import { Logger } from 'winston'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; // TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this // can be removed in a bit @@ -35,13 +35,13 @@ type Options = { logger: Logger; }; -export class UrlReaderProcessor implements LocationProcessor { +export class UrlReaderProcessor implements CatalogProcessor { constructor(private readonly options: Options) {} async readLocation( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (deprecatedTypes.includes(location.type)) { // TODO(Rugvip): Let's not enable this warning yet, as we want to move over the example YAMLs diff --git a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts index 9f1ede3f9e..f5d2dfea38 100644 --- a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.test.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { YamlProcessor } from './YamlProcessor'; import { Entity } from '@backstage/catalog-model'; -import yaml from 'yaml'; import { TextEncoder } from 'util'; +import yaml from 'yaml'; import { - LocationProcessorEntityResult, - LocationProcessorErrorResult, + CatalogProcessorEntityResult, + CatalogProcessorErrorResult, } from './types'; +import { YamlProcessor } from './YamlProcessor'; describe('YamlProcessor', () => { const processor = new YamlProcessor(); @@ -82,7 +82,7 @@ describe('YamlProcessor', () => { expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); - const e = emit.mock.calls[0][0] as LocationProcessorEntityResult; + const e = emit.mock.calls[0][0] as CatalogProcessorEntityResult; expect(e.type).toBe('entity'); expect(e.location).toBe(locationSpec); expect(e.entity).toEqual(entity); @@ -114,12 +114,12 @@ describe('YamlProcessor', () => { expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); - const eComponent = emit.mock.calls[0][0] as LocationProcessorEntityResult; + const eComponent = emit.mock.calls[0][0] as CatalogProcessorEntityResult; expect(eComponent.type).toBe('entity'); expect(eComponent.location).toBe(locationSpec); expect(eComponent.entity).toEqual(entityComponent); - const eApi = emit.mock.calls[1][0] as LocationProcessorEntityResult; + const eApi = emit.mock.calls[1][0] as CatalogProcessorEntityResult; expect(eApi.type).toBe('entity'); expect(eApi.location).toBe(locationSpec); expect(eApi.entity).toEqual(entityApi); @@ -131,7 +131,7 @@ describe('YamlProcessor', () => { expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); - const e = emit.mock.calls[0][0] as LocationProcessorErrorResult; + const e = emit.mock.calls[0][0] as CatalogProcessorErrorResult; expect(e.error.message).toMatch(/^YAML error, /); expect(e.type).toBe('error'); expect(e.location).toBe(locationSpec); @@ -143,7 +143,7 @@ describe('YamlProcessor', () => { expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true); - const e = emit.mock.calls[0][0] as LocationProcessorErrorResult; + const e = emit.mock.calls[0][0] as CatalogProcessorErrorResult; expect(e.error.message).toMatch(/^Expected object at root, got /); expect(e.type).toBe('error'); expect(e.location).toBe(locationSpec); diff --git a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts index 79ae55fae1..46df4e6a2b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/YamlProcessor.ts @@ -18,13 +18,18 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import lodash from 'lodash'; import yaml from 'yaml'; import * as result from './results'; -import { LocationProcessor, LocationProcessorEmit } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; -export class YamlProcessor implements LocationProcessor { +/** + * Handles incoming raw data buffers, and if they have a yaml extension, + * attempts to parse them into structured data and emitting them as un- + * validated entities. + */ +export class YamlProcessor implements CatalogProcessor { async parseData( data: Buffer, location: LocationSpec, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise { if (!location.target.match(/\.ya?ml/)) { return false; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index c9453d0247..f966ef3348 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -31,6 +31,7 @@ export { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; export { GitlabReaderProcessor } from './GitlabReaderProcessor'; export { LocationRefProcessor } from './LocationEntityProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; +export type { PlaceholderResolver } from './PlaceholderProcessor'; export { StaticLocationProcessor } from './StaticLocationProcessor'; export { UrlReaderProcessor } from './UrlReaderProcessor'; export { YamlProcessor } from './YamlProcessor'; diff --git a/plugins/catalog-backend/src/ingestion/processors/results.ts b/plugins/catalog-backend/src/ingestion/processors/results.ts index 9c6a7fc2b3..2718d871dd 100644 --- a/plugins/catalog-backend/src/ingestion/processors/results.ts +++ b/plugins/catalog-backend/src/ingestion/processors/results.ts @@ -16,12 +16,12 @@ import { InputError, NotFoundError } from '@backstage/backend-common'; import { Entity, LocationSpec } from '@backstage/catalog-model'; -import { LocationProcessorResult } from './types'; +import { CatalogProcessorResult } from './types'; export function notFoundError( atLocation: LocationSpec, message: string, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'error', location: atLocation, @@ -32,7 +32,7 @@ export function notFoundError( export function inputError( atLocation: LocationSpec, message: string, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'error', location: atLocation, @@ -43,27 +43,27 @@ export function inputError( export function generalError( atLocation: LocationSpec, message: string, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'error', location: atLocation, error: new Error(message) }; } export function data( atLocation: LocationSpec, newData: Buffer, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'data', location: atLocation, data: newData }; } export function location( newLocation: LocationSpec, optional: boolean, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'location', location: newLocation, optional }; } export function entity( atLocation: LocationSpec, newEntity: Entity, -): LocationProcessorResult { +): CatalogProcessorResult { return { type: 'entity', location: atLocation, entity: newEntity }; } diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index cd2c423423..0e6b1491a6 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -16,7 +16,7 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; -export type LocationProcessor = { +export type CatalogProcessor = { /** * Reads the contents of a location. * @@ -28,7 +28,7 @@ export type LocationProcessor = { readLocation?( location: LocationSpec, optional: boolean, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise; /** @@ -42,7 +42,7 @@ export type LocationProcessor = { parseData?( data: Buffer, location: LocationSpec, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise; /** @@ -57,7 +57,7 @@ export type LocationProcessor = { processEntity?( entity: Entity, location: LocationSpec, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise; /** @@ -71,40 +71,38 @@ export type LocationProcessor = { handleError?( error: Error, location: LocationSpec, - emit: LocationProcessorEmit, + emit: CatalogProcessorEmit, ): Promise; }; -export type LocationProcessorEmit = ( - generated: LocationProcessorResult, -) => void; +export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void; -export type LocationProcessorLocationResult = { +export type CatalogProcessorLocationResult = { type: 'location'; location: LocationSpec; optional: boolean; }; -export type LocationProcessorDataResult = { +export type CatalogProcessorDataResult = { type: 'data'; data: Buffer; location: LocationSpec; }; -export type LocationProcessorEntityResult = { +export type CatalogProcessorEntityResult = { type: 'entity'; entity: Entity; location: LocationSpec; }; -export type LocationProcessorErrorResult = { +export type CatalogProcessorErrorResult = { type: 'error'; error: Error; location: LocationSpec; }; -export type LocationProcessorResult = - | LocationProcessorLocationResult - | LocationProcessorDataResult - | LocationProcessorEntityResult - | LocationProcessorErrorResult; +export type CatalogProcessorResult = + | CatalogProcessorLocationResult + | CatalogProcessorDataResult + | CatalogProcessorEntityResult + | CatalogProcessorErrorResult; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts new file mode 100644 index 0000000000..66e7dba682 --- /dev/null +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, UrlReader } from '@backstage/backend-common'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { DatabaseManager } from '../database'; +import { CatalogProcessorEmit } from '../ingestion'; +import * as result from '../ingestion/processors/results'; +import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder'; + +describe('CatalogBuilder', () => { + const db = DatabaseManager.createTestDatabaseConnection(); + const reader: jest.Mocked = { read: jest.fn() }; + const env: CatalogEnvironment = { + logger: getVoidLogger(), + database: { getClient: () => db }, + config: ConfigReader.fromConfigs([]), + reader, + }; + + afterEach(() => jest.resetAllMocks()); + + it('works with no changes', async () => { + const builder = new CatalogBuilder(env); + const built = await builder.build(); + await expect(built.entitiesCatalog.entities()).resolves.toEqual([]); + await expect(built.locationsCatalog.locations()).resolves.toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ type: 'bootstrap' }), + }), + ]); + }); + + it('works with everything replaced', async () => { + reader.read.mockResolvedValue(Buffer.from('junk')); + + const builder = new CatalogBuilder(env) + .replaceReaderProcessors([ + { + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ) { + expect(location.type).toBe('test'); + emit(result.data(location, await reader.read('ignored'))); + return true; + }, + }, + ]) + .replaceParserProcessors([ + { + async parseData( + data: Buffer, + location: LocationSpec, + emit: CatalogProcessorEmit, + ) { + expect(data.toString()).toEqual('junk'); + emit( + result.entity(location, { + apiVersion: 'av', + kind: 'Component', + metadata: { name: 'n' }, + }), + ); + return true; + }, + }, + ]) + .replacePreProcessors([ + { + async processEntity(entity: Entity) { + expect(entity.apiVersion).toBe('av'); + return { + ...entity, + metadata: { ...entity.metadata, namespace: 'ns' }, + }; + }, + }, + ]) + .replaceEntityPolicies([ + { + async enforce(entity: Entity) { + expect(entity.metadata.namespace).toBe('ns'); + return entity; + }, + }, + ]) + .replaceEntityKinds([ + { + async enforce(entity: Entity) { + expect(entity.metadata.namespace).toBe('ns'); + return entity; + }, + }, + ]) + .replacePostProcessors([ + { + async processEntity(entity: Entity) { + return { + ...entity, + metadata: { ...entity.metadata, post: 'p' }, + }; + }, + }, + ]); + const out = await builder.build(); + + const added = await out.higherOrderOperation.addLocation({ + type: 'test', + target: '', + }); + expect(added.entities).toEqual([ + { + apiVersion: 'av', + kind: 'Component', + metadata: expect.objectContaining({ + name: 'n', + namespace: 'ns', + post: 'p', + }), + }, + ]); + }); +}); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts new file mode 100644 index 0000000000..aa4f97258d --- /dev/null +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -0,0 +1,523 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; +import { + apiEntityV1alpha1Policy, + componentEntityV1alpha1Policy, + DefaultNamespaceEntityPolicy, + EntityPolicies, + EntityPolicy, + FieldFormatEntityPolicy, + groupEntityV1alpha1Policy, + locationEntityV1alpha1Policy, + makeValidator, + NoForeignRootFieldsEntityPolicy, + ReservedFieldsEntityPolicy, + SchemaValidEntityPolicy, + templateEntityV1alpha1Policy, + userEntityV1alpha1Policy, + Validators, +} from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import lodash from 'lodash'; +import { Logger } from 'winston'; +import { + DatabaseEntitiesCatalog, + DatabaseLocationsCatalog, + EntitiesCatalog, + LocationsCatalog, +} from '../catalog'; +import { DatabaseManager } from '../database'; +import { + AnnotateLocationEntityProcessor, + AzureApiReaderProcessor, + BitbucketApiReaderProcessor, + CatalogProcessor, + CodeOwnersProcessor, + EntityPolicyProcessor, + FileReaderProcessor, + GithubOrgReaderProcessor, + GithubReaderProcessor, + GitlabApiReaderProcessor, + GitlabReaderProcessor, + HigherOrderOperation, + HigherOrderOperations, + LocationReaders, + LocationRefProcessor, + PlaceholderProcessor, + PlaceholderResolver, + StaticLocationProcessor, + UrlReaderProcessor, + YamlProcessor, +} from '../ingestion'; +import { CatalogRulesEnforcer } from '../ingestion/CatalogRules'; +import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor'; +import { + jsonPlaceholderResolver, + textPlaceholderResolver, + yamlPlaceholderResolver, +} from '../ingestion/processors/PlaceholderProcessor'; + +export type CatalogEnvironment = { + logger: Logger; + database: PluginDatabaseManager; + config: Config; + reader: UrlReader; +}; + +/** + * A builder that helps wire up all of the component parts of the catalog. + * + * The touch points where you can replace or extend behavior are as follows: + * + * - Reader processors can be added or replaced. These implement the + * functionality of reading raw data from a location, in the form of an + * entity definition file. + * - Parser processors can be added or replaced. These accept the raw data as + * read by the previous processors and parse it into raw structured data + * (for example, from a binary buffer containing yaml text, to a JS object + * structure). + * - Placeholder resolvers can be replaced or added. These run on the raw + * structured data between the parsing and pre-processing steps, to replace + * dollar-prefixed entries with their actual values (like $file). + * - Pre-processors can be added or replaced. These take the raw unvalidated + * data from the parser processors and can enrich or extend it before + * validation. This is the place where for example codeowners data can be + * injected into partial entity definitions. + * - Entity policies can be added or replaced. These are the first line of + * validation from the output of the pre-processing step. All policies are + * given the chance to inspect the entity, and all of them have to pass in + * order for the entity to be considered valid from an overall point of + * view. + * - Field format validators can be replaced. These check the format of + * individual core fields such as metadata.name, such that they adhere to + * certain rules. + * - Entity kinds can be added or replaced. These are the second line of + * validation that is applied after the entity policies, which add additional + * kind-specific validation (usually based on a schema). Only one of the + * entity kinds has to accept the entity, but if none of them do, the + * entity is rejected as a whole. + * - Post-processors can be added or replaced. These take the validated + * entities out of the validation step and can perform additional actions on + * them. + */ +export class CatalogBuilder { + private readonly env: CatalogEnvironment; + private entityPolicies: EntityPolicy[]; + private entityPoliciesReplace: boolean; + private entityKinds: EntityPolicy[]; + private entityKindsReplace: boolean; + private readerProcessors: CatalogProcessor[]; + private readerProcessorsReplace: boolean; + private parserProcessors: CatalogProcessor[]; + private parserProcessorsReplace: boolean; + private preProcessors: CatalogProcessor[]; + private preProcessorsReplace: boolean; + private postProcessors: CatalogProcessor[]; + private postProcessorsReplace: boolean; + private placeholderResolvers: Record; + private fieldFormatValidators: Partial; + + constructor(env: CatalogEnvironment) { + this.env = env; + this.entityPolicies = []; + this.entityPoliciesReplace = false; + this.entityKinds = []; + this.entityKindsReplace = false; + this.readerProcessors = []; + this.readerProcessorsReplace = false; + this.parserProcessors = []; + this.parserProcessorsReplace = false; + this.preProcessors = []; + this.preProcessorsReplace = false; + this.postProcessors = []; + this.postProcessorsReplace = false; + this.placeholderResolvers = {}; + this.fieldFormatValidators = {}; + } + + /** + * Adds policies that are used to validate entities between the pre- + * processing and post-processing stages. All such policies must pass for the + * entity to be considered valid. + * + * If what you want to do is to replace the rules for what format is allowed + * in various core entity fields (such as metadata.name), you may want to use + * {@link CatalogBuilder#setFieldFormatValidators} instead. + * + * @param policies One or more policies + */ + addEntityPolicy(...policies: EntityPolicy[]): CatalogBuilder { + this.entityPolicies.push(...policies); + return this; + } + + /** + * Sets what policies to use for validation of entities between the pre- + * processing and post-processing stages. All such policies must pass for the + * entity to be considered valid. + * + * If what you want to do is to replace the rules for what format is allowed + * in various core entity fields (such as metadata.name), you may want to use + * {@link CatalogBuilder#setFieldFormatValidators} instead. + * + * This function replaces the default set of policies; use with care. + * + * @param policies One or more policies + */ + replaceEntityPolicies(policies: EntityPolicy[]): CatalogBuilder { + this.entityPolicies = [...policies]; + this.entityPoliciesReplace = true; + return this; + } + + /** + * Adds entity kinds that are used to validate a certain apiVersion/kind. One + * of the entity kind policies must match a given entity for it to be + * considered valid. + * + * @param policies One or more policies + */ + addEntityKind(...policies: EntityPolicy[]): CatalogBuilder { + this.entityKinds.push(...policies); + return this; + } + + /** + * Sets what entity policies that are used to validate a certain apiVersion/ + * kind. One of the entity kind policies must match a given entity for it to + * be considered valid. + * + * This function replaces the default set of kinds; use with care. + * + * @param policies One or more policies + */ + replaceEntityKinds(policies: EntityPolicy[]): CatalogBuilder { + this.entityKinds = [...policies]; + this.entityKindsReplace = true; + return this; + } + + /** + * Adds processors that support reading of definition files. These are run + * before the entities are parsed, pre-processed, validated and post- + * processed. + * + * @param processors One or more processors + */ + addReaderProcessor(...processors: CatalogProcessor[]): CatalogBuilder { + this.readerProcessors.push(...processors); + return this; + } + + /** + * Sets what processors to use for the reading of definition files. These are + * run before the entities are parsed, pre-processed, validated and post- + * processed. + * + * This function replaces the default set of processors in this stage; use + * with care. + * + * @param processors One or more processors + */ + replaceReaderProcessors(processors: CatalogProcessor[]): CatalogBuilder { + this.readerProcessors = [...processors]; + this.readerProcessorsReplace = true; + return this; + } + + /** + * Adds processors that run after each definition file has been read, in + * order to parse the raw data. These are run before the entities are + * pre-processed, validated and post-processed. + * + * @param processors One or more processors + */ + addParserProcessor(...processors: CatalogProcessor[]): CatalogBuilder { + this.parserProcessors.push(...processors); + return this; + } + + /** + * Sets what processors to run after each definition file has been read, in + * order to parse the raw data. These are run before the entities are + * pre-processed, validated and post-processed. + * + * This function replaces the default set of processors in this stage; use + * with care. + * + * @param processors One or more processors + */ + replaceParserProcessors(processors: CatalogProcessor[]): CatalogBuilder { + this.parserProcessors = [...processors]; + this.parserProcessorsReplace = true; + return this; + } + + /** + * Adds processors that run after each entity has been read and parsed, + * but before being validated and post-processed. + * + * @param processors One or more processors + */ + addPreProcessor(...processors: CatalogProcessor[]): CatalogBuilder { + this.preProcessors.push(...processors); + return this; + } + + /** + * Sets what processors to run after each entity has been read and parsed, + * but before being validated and post-processed. + * + * This function replaces the default set of processors in this stage; use + * with care. + * + * @param processors One or more processors + */ + replacePreProcessors(processors: CatalogProcessor[]): CatalogBuilder { + this.preProcessors = [...processors]; + this.preProcessorsReplace = true; + return this; + } + + /** + * Adds processors that run after each entity has been read, parsed, + * run through the pre-processors, and validated. + * + * @param processors One or more processors + */ + addPostProcessor(...processors: CatalogProcessor[]): CatalogBuilder { + this.postProcessors.push(...processors); + return this; + } + + /** + * Sets what processors to run after each entity has been read, parsed, + * run through the pre-processors, and validated. + * + * This function replaces the default set of processors in this stage; use + * with care. + * + * @param processors One or more processors + */ + replacePostProcessors(processors: CatalogProcessor[]): CatalogBuilder { + this.postProcessors = [...processors]; + this.postProcessorsReplace = true; + return this; + } + + /** + * Adds, or overwrites, a handler for placeholders (e.g. $file) in entity + * definition files. + * + * @param key The key that identifies the placeholder, e.g. "file" + * @param resolver The resolver that gets values for this placeholder + */ + setPlaceholderResolver(key: string, resolver: PlaceholderResolver) { + this.placeholderResolvers[key] = resolver; + } + + /** + * Sets the validator function to use for one or more special fields of an + * entity. This is useful if the default rules for formatting of fields are + * not sufficient. + * + * This function has no effect if used together with + * {@link CatalogBuilder#replaceEntityPolicies}. + * + * @param validators The (subset of) validators to set + */ + setFieldFormatValidators(validators: Partial) { + lodash.merge(this.fieldFormatValidators, validators); + } + + /** + * Wires up and returns all of the component parts of the catalog + */ + async build(): Promise<{ + entitiesCatalog: EntitiesCatalog; + locationsCatalog: LocationsCatalog; + higherOrderOperation: HigherOrderOperation; + }> { + const { config, database, logger } = this.env; + + const entityPolicy = this.buildEntityPolicy(); + const processors = this.buildProcessors(entityPolicy); + const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config); + + const locationReader = new LocationReaders({ + ...this.env, + processors, + rulesEnforcer, + }); + + const db = await DatabaseManager.createDatabase( + await database.getClient(), + { logger }, + ); + + const entitiesCatalog = new DatabaseEntitiesCatalog(db, this.env.logger); + const locationsCatalog = new DatabaseLocationsCatalog(db); + const higherOrderOperation = new HigherOrderOperations( + entitiesCatalog, + locationsCatalog, + locationReader, + logger, + ); + + return { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + }; + } + + private buildEntityPolicy(): EntityPolicy { + const entityPolicies: EntityPolicy[] = this.entityPoliciesReplace + ? [new SchemaValidEntityPolicy(), ...this.entityPolicies] + : [ + new SchemaValidEntityPolicy(), + new DefaultNamespaceEntityPolicy(), + new NoForeignRootFieldsEntityPolicy(), + new FieldFormatEntityPolicy( + makeValidator(this.fieldFormatValidators), + ), + new ReservedFieldsEntityPolicy(), + ...this.entityPolicies, + ]; + + const entityKinds: EntityPolicy[] = this.entityKindsReplace + ? this.entityKinds + : [ + componentEntityV1alpha1Policy, + groupEntityV1alpha1Policy, + userEntityV1alpha1Policy, + locationEntityV1alpha1Policy, + templateEntityV1alpha1Policy, + apiEntityV1alpha1Policy, + ...this.entityKinds, + ]; + + return EntityPolicies.allOf([ + EntityPolicies.allOf(entityPolicies), + EntityPolicies.oneOf(entityKinds), + ]); + } + + private buildProcessors(entityPolicy: EntityPolicy): CatalogProcessor[] { + const { config, reader } = this.env; + + const placeholderResolvers = lodash.merge( + { + json: jsonPlaceholderResolver, + yaml: yamlPlaceholderResolver, + text: textPlaceholderResolver, + }, + this.placeholderResolvers, + ); + + return [ + StaticLocationProcessor.fromConfig(config), + ...this.buildReaderProcessors(), + ...this.buildParserProcessors(), + new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }), + ...this.buildPreProcessors(), + new EntityPolicyProcessor(entityPolicy), + ...this.buildPostProcessors(), + ]; + } + + private buildReaderProcessors(): CatalogProcessor[] { + const { config, logger, reader } = this.env; + + if (this.readerProcessorsReplace) { + return this.readerProcessors; + } + + // TODO(Rugvip): These are added for backwards compatibility if config exists + // The idea is to have everyone migrate from using the old processors to the new + // integration config driven UrlReaders. In an upcoming release we can then completely + // remove support for the old processors, but still keep handling the deprecated location + // types for a while, but with a warning. + const oldProcessors = []; + const pc = config.getOptionalConfig('catalog.processors'); + if (pc?.has('github')) { + logger.warn( + `Using deprecated configuration for catalog.processors.github, move to using integrations.github instead`, + ); + oldProcessors.push(GithubReaderProcessor.fromConfig(config, logger)); + } + if (pc?.has('gitlabApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.gitlabApi, move to using integrations.gitlab instead`, + ); + oldProcessors.push(new GitlabApiReaderProcessor(config)); + oldProcessors.push(new GitlabReaderProcessor()); + } + if (pc?.has('bitbucketApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.bitbucketApi, move to using integrations.bitbucket instead`, + ); + oldProcessors.push(new BitbucketApiReaderProcessor(config)); + } + if (pc?.has('azureApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.azureApi, move to using integrations.azure instead`, + ); + oldProcessors.push(new AzureApiReaderProcessor(config)); + } + + return [ + new FileReaderProcessor(), + ...oldProcessors, + GithubOrgReaderProcessor.fromConfig(config, { logger }), + LdapOrgReaderProcessor.fromConfig(config, { logger }), + new UrlReaderProcessor({ reader, logger }), + ...this.readerProcessors, + ]; + } + + private buildParserProcessors(): CatalogProcessor[] { + if (this.parserProcessorsReplace) { + return this.parserProcessors; + } + + return [new YamlProcessor(), ...this.parserProcessors]; + } + + private buildPreProcessors(): CatalogProcessor[] { + const { reader } = this.env; + + if (this.preProcessorsReplace) { + return this.preProcessors; + } + + return [new CodeOwnersProcessor({ reader }), ...this.preProcessors]; + } + + private buildPostProcessors(): CatalogProcessor[] { + if (this.postProcessorsReplace) { + return this.postProcessors; + } + + return [ + new LocationRefProcessor(), + new AnnotateLocationEntityProcessor(), + ...this.postProcessors, + ]; + } +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/types.ts b/plugins/catalog-backend/src/service/index.ts similarity index 81% rename from plugins/kubernetes-backend/src/cluster-locator/types.ts rename to plugins/catalog-backend/src/service/index.ts index dae8eb6d60..c6da8db486 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/types.ts +++ b/plugins/catalog-backend/src/service/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ -export type ClusterLocatorMethod = 'configMultiTenant' | 'http'; -export type AuthProviderType = 'google' | 'serviceAccount'; +export { CatalogBuilder } from './CatalogBuilder'; +export { createRouter } from './router'; +export type { RouterOptions } from './router'; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index ecf4b0e614..414f8a93de 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -32,11 +32,10 @@ describe('createRouter', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - entityByUid: jest.fn(), - entityByName: jest.fn(), addOrUpdateEntity: jest.fn(), addEntities: jest.fn(), removeEntityByUid: jest.fn(), + batchAddOrUpdateEntities: jest.fn(), }; locationsCatalog = { addLocation: jest.fn(), @@ -83,11 +82,11 @@ describe('createRouter', () => { expect(response.status).toEqual(200); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith([ - { key: 'a', values: ['1', null, '3'] }, - { key: 'b', values: ['4'] }, - { key: 'c', values: [null] }, - ]); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + a: ['1', null, '3'], + b: ['4'], + c: [null], + }); }); }); @@ -100,23 +99,27 @@ describe('createRouter', () => { name: 'c', }, }; - entitiesCatalog.entityByUid.mockResolvedValue(entity); + entitiesCatalog.entities.mockResolvedValue([entity]); const response = await request(app).get('/entities/by-uid/zzz'); - expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz'); + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + 'metadata.uid': 'zzz', + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); it('responds with a 404 for missing entities', async () => { - entitiesCatalog.entityByUid.mockResolvedValue(undefined); + entitiesCatalog.entities.mockResolvedValue([]); const response = await request(app).get('/entities/by-uid/zzz'); - expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz'); + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + 'metadata.uid': 'zzz', + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/uid/); }); @@ -132,30 +135,30 @@ describe('createRouter', () => { namespace: 'ns', }, }; - entitiesCatalog.entityByName.mockResolvedValue(entity); + entitiesCatalog.entities.mockResolvedValue([entity]); const response = await request(app).get('/entities/by-name/k/ns/n'); - expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ kind: 'k', - namespace: 'ns', - name: 'n', + 'metadata.namespace': 'ns', + 'metadata.name': 'n', }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); it('responds with a 404 for missing entities', async () => { - entitiesCatalog.entityByName.mockResolvedValue(undefined); + entitiesCatalog.entities.mockResolvedValue([]); const response = await request(app).get('/entities/by-name/b/d/c'); - expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ kind: 'b', - namespace: 'd', - name: 'c', + 'metadata.namespace': 'd', + 'metadata.name': 'c', }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 8da589c721..a262e9b5f4 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -54,11 +54,13 @@ export async function createRouter( }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; - const entity = await entitiesCatalog.entityByUid(uid); - if (!entity) { + const entities = await entitiesCatalog.entities({ + 'metadata.uid': uid, + }); + if (!entities.length) { res.status(404).send(`No entity with uid ${uid}`); } - res.status(200).send(entity); + res.status(200).send(entities[0]); }) .delete('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; @@ -67,19 +69,19 @@ export async function createRouter( }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const entity = await entitiesCatalog.entityByName({ - kind, - namespace, - name, + const entities = await entitiesCatalog.entities({ + kind: kind, + 'metadata.namespace': namespace, + 'metadata.name': name, }); - if (!entity) { + if (!entities.length) { res .status(404) .send( `No entity with kind ${kind} namespace ${namespace} name ${name}`, ); } - res.status(200).send(entity); + res.status(200).send(entities[0]); }); } @@ -121,7 +123,7 @@ export async function createRouter( function translateQueryToEntityFilters( request: express.Request, ): EntityFilters { - const filters: EntityFilters = []; + const filters: Record = {}; for (const [key, valueOrValues] of Object.entries(request.query)) { const values = Array.isArray(valueOrValues) @@ -132,10 +134,8 @@ function translateQueryToEntityFilters( throw new InputError('Complex query parameters are not supported'); } - filters.push({ - key, - values: values.map(v => v || null) as string[], - }); + const matchers = key in filters ? filters[key] : (filters[key] = []); + matchers.push(...(values.map(v => v || null) as (string | null)[])); } return filters; diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 5939c7eb58..713a5783ba 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -18,16 +18,14 @@ import { createServiceBuilder, loadBackendConfig, UrlReaders, + useHotMemoize, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { Server } from 'http'; import { Logger } from 'winston'; -import { HigherOrderOperations } from '..'; -import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog'; -import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog'; -import { DatabaseManager } from '../database/DatabaseManager'; +import { DatabaseManager } from '../database'; +import { CatalogBuilder } from './CatalogBuilder'; import { createRouter } from './router'; -import { LocationReaders } from '../ingestion'; export interface ServerOptions { port: number; @@ -41,18 +39,22 @@ export async function startStandaloneServer( const logger = options.logger.child({ service: 'catalog-backend' }); const config = ConfigReader.fromConfigs(await loadBackendConfig()); const reader = UrlReaders.default({ logger, config }); + const db = useHotMemoize(module, () => + DatabaseManager.createInMemoryDatabaseConnection(), + ); logger.debug('Creating application...'); - const db = await DatabaseManager.createInMemoryDatabase({ logger }); - const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); - const locationReader = new LocationReaders({ logger, config, reader }); - const higherOrderOperation = new HigherOrderOperations( + const builder = new CatalogBuilder({ + logger, + database: { getClient: () => db }, + config, + reader, + }); + const { entitiesCatalog, locationsCatalog, - locationReader, - logger, - ); + higherOrderOperation, + } = await builder.build(); logger.debug('Starting application server...'); const router = await createRouter({ diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 92f51ace06..298aeacfec 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -15,11 +15,11 @@ */ import { + configApiRef, Header, HomepageTimer, identityApiRef, Page, - pageTheme, useApi, } from '@backstage/core'; import React from 'react'; @@ -33,12 +33,13 @@ const CatalogLayout = ({ children }: Props) => { const greeting = getTimeBasedGreeting(); const profile = useApi(identityApiRef).getProfile(); const userId = useApi(identityApiRef).getUserId(); + const orgName = useApi(configApiRef).getOptionalString('organization.name'); return ( - +
diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 6a1b80150e..e3a225a03e 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -17,15 +17,7 @@ import React, { useState, useContext } from 'react'; import { useParams, useNavigate } from 'react-router'; import { EntityContext } from '../../hooks/useEntity'; -import { - pageTheme, - PageTheme, - Page, - Header, - HeaderLabel, - Content, - Progress, -} from '@backstage/core'; +import { Page, Header, HeaderLabel, Content, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { Box } from '@material-ui/core'; @@ -34,11 +26,6 @@ import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEnti import { Alert } from '@material-ui/lab'; import { Tabbed } from './Tabbed'; -const getPageTheme = (entity?: Entity): PageTheme => { - const themeKey = entity?.spec?.type?.toString() ?? 'home'; - return pageTheme[themeKey] ?? pageTheme.home; -}; - const EntityPageTitle = ({ entity, title, @@ -100,7 +87,7 @@ export const EntityPageLayout = ({ const showRemovalDialog = () => setConfirmationDialogOpen(true); return ( - +
} pageTitleOverride={headerTitle} diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index e4eec3c21f..258cc38d15 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -85,7 +85,7 @@ export const UnregisterEntityDialog: FC = ({ {error.toString()} ) : null} - {entities ? ( + {entities?.length ? ( <> This action will unregister the following entities: @@ -107,11 +107,11 @@ export const UnregisterEntityDialog: FC = ({ - - To undo, just re-register the entity in Backstage. - ) : null} + + To undo, just re-register the entity in Backstage. + - All your software catalog entities - - - - - ); -}; +export const ProjectListPage = () => ( + +
+ {labels} +
+ + + + All your software catalog entities + + + +
+); diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index baab018415..70bf4c8c3f 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -21,7 +21,6 @@ import { Header, SupportButton, Page, - pageTheme, Progress, HeaderLabel, useApi, @@ -91,7 +90,7 @@ const ClusterList: FC<{}> = () => { } return ( - +
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index b2d62e8650..9b6e5bdb08 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React, { FC, useEffect, useState } from 'react'; import { Content, Header, Page, - pageTheme, Table, Progress, HeaderLabel, @@ -85,7 +83,7 @@ const ClusterPage: FC<{}> = () => { }, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]); return ( - +
diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx index a5125fb006..a70cf97342 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx @@ -18,7 +18,6 @@ import React, { FC, useEffect, useState } from 'react'; import { Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -259,7 +258,7 @@ const ProfileCatalog: FC<{}> = () => { ]; return ( - +
({ describe('GraphiQLPage', () => { it('should show progress', async () => { + jest.useFakeTimers(); const loadingApi: GraphQLBrowseApi = { async getEndpoints() { await new Promise(() => {}); @@ -43,9 +45,12 @@ describe('GraphiQLPage', () => { , , ); - + act(() => { + jest.advanceTimersByTime(250); + }); rendered.getByText('GraphiQL'); rendered.getByTestId('progress'); + jest.useRealTimers(); }); it('should show error', async () => { diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx index 7eb91b59d8..224def1d78 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React from 'react'; import { Content, @@ -21,7 +20,6 @@ import { HeaderLabel, Page, Progress, - pageTheme, useApi, } from '@backstage/core'; import { useAsync } from 'react-use'; @@ -60,7 +58,7 @@ export const GraphiQLPage = () => { } return ( - +
diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 565906ca85..1d3e7f60e5 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -33,7 +33,7 @@ proxy: $env: JENKINS_BASIC_AUTH_HEADER ``` -4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password) +4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password). Here user is the name of the user created in Jenkins. ```shell HEADER=$(echo -n user:api-token | base64) @@ -61,6 +61,21 @@ spec: 8. Click the component in the catalog you should now see Jenkins builds, and a last build result for your master build. +Note: + +If you are not using environment variable then you can directly type API token in app-config.yaml + +```yaml +proxy: + '/jenkins/api': + target: 'http://localhost:8080' # your Jenkins URL + changeOrigin: true + headers: + Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== +``` + +YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== is the base64 of user and it's API token e.g. admin:11ec256e438501c3f5c76b751a7e47af83 + ## Features - View all runs inside a folder diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md index fbbde1fe65..a9fdc14707 100644 --- a/plugins/kubernetes-backend/README.md +++ b/plugins/kubernetes-backend/README.md @@ -8,21 +8,33 @@ It responds to Kubernetes requests from the frontend. ## Configuration -### clusterLocatorMethod +### serviceLocatorMethod This configures how to determine which clusters a component is running in. -Currently, the only valid locator method is: +Currently, the only valid serviceLocatorMethod is: -#### configMultiTenant +#### multiTenant This configuration assumes that all components run on all the provided clusters. +### clusterLocatorMethods + +This is used to determine where to retrieve cluster configuration from. + +Currently, the only valid serviceLocatorMethod is: + +#### config + +This clusterLocatorMethod will read cluster information in from config + Example: ```yaml kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: - url: http://127.0.0.1:9999 name: minikube @@ -35,7 +47,7 @@ kubernetes: ##### clusters -Used by the `configMultiTenant` `clusterLocatorMethod` to construct Kubernetes clients. +Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients. ###### url diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index f97f760c9f..774867de04 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -27,7 +27,9 @@ Update `app-config.development.yaml` as follows. ```yaml kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: - url: name: minikube diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts similarity index 82% rename from plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts rename to plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index dd77a11bae..550703e430 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -15,10 +15,10 @@ */ import '@backstage/backend-common'; -import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator'; import { ConfigReader, Config } from '@backstage/config'; +import { ConfigClusterLocator } from './ConfigClusterLocator'; -describe('MultiTenantConfigClusterLocator', () => { +describe('ConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { const config: Config = new ConfigReader( { @@ -27,11 +27,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([]); }); @@ -50,11 +50,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([ { @@ -86,11 +86,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([ { diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts similarity index 65% rename from plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts rename to plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 5a1f0f3839..5ff4581f71 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -14,23 +14,20 @@ * limitations under the License. */ +import { ClusterDetails, KubernetesClustersSupplier } from '..'; import { Config } from '@backstage/config'; -import { ClusterDetails, KubernetesClusterLocator } from '..'; -// This cluster locator assumes that every service is located on every cluster -// Therefore it will always return all clusters in an app configuration file -export class MultiTenantConfigClusterLocator - implements KubernetesClusterLocator { +export class ConfigClusterLocator implements KubernetesClustersSupplier { private readonly clusterDetails: ClusterDetails[]; constructor(clusterDetails: ClusterDetails[]) { this.clusterDetails = clusterDetails; } - static fromConfig(config: Config[]): MultiTenantConfigClusterLocator { + static fromConfig(config: Config[]): ConfigClusterLocator { // TODO: Add validation that authProvider is required and serviceAccountToken // is required if authProvider is serviceAccount - return new MultiTenantConfigClusterLocator( + return new ConfigClusterLocator( config.map(c => { return { name: c.getString('name'), @@ -42,9 +39,7 @@ export class MultiTenantConfigClusterLocator ); } - // As this implementation always returns all clusters serviceId is ignored here - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getClusterByServiceId(_serviceId: string): Promise { + async getClusters(): Promise { return this.clusterDetails; } } diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts new file mode 100644 index 0000000000..32a09cb496 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, ConfigReader } from '../../../../packages/config/src'; +import { getCombinedClusterDetails } from './index'; + +describe('getCombinedClusterDetails', () => { + it('should retrieve cluster details from config', async () => { + const config: Config = new ConfigReader( + { + kubernetes: { + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], + }, + }, + 'ctx', + ); + + const result = await getCombinedClusterDetails(['config'], config); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + serviceAccountToken: undefined, + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + }); + + it('throws an error when using an unsupported cluster locator', async () => { + await expect( + getCombinedClusterDetails(['magic' as any], new ConfigReader({}, 'ctx')), + ).rejects.toStrictEqual( + 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 new file mode 100644 index 0000000000..ea418768b4 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ClusterDetails, ClusterLocatorMethod } from '..'; +import { Config } from '../../../../packages/config/src'; +import { ConfigClusterLocator } from './ConfigClusterLocator'; + +export { ConfigClusterLocator } from './ConfigClusterLocator'; + +export const getCombinedClusterDetails = async ( + clusterLocatorMethods: ClusterLocatorMethod[], + rootConfig: Config, +): Promise => { + return Promise.all( + clusterLocatorMethods.map(clusterLocatorMethod => { + switch (clusterLocatorMethod) { + case 'config': + return ConfigClusterLocator.fromConfig( + rootConfig.getConfigArray('kubernetes.clusters'), + ).getClusters(); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${clusterLocatorMethod}"`, + ); + } + }), + ) + .then(res => { + return res.flat(); + }) + .catch(e => { + throw e; + }); +}; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts new file mode 100644 index 0000000000..13761e2671 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 '@backstage/backend-common'; +import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; + +describe('MultiTenantConfigClusterLocator', () => { + it('empty clusters returns empty cluster details', async () => { + const sut = new MultiTenantServiceLocator([]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([]); + }); + + it('one clusters returns one cluster details', async () => { + const sut = new MultiTenantServiceLocator([ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + serviceAccountToken: '12345', + }, + ]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: '12345', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + ]); + }); + + it('two clusters returns two cluster details', async () => { + const sut = new MultiTenantServiceLocator([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + }); +}); diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts new file mode 100644 index 0000000000..a5822a2078 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ClusterDetails, KubernetesServiceLocator } from '..'; + +// 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[]; + + constructor(clusterDetails: ClusterDetails[]) { + this.clusterDetails = clusterDetails; + } + + // 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; + } +} diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 4218c64e80..8c26ded2d4 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -22,7 +22,7 @@ const TEST_SERVICE_ID = 'my-service'; const fetchObjectsByServiceId = jest.fn(); -const getClusterByServiceId = jest.fn(); +const getClustersByServiceId = jest.fn(); const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) => @@ -70,7 +70,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }); it('retrieve objects for one cluster', async () => { - getClusterByServiceId.mockImplementation(() => + getClustersByServiceId.mockImplementation(() => Promise.resolve([ { name: 'test-cluster', @@ -87,13 +87,13 @@ describe('handleGetKubernetesObjectsByServiceId', () => { fetchObjectsByServiceId, }, { - getClusterByServiceId, + getClustersByServiceId, }, getVoidLogger(), {}, ); - expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsByServiceId.mock.calls.length).toBe(1); expect(result).toStrictEqual({ items: [ @@ -140,7 +140,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }); it('retrieve objects for two clusters', async () => { - getClusterByServiceId.mockImplementation(() => + getClustersByServiceId.mockImplementation(() => Promise.resolve([ { name: 'test-cluster', @@ -161,7 +161,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { fetchObjectsByServiceId, }, { - getClusterByServiceId, + getClustersByServiceId, }, getVoidLogger(), { @@ -171,7 +171,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }, ); - expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsByServiceId.mock.calls.length).toBe(2); expect(result).toStrictEqual({ items: [ diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index f0b21cbc0c..6fc6092bd6 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -18,7 +18,7 @@ import { Logger } from 'winston'; import { AuthRequestBody, ClusterDetails, - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, KubernetesObjectTypes, ObjectsByServiceIdResponse, @@ -29,7 +29,7 @@ import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator export type GetKubernetesObjectsByServiceIdHandler = ( serviceId: string, fetcher: KubernetesFetcher, - clusterLocator: KubernetesClusterLocator, + serviceLocator: KubernetesServiceLocator, logger: Logger, requestBody: AuthRequestBody, objectsToFetch?: Set, @@ -49,12 +49,12 @@ const DEFAULT_OBJECTS = new Set([ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( serviceId, fetcher, - clusterLocator, + serviceLocator, logger, requestBody, objectsToFetch = DEFAULT_OBJECTS, ) => { - const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId( + const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId( serviceId, ); diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 4e9a206eb4..6f9fbbf70f 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -19,7 +19,7 @@ import express from 'express'; import request from 'supertest'; import { makeRouter } from './router'; import { - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, ObjectsByServiceIdResponse, } from '..'; @@ -27,7 +27,7 @@ import { describe('router', () => { let app: express.Express; let kubernetesFetcher: jest.Mocked; - let kubernetesClusterLocator: jest.Mocked; + let kubernetesServiceLocator: jest.Mocked; let handleGetByServiceId: jest.Mock>; beforeAll(async () => { @@ -35,8 +35,8 @@ describe('router', () => { fetchObjectsByServiceId: jest.fn(), }; - kubernetesClusterLocator = { - getClusterByServiceId: jest.fn(), + kubernetesServiceLocator = { + getClustersByServiceId: jest.fn(), }; handleGetByServiceId = jest.fn(); @@ -44,7 +44,7 @@ describe('router', () => { const router = makeRouter( getVoidLogger(), kubernetesFetcher, - kubernetesClusterLocator, + kubernetesServiceLocator, handleGetByServiceId as any, ); app = express().use(router); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 9e9b4ab2f5..b73115e1e4 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -18,8 +18,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { ClusterLocatorMethod } from '../cluster-locator/types'; -import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator'; +import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesClientProvider } from './KubernetesClientProvider'; import { @@ -28,30 +27,35 @@ import { } from './getKubernetesObjectsByServiceIdHandler'; import { AuthRequestBody, - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, -} from '../types/types'; + ServiceLocatorMethod, + ClusterLocatorMethod, + ClusterDetails, +} from '..'; +import { getCombinedClusterDetails } from '../cluster-locator'; export interface RouterOptions { logger: Logger; config: Config; } -const getClusterLocator = (config: Config): KubernetesClusterLocator => { - const clusterLocatorMethod = config.getString( - 'kubernetes.clusterLocatorMethod', - ) as ClusterLocatorMethod; +const getServiceLocator = ( + config: Config, + clusterDetails: ClusterDetails[], +): KubernetesServiceLocator => { + const serviceLocatorMethod = config.getString( + 'kubernetes.serviceLocatorMethod', + ) as ServiceLocatorMethod; - switch (clusterLocatorMethod) { - case 'configMultiTenant': - return MultiTenantConfigClusterLocator.fromConfig( - config.getConfigArray('kubernetes.clusters'), - ); + switch (serviceLocatorMethod) { + case 'multiTenant': + return new MultiTenantServiceLocator(clusterDetails); case 'http': throw new Error('not implemented'); default: throw new Error( - `Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`, + `Unsupported kubernetes.clusterLocatorMethod "${serviceLocatorMethod}"`, ); } }; @@ -59,13 +63,12 @@ const getClusterLocator = (config: Config): KubernetesClusterLocator => { export const makeRouter = ( logger: Logger, fetcher: KubernetesFetcher, - clusterLocator: KubernetesClusterLocator, + serviceLocator: KubernetesServiceLocator, handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler, ): express.Router => { const router = Router(); router.use(express.json()); - // TODO error handling router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; const requestBody: AuthRequestBody = req.body; @@ -73,7 +76,7 @@ export const makeRouter = ( const response = await handleGetByServiceId( serviceId, fetcher, - clusterLocator, + serviceLocator, logger, requestBody, ); @@ -96,17 +99,26 @@ export async function createRouter( logger.info('Initializing Kubernetes backend'); - const clusterLocator = getClusterLocator(options.config); - const fetcher = new KubernetesClientBasedFetcher({ kubernetesClientProvider: new KubernetesClientProvider(), logger, }); + const clusterLocatorMethods = options.config.getStringArray( + 'kubernetes.clusterLocatorMethods', + ) as ClusterLocatorMethod[]; + + const clusterDetails = await getCombinedClusterDetails( + clusterLocatorMethods, + options.config, + ); + + const serviceLocator = getServiceLocator(options.config, clusterDetails); + return makeRouter( logger, fetcher, - clusterLocator, + serviceLocator, handleGetKubernetesObjectsByServiceId, ); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index eb2817f268..1422511c21 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -118,8 +118,13 @@ export interface KubernetesFetcher { } // Used to locate which cluster(s) a service is running on -export interface KubernetesClusterLocator { - getClusterByServiceId(serviceId: string): Promise; +export interface KubernetesServiceLocator { + getClustersByServiceId(serviceId: string): Promise; +} + +// Used to load cluster details from different sources +export interface KubernetesClustersSupplier { + getClusters(): Promise; } export type KubernetesErrorTypes = @@ -132,3 +137,7 @@ export interface KubernetesFetchError { statusCode?: number; resourcePath?: string; } + +export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http +export type ClusterLocatorMethod = 'config'; +export type AuthProviderType = 'google' | 'serviceAccount'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 013c7ad002..a3e82e79a4 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -22,7 +22,6 @@ import { configApiRef, Content, Page, - pageTheme, Progress, TabbedCard, useApi, @@ -148,7 +147,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; return ( - + {kubernetesObjects === undefined && error === undefined && ( diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index a75bb25a0d..c6411a9baa 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React, { useState, useMemo, FC, ReactNode } from 'react'; import { useLocalStorage, useAsync } from 'react-use'; import { useNavigate } from 'react-router-dom'; @@ -28,7 +27,6 @@ import { ContentHeader, HeaderLabel, Progress, - pageTheme, useApi, } from '@backstage/core'; @@ -95,7 +93,7 @@ const AuditList: FC<{}> = () => { } return ( - +
= () => { - {content} + {content} diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index 742be67fed..ca292a133d 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -163,7 +163,7 @@ describe('AuditView', () => { }); describe('when the request for the website by id is pending', () => { - it('it shows the loading', async () => { + it('shows the loading', async () => { mockFetch.mockImplementationOnce(() => new Promise(() => {})); const rendered = render( wrapInTestApp( @@ -177,7 +177,7 @@ describe('AuditView', () => { }); describe('when the request for the website by id fails', () => { - it('it shows an error', async () => { + it('shows an error', async () => { mockFetch.mockRejectOnce(new Error('failed to fetch')); const rendered = render( wrapInTestApp( diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 54bd179418..ac38a329f1 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -34,7 +34,6 @@ import { import Alert from '@material-ui/lab/Alert'; import { useApi, - pageTheme, InfoCard, Header, Page, @@ -193,7 +192,7 @@ export const AuditViewContent: FC<{}> = () => { }; const ConnectedAuditView = () => ( - +
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index ad46b06020..372992a718 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -30,7 +30,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -170,7 +169,7 @@ export const CreateAuditContent: FC<{}> = () => { }; const CreateAudit = () => ( - +
diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 868e26f04c..580162f182 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -75,7 +75,7 @@ describe('useWebsiteForEntity', () => { (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website); }); - it('returns the lighthouse information for the website url in annotations ', async () => { + it('returns the lighthouse information for the website url in annotations', async () => { const { result, waitForNextUpdate } = subject(); await waitForNextUpdate(); expect(result.current?.value).toBe(website); diff --git a/plugins/linkerd-backend/src/service/helpers.ts b/plugins/linkerd-backend/src/service/helpers.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/newrelic/README.md b/plugins/newrelic/README.md index 19bd24950c..e14acedef2 100644 --- a/plugins/newrelic/README.md +++ b/plugins/newrelic/README.md @@ -7,16 +7,38 @@ Website: [https://newrelic.com](https://newrelic.com) ## Getting Started -Add New Relic REST API Key to `app-config.yaml` +This plugin uses the Backstage proxy to securely communicate with New Relic's +APIs. Add the following to your `app-config.yaml` to enable this configuration: ```yaml -newrelic: - api: - baseUrl: 'https://api.newrelic.com/v2' - key: +proxy: + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY ``` -New Relic Plugin Path: [/newrelic](http://localhost:3000/newrelic) +In your production deployment of Backstage, you would also need to ensure that +you've set the `NEW_RELIC_REST_API_KEY` environment variable before starting +the backend. + +While working locally, you may wish to hard-code your API key in your +`app-config.local.yaml` like this: + +```yaml +# app-config.local.yaml +proxy: + '/newrelic/apm/api': + headers: + X-Api-Key: NRRA-YourActualApiKey +``` + +Read more about how to find or generate this key in +[New Relic's Documentation](https://docs.newrelic.com/docs/apis/get-started/intro-apis/types-new-relic-api-keys#rest-api-key). + +See if it's working by visiting the New Relic Plugin Path: +[/newrelic](http://localhost:3000/newrelic) ## Features diff --git a/plugins/newrelic/src/api/index.ts b/plugins/newrelic/src/api/index.ts new file mode 100644 index 0000000000..d76a87875c --- /dev/null +++ b/plugins/newrelic/src/api/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createApiRef, DiscoveryApi } from '@backstage/core'; + +export type NewRelicApplication = { + id: number; + application_summary: NewRelicApplicationSummary; + name: string; + language: string; + health_status: string; + reporting: boolean; + settings: NewRelicApplicationSettings; + links?: NewRelicApplicationLinks; +}; + +export type NewRelicApplicationSummary = { + apdex_score: number; + error_rate: number; + host_count: number; + instance_count: number; + response_time: number; + throughput: number; +}; + +export type NewRelicApplicationSettings = { + app_apdex_threshold: number; + end_user_apdex_threshold: number; + enable_real_user_monitoring: boolean; + use_server_side_config: boolean; +}; + +export type NewRelicApplicationLinks = { + application_instances: Array; + servers: Array; + application_hosts: Array; +}; + +export type NewRelicApplications = { + applications: NewRelicApplication[]; +}; + +export const newRelicApiRef = createApiRef({ + id: 'plugin.newrelic.service', + description: 'Used by the NewRelic plugin to make requests', +}); + +const DEFAULT_PROXY_PATH_BASE = '/newrelic'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /newrelic + */ + proxyPathBase?: string; +}; + +export interface NewRelicApi { + getApplications(): Promise; +} + +export class NewRelicClient implements NewRelicApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPathBase: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE; + } + + async getApplications(): Promise { + const url = await this.getApiUrl('apm', 'applications.json'); + const response = await fetch(url); + let responseJson; + + try { + responseJson = await response.json(); + } catch (e) { + responseJson = { applications: [] }; + } + + if (response.status !== 200) { + throw new Error( + `Error communicating with New Relic: ${ + responseJson?.error?.title || response.statusText + }`, + ); + } + + return responseJson; + } + + private async getApiUrl(product: string, path: string) { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return `${proxyUrl}${this.proxyPathBase}/${product}/api/${path}`; + } +} diff --git a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx index 94d5c530a1..81e28a8c80 100644 --- a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx @@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core'; import { Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -28,7 +27,7 @@ import { import NewRelicFetchComponent from '../NewRelicFetchComponent'; const NewRelicComponent: FC<{}> = () => ( - +
diff --git a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx index 9b3791e062..62f52e7875 100644 --- a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx @@ -15,52 +15,10 @@ */ import React, { FC } from 'react'; -import { - configApiRef, - Progress, - Table, - TableColumn, - useApi, -} from '@backstage/core'; +import { Progress, Table, TableColumn, useApi } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; import { useAsync } from 'react-use'; - -type NewRelicApplication = { - id: number; - application_summary: NewRelicApplicationSummary; - name: string; - language: string; - health_status: string; - reporting: boolean; - settings: NewRelicApplicationSettings; - links?: NewRelicApplicationLinks; -}; - -type NewRelicApplicationSummary = { - apdex_score: number; - error_rate: number; - host_count: number; - instance_count: number; - response_time: number; - throughput: number; -}; - -type NewRelicApplicationSettings = { - app_apdex_threshold: number; - end_user_apdex_threshold: number; - enable_real_user_monitoring: boolean; - use_server_side_config: boolean; -}; - -type NewRelicApplicationLinks = { - application_instances: Array; - servers: Array; - application_hosts: Array; -}; - -type NewRelicApplications = { - applications: NewRelicApplication[]; -}; +import { newRelicApiRef, NewRelicApplications } from '../../api'; export const NewRelicAPMTable: FC = ({ applications, @@ -73,7 +31,7 @@ export const NewRelicAPMTable: FC = ({ { title: 'Instance Count', field: 'instanceCount' }, { title: 'Apdex', field: 'apdexScore' }, ]; - const data = applications.map((app: NewRelicApplication) => { + const data = applications.map(app => { const { name, application_summary: applicationSummary } = app; const { response_time: responseTime, @@ -104,20 +62,11 @@ export const NewRelicAPMTable: FC = ({ }; const NewRelicFetchComponent: FC<{}> = () => { - const configApi = useApi(configApiRef); - const apiBaseUrl = configApi.getString('newrelic.api.baseUrl'); - const apiKey = configApi.getString('newrelic.api.key'); + const api = useApi(newRelicApiRef); - const { value, loading, error } = useAsync(async (): Promise< - NewRelicApplication[] - > => { - const response = await fetch(`${apiBaseUrl}/applications.json`, { - headers: { - 'X-Api-Key': apiKey, - }, - }); - const data: NewRelicApplications = await response.json(); - return data.applications.filter((application: NewRelicApplication) => { + const { value, loading, error } = useAsync(async () => { + const data = await api.getApplications(); + return data.applications.filter(application => { return application.hasOwnProperty('application_summary'); }); }, []); diff --git a/plugins/newrelic/src/plugin.ts b/plugins/newrelic/src/plugin.ts index 21aac69e8f..5f5ba88617 100644 --- a/plugins/newrelic/src/plugin.ts +++ b/plugins/newrelic/src/plugin.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { NewRelicClient, newRelicApiRef } from './api'; import NewRelicComponent from './components/NewRelicComponent'; export const rootRouteRef = createRouteRef({ @@ -24,6 +30,13 @@ export const rootRouteRef = createRouteRef({ export const plugin = createPlugin({ id: 'newrelic', + apis: [ + createApiFactory({ + api: newRelicApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new NewRelicClient({ discoveryApi }), + }), + ], register({ router }) { router.addRoute(rootRouteRef, NewRelicComponent); }, diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 8ceacf662b..0d7cf00509 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -30,14 +30,14 @@ "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 10c3d9fcd1..875b1f8660 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -30,7 +30,7 @@ describe('RegisterComponentForm', () => { expect(screen.getByText('Submit').closest('button')).toBeDisabled(); }); - it('should enable a submit button when the target url is set ', async () => { + it('should enable a submit button when the target url is set', async () => { render(); await act(async () => { diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index 933b67060a..cd3eb1adbb 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -19,7 +19,6 @@ import { Grid, makeStyles } from '@material-ui/core'; import { InfoCard, Page, - pageTheme, Content, useApi, errorApiRef, @@ -113,7 +112,7 @@ export const RegisterComponentPage = ({ }; return ( - +
diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx index d09c2054b1..fe99e70575 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { Content, Header, Page, pageTheme } from '@backstage/core'; +import { Content, Header, Page } from '@backstage/core'; import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable'; import { useRollbarEntities } from '../../hooks/useRollbarEntities'; @@ -23,7 +23,7 @@ export const RollbarHome = () => { const { entities, loading, error } = useRollbarEntities(); return ( - +
{ const { entity } = useCatalogEntity(); return ( - +
diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml index 854b692fcf..e39350e928 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: documentation lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml index 2126971502..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,7 +1,7 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} -nav: +nav: - Introduction: index.md plugins: diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml index f6e1d2c8d9..423c8683a1 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: website lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml index 0d10d11063..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} nav: - Introduction: index.md diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml index 05b87c9c0c..f4ff350d10 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: service lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml index 0d10d11063..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} nav: - Introduction: index.md diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index babdd65c04..de57043a50 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -76,7 +76,9 @@ describe('GitHubPreparer', () => { 1, 'https://github.com/benjdlambert/backstage-graphql-template', expect.any(String), - {}, + { + checkoutBranch: 'master', + }, ); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { @@ -87,7 +89,9 @@ describe('GitHubPreparer', () => { 1, 'https://github.com/benjdlambert/backstage-graphql-template', expect.any(String), - {}, + { + checkoutBranch: 'master', + }, ); }); it('return the temp directory with the path to the folder if it is specified', async () => { @@ -107,6 +111,7 @@ describe('GitHubPreparer', () => { 'https://github.com/benjdlambert/backstage-graphql-template', expect.any(String), { + checkoutBranch: 'master', fetchOpts: { callbacks: { credentials: expect.any(Function), diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index 49703c5616..6eee00f9cb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -21,7 +21,7 @@ import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; import GitUriParser from 'git-url-parse'; -import { Clone, Cred } from 'nodegit'; +import { Clone, CloneOptions, Cred } from 'nodegit'; export class GithubPreparer implements PreparerBase { token?: string; @@ -52,17 +52,22 @@ export class GithubPreparer implements PreparerBase { template.spec.path ?? '.', ); - const cloneOptions = token - ? { - fetchOpts: { - callbacks: { - credentials() { - return Cred.userpassPlaintextNew(token, 'x-oauth-basic'); - }, + let cloneOptions: CloneOptions = { + checkoutBranch: parsedGitLocation.ref, + }; + + if (token) { + cloneOptions = { + ...cloneOptions, + fetchOpts: { + callbacks: { + credentials() { + return Cred.userpassPlaintextNew(token, 'x-oauth-basic'); }, }, - } - : {}; + }, + }; + } await Clone.clone(repositoryCheckoutUrl, tempDir, cloneOptions); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 31bcdf189b..106a448d7c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -13,16 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers', () => ({ runDockerContainer: jest.fn() })); +jest.mock('./helpers', () => ({ + runDockerContainer: jest.fn(), + runCommand: jest.fn(), +})); +jest.mock('command-exists-promise', () => jest.fn()); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { RunDockerContainerOptions } from './helpers'; +import { RunDockerContainerOptions, RunCommandOptions } from './helpers'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; +const commandExists = require('command-exists-promise'); + describe('CookieCutter Templater', () => { const cookie = new CookieCutter(); const mockDocker = {} as Docker; @@ -32,6 +38,10 @@ describe('CookieCutter Templater', () => { runDockerContainer: jest.Mock; } = require('./helpers'); + jest + .spyOn(fs, 'readdir') + .mockImplementation(() => Promise.resolve(['newthing'])); + beforeEach(async () => { jest.clearAllMocks(); }); @@ -174,4 +184,71 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); }); + + describe('when cookiecutter is available', () => { + beforeAll(() => { + commandExists.mockImplementation(() => () => true); + }); + + it('use the binary', async () => { + const { + runCommand, + }: { + runCommand: jest.Mock; + } = require('./helpers'); + + const stream = new PassThrough(); + + const tempdir = await mkTemp(); + + const values = { + owner: 'blobby', + storePath: 'spotify/end-repo', + component_id: 'newthing', + }; + + await cookie.run({ + directory: tempdir, + values, + logStream: stream, + dockerClient: mockDocker, + }); + + expect(runCommand).toHaveBeenCalledWith({ + command: 'cookiecutter', + args: expect.arrayContaining([ + '--no-input', + '-o', + tempdir, + expect.stringContaining(`${tempdir}-result`), + '--verbose', + ]), + logStream: stream, + }); + }); + }); + + describe('when nothing was generated', () => { + beforeEach(() => { + jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); + }); + + it('throws an error', async () => { + const stream = new PassThrough(); + + const tempdir = await mkTemp(); + + return expect( + cookie.run({ + directory: tempdir, + values: { + owner: 'blobby', + storePath: 'spotify/end-repo', + }, + logStream: stream, + dockerClient: mockDocker, + }), + ).rejects.toThrow(/Cookie Cutter did not generate anything/); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 21b6fc3dde..7be8730bd9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -78,8 +78,16 @@ export class CookieCutter implements TemplaterBase { }); } + // if cookiecutter was successful, resultDir will contain + // exactly one directory. + const [generated] = await fs.readdir(resultDir); + + if (generated === undefined) { + throw new Error('Cookie Cutter did not generate anything'); + } + return { - resultDir: path.resolve(resultDir, options.values.component_id as string), + resultDir: path.resolve(resultDir, generated), }; } } diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index b5bdaaef4a..d0cb25f254 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -22,7 +22,6 @@ import { Header, Lifecycle, Page, - pageTheme, Progress, SupportButton, useApi, @@ -66,7 +65,7 @@ export const ScaffolderPage = () => { }, [error, errorApi]); return ( - +
{ - const theme = pageTheme[type] ?? pageTheme.other; + const backstageTheme = useTheme(); + + const themeId = pageTheme[type] ? type : 'other'; + const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); const href = generatePath(templateRoute.path, { templateName: name }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index e68c795bd6..7cd1ce0243 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -22,7 +22,6 @@ import { Lifecycle, Page, useApi, - pageTheme, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; @@ -145,7 +144,7 @@ export const TemplatePage = () => { } return ( - +
= () => { const sentryProjectId = 'sample-sentry-project-id'; return ( - +
diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index 5591d74b82..59cababbc9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -19,6 +19,7 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; +import { act } from 'react-dom/test-utils'; import { withLogCollector } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; @@ -34,8 +35,9 @@ describe('RadarComponent', () => { }); it('should render a progress bar', async () => { - const errorApi = { post: () => {} }; + jest.useFakeTimers(); + const errorApi = { post: () => {} }; const { getByTestId, queryByTestId } = render( @@ -48,9 +50,13 @@ describe('RadarComponent', () => { , ); + act(() => { + jest.advanceTimersByTime(250); + }); expect(getByTestId('progress')).toBeInTheDocument(); await waitForElement(() => queryByTestId('tech-radar-svg')); + jest.useRealTimers(); }); it('should call the errorApi if load fails', async () => { diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index 97fae16380..ee5cf8e131 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -22,6 +22,7 @@ import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarPage } from './RadarPage'; +import { act } from 'react-dom/test-utils'; import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils'; describe('RadarPage', () => { @@ -34,6 +35,8 @@ describe('RadarPage', () => { }); it('should render a progress bar', async () => { + jest.useFakeTimers(); + const techRadarProps = { width: 1200, height: 800, @@ -48,9 +51,13 @@ describe('RadarPage', () => { ), ); + act(() => { + jest.advanceTimersByTime(250); + }); expect(getByTestId('progress')).toBeInTheDocument(); await waitForElement(() => queryByTestId('tech-radar-svg')); + jest.useRealTimers(); }); it('should render a header with a svg', async () => { diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 519c9afeee..779d1fa088 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { Grid } from '@material-ui/core'; +import { Grid, makeStyles } from '@material-ui/core'; import { Content, ContentHeader, @@ -23,11 +23,16 @@ import { Header, HeaderLabel, SupportButton, - pageTheme, } from '@backstage/core'; import RadarComponent from '../components/RadarComponent'; import { TechRadarComponentProps } from '../api'; +const useStyles = makeStyles(() => ({ + overflowXScroll: { + overflowX: 'scroll', + }, +})); + export type TechRadarPageProps = TechRadarComponentProps & { title?: string; subtitle?: string; @@ -39,28 +44,31 @@ export const RadarPage = ({ subtitle, pageTitle, ...props -}: TechRadarPageProps): JSX.Element => ( - -
- - -
- - - - This is used for visualizing the official guidelines of different - areas of software development such as languages, frameworks, - infrastructure and processes. - - - - - +}: TechRadarPageProps): JSX.Element => { + const classes = useStyles(); + return ( + +
+ + +
+ + + + This is used for visualizing the official guidelines of different + areas of software development such as languages, frameworks, + infrastructure and processes. + + + + + + -
-
-
-); +
+ + ); +}; RadarPage.defaultProps = { title: 'Tech Radar', diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts deleted file mode 100644 index 39a3d5c7fa..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { AzurePreparer } from './azure'; -import { checkoutGitRepository } from '../../../helpers'; - -function normalizePath(path: string) { - return path - .replace(/^[a-z]:/i, '') - .split('\\') - .join('/'); -} - -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), - checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), -})); - -const createMockEntity = (annotations = {}) => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - annotations: { - ...annotations, - }, - }, - }; -}; - -const logger = getVoidLogger(); - -describe('Azure DevOps preparer', () => { - it('should prepare temp docs path from Azure DevOps repo', async () => { - const preparer = new AzurePreparer(logger); - - const mockEntity = createMockEntity({ - 'backstage.io/techdocs-ref': - 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', - }); - - const tempDocsPath = await preparer.prepare(mockEntity); - expect(checkoutGitRepository).toHaveBeenCalledTimes(1); - expect(normalizePath(tempDocsPath)).toEqual( - '/tmp/backstage-repo/org/name/branch/template.yaml', - ); - }); -}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts deleted file mode 100644 index 18e6c3c755..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/azure.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 path from 'path'; -import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; -import parseGitUrl from 'git-url-parse'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; - -import { Logger } from 'winston'; - -export class AzurePreparer implements PreparerBase { - private readonly logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async prepare(entity: Entity): Promise { - const { type, target } = parseReferenceAnnotation( - 'backstage.io/techdocs-ref', - entity, - ); - - if (type !== 'azure/api') { - throw new InputError(`Wrong target type: ${type}, should be 'azure/api'`); - } - - try { - const repoPath = await checkoutGitRepository(target, this.logger); - const parsedGitLocation = parseGitUrl(target); - - return path.join(repoPath, parsedGitLocation.filepath); - } catch (error) { - this.logger.debug(`Repo checkout failed with error ${error.message}`); - throw error; - } - } -} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts similarity index 59% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts rename to plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts index a657cd1697..cec1110f97 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts @@ -15,7 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { GithubPreparer } from './github'; +import { CommonGitPreparer } from './commonGit'; import { checkoutGitRepository } from '../../../helpers'; function normalizePath(path: string) { @@ -45,9 +45,9 @@ const createMockEntity = (annotations = {}) => { const logger = getVoidLogger(); -describe('github preparer', () => { +describe('commonGit preparer', () => { it('should prepare temp docs path from github repo', async () => { - const preparer = new GithubPreparer(logger); + const preparer = new CommonGitPreparer(logger); const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': @@ -60,4 +60,34 @@ describe('github preparer', () => { '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', ); }); + + it('should prepare temp docs path from gitlab repo', async () => { + const preparer = new CommonGitPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/techdocs-ref': + 'gitlab:https://gitlab.com/xesjkeee/go-logger/blob/master/catalog-info.yaml', + }); + + const tempDocsPath = await preparer.prepare(mockEntity); + expect(checkoutGitRepository).toHaveBeenCalledTimes(2); + expect(normalizePath(tempDocsPath)).toEqual( + '/tmp/backstage-repo/org/name/branch/catalog-info.yaml', + ); + }); + + it('should prepare temp docs path from azure repo', async () => { + const preparer = new CommonGitPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/techdocs-ref': + 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + }); + + const tempDocsPath = await preparer.prepare(mockEntity); + expect(checkoutGitRepository).toHaveBeenCalledTimes(3); + expect(normalizePath(tempDocsPath)).toEqual( + '/tmp/backstage-repo/org/name/branch/template.yaml', + ); + }); }); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts similarity index 83% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts rename to plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts index 56f3793fbb..d9ba96a031 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts @@ -15,7 +15,6 @@ */ import path from 'path'; import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; import parseGitUrl from 'git-url-parse'; import { @@ -25,7 +24,7 @@ import { import { Logger } from 'winston'; -export class GitlabPreparer implements PreparerBase { +export class CommonGitPreparer implements PreparerBase { private readonly logger: Logger; constructor(logger: Logger) { @@ -33,15 +32,11 @@ export class GitlabPreparer implements PreparerBase { } async prepare(entity: Entity): Promise { - const { type, target } = parseReferenceAnnotation( + const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - if (type !== 'gitlab') { - throw new InputError(`Wrong target type: ${type}, should be 'gitlab'`); - } - try { const repoPath = await checkoutGitRepository(target, this.logger); const parsedGitLocation = parseGitUrl(target); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts deleted file mode 100644 index a0f91a375a..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 path from 'path'; -import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; -import parseGitUrl from 'git-url-parse'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; - -import { Logger } from 'winston'; - -export class GithubPreparer implements PreparerBase { - private readonly logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async prepare(entity: Entity): Promise { - const { type, target } = parseReferenceAnnotation( - 'backstage.io/techdocs-ref', - entity, - ); - - if (type !== 'github') { - throw new InputError(`Wrong target type: ${type}, should be 'github'`); - } - - try { - const repoPath = await checkoutGitRepository(target, this.logger); - - const parsedGitLocation = parseGitUrl(target); - return path.join(repoPath, parsedGitLocation.filepath); - } catch (error) { - this.logger.debug(`Repo checkout failed with error ${error.message}`); - throw error; - } - } -} diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts deleted file mode 100644 index 75cad50224..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { GitlabPreparer } from './gitlab'; -import { checkoutGitRepository } from '../../../helpers'; - -function normalizePath(path: string) { - return path - .replace(/^[a-z]:/i, '') - .split('\\') - .join('/'); -} - -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), - checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), -})); - -const createMockEntity = (annotations = {}) => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - annotations: { - ...annotations, - }, - }, - }; -}; - -const logger = getVoidLogger(); - -describe('gitlab preparer', () => { - it('should prepare temp docs path from gitlab repo', async () => { - const preparer = new GitlabPreparer(logger); - - // TODO: fix url repo - const mockEntity = createMockEntity({ - 'backstage.io/techdocs-ref': - 'gitlab:https://gitlab.com/xesjkeee/go-logger/blob/master/catalog-info.yaml', - }); - - const tempDocsPath = await preparer.prepare(mockEntity); - expect(checkoutGitRepository).toHaveBeenCalledTimes(1); - expect(normalizePath(tempDocsPath)).toEqual( - '/tmp/backstage-repo/org/name/branch/catalog-info.yaml', - ); - }); -}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts index 249854f7ee..46e1d84c69 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ export { DirectoryPreparer } from './dir'; -export { GithubPreparer } from './github'; -export { GitlabPreparer } from './gitlab'; -export { AzurePreparer } from './azure'; +export { CommonGitPreparer } from './commonGit'; export { Preparers } from './preparers'; export type { PreparerBuilder, PreparerBase } from './types'; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index cbef46f6c1..0408a540ca 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -24,7 +24,6 @@ import { useApi, Content, Page, - pageTheme, Header, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; @@ -43,7 +42,7 @@ export const TechDocsHome = () => { if (loading) { return ( - +
{ if (error) { return ( - +
{ } return ( - +
{ }; return ( - + - + } /> diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx index be464bd485..f97fcda3f1 100644 --- a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx @@ -25,12 +25,14 @@ jest.useFakeTimers(); describe('', () => { it('should render a message if techdocs page takes more time to load', () => { const rendered = render(wrapInTestApp()); - - expect(rendered.getByTestId('progress')).toBeDefined(); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(rendered.getByTestId('progress')).toBeInTheDocument(); expect(rendered.queryByTestId('delay-reason')).toBeNull(); act(() => { jest.advanceTimersByTime(5000); }); - expect(rendered.getByTestId('delay-reason')).toBeDefined(); + expect(rendered.getByTestId('delay-reason')).toBeInTheDocument(); }); }); diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx index 563bf44150..bc2a68578d 100644 --- a/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx +++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx @@ -37,7 +37,7 @@ export const AuthProviders = ({ providerSettings }: Props) => { } return ( - + {providers} ); diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 7213ee2b83..4bae3dbfbb 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -22,14 +22,13 @@ import { SessionState, } from '@backstage/core'; import { + Button, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Tooltip, } from '@material-ui/core'; -import PowerButton from '@material-ui/icons/PowerSettingsNew'; -import { ToggleButton } from '@material-ui/lab'; type Props = { title: string; @@ -84,14 +83,13 @@ export const ProviderSettingsItem = ({ arrow title={signedIn ? `Sign out from ${title}` : `Sign in to ${title}`} > - (signedIn ? api.signOut() : api.signIn())} + diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx index 71f59c52b8..e0f4d6f40e 100644 --- a/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx +++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx @@ -62,7 +62,7 @@ export const FeatureFlags = () => { } return ( - + {featureFlags.map(featureFlag => { const enabled = Boolean(state[featureFlag.name]); diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx index 92065f2ed6..460a15f56b 100644 --- a/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx +++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx @@ -19,10 +19,9 @@ import { ListItem, ListItemSecondaryAction, ListItemText, + Switch, Tooltip, } from '@material-ui/core'; -import CheckIcon from '@material-ui/icons/CheckCircle'; -import { ToggleButton } from '@material-ui/lab'; import { FeatureFlagsRegistryItem } from '@backstage/core'; type Props = { @@ -39,14 +38,12 @@ export const FlagItem = ({ flag, enabled, toggleHandler }: Props) => ( /> - toggleHandler(flag.name)} - > - - + name={flag.name} + /> diff --git a/plugins/user-settings/src/components/General/General.tsx b/plugins/user-settings/src/components/General/General.tsx index 00ac17dfe1..b6d30d0103 100644 --- a/plugins/user-settings/src/components/General/General.tsx +++ b/plugins/user-settings/src/components/General/General.tsx @@ -21,12 +21,12 @@ import { Profile } from './Profile'; import { ThemeToggle } from './ThemeToggle'; export const General = () => ( - - + + - - + + diff --git a/plugins/user-settings/src/components/General/PinButton.test.tsx b/plugins/user-settings/src/components/General/PinButton.test.tsx index 76af8ce8d4..939588a9ee 100644 --- a/plugins/user-settings/src/components/General/PinButton.test.tsx +++ b/plugins/user-settings/src/components/General/PinButton.test.tsx @@ -32,10 +32,9 @@ describe('', () => { , ), ); - expect(rendered.getByText('Pin Sidebar')).toBeInTheDocument(); - const pinButton = rendered.getByTitle('Pin Sidebar'); + const pinButton = rendered.getByLabelText('Pin Sidebar Switch'); fireEvent.click(pinButton); expect(mockToggleFn).toHaveBeenCalled(); }); diff --git a/plugins/user-settings/src/components/General/PinButton.tsx b/plugins/user-settings/src/components/General/PinButton.tsx index e36fc365f8..d44e5c9a3b 100644 --- a/plugins/user-settings/src/components/General/PinButton.tsx +++ b/plugins/user-settings/src/components/General/PinButton.tsx @@ -19,18 +19,11 @@ import { ListItem, ListItemSecondaryAction, ListItemText, + Switch, Tooltip, } from '@material-ui/core'; -import LockIcon from '@material-ui/icons/Lock'; -import LockOpenIcon from '@material-ui/icons/LockOpen'; -import { ToggleButton } from '@material-ui/lab'; import { SidebarPinStateContext } from '@backstage/core'; -type PinIconProps = { isPinned: boolean }; - -const PinIcon = ({ isPinned }: PinIconProps) => - isPinned ? : ; - export const PinButton = () => { const { isPinned, toggleSidebarPinState } = useContext( SidebarPinStateContext, @@ -48,16 +41,13 @@ export const PinButton = () => { arrow title={`${isPinned ? 'Unpin' : 'Pin'} Sidebar`} > - { - toggleSidebarPinState(); - }} - > - - + toggleSidebarPinState()} + name="pin" + inputProps={{ 'aria-label': 'Pin Sidebar Switch' }} + /> diff --git a/plugins/user-settings/src/components/General/ThemeToggle.tsx b/plugins/user-settings/src/components/General/ThemeToggle.tsx index 111bd838a8..dc24a61f8f 100644 --- a/plugins/user-settings/src/components/General/ThemeToggle.tsx +++ b/plugins/user-settings/src/components/General/ThemeToggle.tsx @@ -102,12 +102,20 @@ export const ThemeToggle = () => { title={`Select ${theme.title}`} value={theme.variant} > - + <> + {theme.variant}  + + ); })} - + + Auto  diff --git a/plugins/user-settings/src/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx index 86096e9a1f..0b4a86c99d 100644 --- a/plugins/user-settings/src/components/SettingsPage.tsx +++ b/plugins/user-settings/src/components/SettingsPage.tsx @@ -15,7 +15,7 @@ */ import React, { useState } from 'react'; -import { Content, Header, HeaderTabs, Page, pageTheme } from '@backstage/core'; +import { Content, Header, HeaderTabs, Page } from '@backstage/core'; import { General } from './General'; import { AuthProviders } from './AuthProviders'; import { FeatureFlags } from './FeatureFlags'; @@ -43,7 +43,7 @@ export const SettingsPage = ({ providerSettings }: Props) => { ]; return ( - +
{content[activeTab]} diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 1afdb53ad4..7b94024948 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -30,7 +30,6 @@ import { Header, HomepageTimer, Page, - pageTheme, ContentHeader, SupportButton, WarningPanel, @@ -44,7 +43,7 @@ const WelcomePage = () => { const profile = { givenName: '' }; return ( - +
{ + // Files have been provided as arguments + if (process.argv.length > 2) { + return process.argv.slice(2); + } - // set all file(s) path as absolute path - filesToLint = filesToLint - .toString() - .split('\n') - .map(filepath => (filepath ? path.join(process.cwd(), filepath) : null)) - .filter(Boolean); + let command = `git ls-files | ./node_modules/.bin/shx grep ".md"`; + if (process.platform === 'win32') { + command = `git ls-files | .\\node_modules\\.bin\\shx grep ".md"`; + } - const output = spawnSync('vale', filesToLint, inheritStdIo); + return execSync(command, { + stdio: ['ignore', 'pipe', 'inherit'], + }) + .toString() + .split('\n'); +}; - // if the command does not succeed - if (output.status !== 0) { - // if it contains system level error. [in this case vale does not exist] - if (output.error) { - console.error(ERROR_MESSAGE); - } +// Proceed with the script only if Vale linter is installed. Limit the friction and surprises caused by the script. +// On CI, we want to ensure vale linter is run. +commandExists('vale') + .catch(() => { + if (process.env.CI) { + console.log(VALE_NOT_FOUND_MESSAGE); process.exit(1); } - } catch (e) { - console.error(e.message); - process.exit(1); - } -} else { - const validMDFilesCommand = `${listFilesTrackedByGit} | ./node_modules/.bin/shx grep ".md"`; - // use xargs - try { - execSync(`${validMDFilesCommand} | xargs vale`, inheritStdIo); - } catch (e) { - console.error(ERROR_MESSAGE); - process.exit(1); - } -} + console.log(LINT_SKIPPED_MESSAGE); + process.exit(0); + }) + .then(() => { + const filesToLint = getFilesToLint(); + + if (process.platform === 'win32') { + // Windows + try { + const output = spawnSync('vale', filesToLint, inheritStdIo); + + // If the command does not succeed + if (output.status !== 0) { + // If it contains system level error. In this case vale does not exist. + if (output.error) { + console.log(LINT_ERROR_MESSAGE); + } + process.exit(1); + } + } catch (e) { + console.log(e.message); + process.exit(1); + } + } else { + // Unix + const output = spawnSync('vale', filesToLint, inheritStdIo); + if (output.status !== 0) { + console.log(LINT_ERROR_MESSAGE); + process.exit(1); + } + } + }); diff --git a/yarn.lock b/yarn.lock index 0de83d8d04..6e48747b66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3650,10 +3650,10 @@ resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.3.0.tgz#a051eb4db2ad778e39933a31ba806f415dd3ba90" integrity sha512-v/xZ4Xk18ZgBARcCe99IDqdO97GU0UC784gG8PhGGFDdy5Rbdy7ixTjHkGYttkr43PB7SX6ttohNhkKSW4nATQ== -"@roadiehq/backstage-plugin-github-insights@^0.2.2": - version "0.2.2" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.2.tgz#1647aec22f1f8bc40268f075ae307abf660ae75c" - integrity sha512-m4j1kUmoj+ZCRyaisXVBkLW9kaI/OGMq8xzthm4adOb12mS0s2p2P9AMa4T+bRyLgHpQ0vB75qhw4p6sHJAvWA== +"@roadiehq/backstage-plugin-github-insights@^0.2.7": + version "0.2.7" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.7.tgz#b8e86af9215270d8c02fca4f2f32880ff84c0503" + integrity sha512-oYqGkAkPa2pvlk+tDgTOkdhTUdLvc71x4FRZI82h1aaPzrz7DODooU644Qsduc5yYiXicbFkvMUk1b3fjL4Dxw== dependencies: "@backstage/catalog-model" "^0.1.1-alpha.24" "@backstage/core" "^0.1.1-alpha.24" @@ -3661,6 +3661,8 @@ "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" + "@octokit/rest" "^18.0.0" + "@octokit/types" "^5.0.1" history "^5.0.0" react "^16.13.1" react-dom "^16.13.1" @@ -3668,15 +3670,15 @@ react-router "^6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-github-pull-requests@^0.4.3": - version "0.4.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.4.3.tgz#720fd3c53ae8e61d8cfc727cda47e236af442b02" - integrity sha512-IJGMys5FJ512sitaMwWgACll3ZTuE3nZUX05MNXly/WXty6VBcfboiPbSB6IzLAEWMmvYlV1YUwwL7hAl/62GQ== +"@roadiehq/backstage-plugin-github-pull-requests@^0.5.1": + version "0.5.1" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.5.1.tgz#a0e3ef97de20fe87e55a26f0198d4ff80b6ecd8c" + integrity sha512-0Btg4jn7SWAp/0A7+xCuHxD4LKPoE3LSEkjMvLmWk/AIIvNA6uSWXeAv1nJZSe4JBV0Qzt+r+TS+hCcFR+vcMg== dependencies: - "@backstage/catalog-model" "^0.1.1-alpha.23" - "@backstage/core" "^0.1.1-alpha.23" - "@backstage/plugin-catalog" "^0.1.1-alpha.23" - "@backstage/theme" "^0.1.1-alpha.23" + "@backstage/catalog-model" "^0.1.1-alpha.24" + "@backstage/core" "^0.1.1-alpha.24" + "@backstage/plugin-catalog" "^0.1.1-alpha.24" + "@backstage/theme" "^0.1.1-alpha.24" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "^4.0.0-alpha.56" @@ -3800,10 +3802,10 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@spotify/eslint-config-base@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-7.0.0.tgz#36804ae09ec938f1aa5f9464ea993f3f151cfaa8" - integrity sha512-XRTrTRyRRYBxPYINKNHw4B8QWlA3p4I+id/Po2sMdejtXH3LZgDgIjdschUZSdQ6J6dVYDdaVykdizRM9I+G6Q== +"@spotify/eslint-config-base@^8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-8.0.0.tgz#c3d10d8a05ad9129d579952cb80063e4c4fa216e" + integrity sha512-07Fo+KoTMl4AaZJPD6deFnYmG634wzgeqfFcWdFlIrOLAasvWSH8Lp6ZE9cJBKuffOAy8kuEE1SwFo34+5X8bQ== "@spotify/eslint-config-oss@^1.0.1": version "1.0.2" @@ -3812,51 +3814,21 @@ dependencies: eslint-plugin-notice "^0.9.10" -"@spotify/eslint-config-react@^7.0.1": - version "7.0.1" - resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-7.0.1.tgz#2e70de9d7911ea9aeaa55a83a332220a28f6c431" - integrity sha512-HNDHvm19EaBXiDgsg52mjMgKWZTeA0hO2Q75ACNwb8UtjIKkANqyyuzyDGo8jiGMbWIm6wJjShlRtT95emNlqQ== +"@spotify/eslint-config-react@^8.0.0": + version "8.0.2" + resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-8.0.2.tgz#91af3c58b22d49a96795336ed66927e722cda430" + integrity sha512-V0gzO/jWmjhODLINAfeunHj+OBdj/cQeVDw+92ZUlkonAg6BZLjX6Vy8jeIUFjfgmVOtI9eKbcApxaurj1jN6g== -"@spotify/eslint-config-typescript@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-7.0.0.tgz#fc5227e3344f74b41ac3a530df24a95ac13254e4" - integrity sha512-28I/SAf68NKbWZ5IY0WYMa0D18PxWdC9DP9gRbOTlZufmsS8jEgqf3zBUWmP6XOf1nihpKWcqvbFUG5H7/JYXA== - -"@spotify/eslint-config@^7.0.0", "@spotify/eslint-config@^7.0.1": - version "7.0.1" - resolved "https://registry.npmjs.org/@spotify/eslint-config/-/eslint-config-7.0.1.tgz#07a21cfd7fce89cfc2c6dd5ea5d747e741201b66" - integrity sha512-8GI/TZGUhS4pr7oipT2MjrZFRgXcKzk9YImEusUdD2f5vlCniRFIBQNrvTMkyjfdQqvIVqJPLcdVPXeAgprsMw== - dependencies: - "@spotify/eslint-config-base" "^7.0.0" - "@spotify/eslint-config-react" "^7.0.1" - "@spotify/eslint-config-typescript" "^7.0.0" - "@spotify/web-scripts-utils" "^7.0.0" - "@typescript-eslint/eslint-plugin" "^2.14.0" - "@typescript-eslint/parser" "^2.14.0" - eslint-config-prettier "^6.0.0" - eslint-plugin-jest "^23.6.0" - eslint-plugin-jsx-a11y "^6.2.1" - eslint-plugin-react "^7.12.4" - eslint-plugin-react-hooks "^4.0.0" - -"@spotify/prettier-config@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-7.0.0.tgz#47750979d1282197295108b6958360660a955c16" - integrity sha512-lIMcx/2oDqTtW84iHKkRJe+8U6HK6GPwWH5sJp9UEHcDpdXomOQYvwcGXy2I2zwPQQ14gYYE6nEJuSnnYqsYRw== +"@spotify/eslint-config-typescript@^8.0.0": + version "8.1.1" + resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-8.1.1.tgz#a8c6f74ef9445fe740008ae3a26003a2eef78f93" + integrity sha512-0NkE6ZiSRf9IBWwCxzn/l7UdwcL/KZsbzjjOSweafyYueajEn+eIpKjOgDGUL/ebxSrOtjEp+YeFc4lSPkP0qg== "@spotify/prettier-config@^8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-8.0.0.tgz#8b6c2bd579ddc54887155a0721fe04e96c89f7f2" integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ== -"@spotify/web-scripts-utils@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/web-scripts-utils/-/web-scripts-utils-7.0.0.tgz#8c6b8039fc645a36ac48629eb9ba06600f4d828a" - integrity sha512-McMy0j60lxOHjgDjegthZqEWN/PabphiM30A/mI/Y7xh9+JFnYWBTSm/wgn6EW2BsPpV631xSYYkk6B1Ph14Fw== - dependencies: - glob "^7.1.4" - read-pkg-up "^7.0.1" - "@storybook/addon-actions@^6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.21.tgz#0de1d109d4b1eb99f644bbe84e74c25cfd2b1b6b" @@ -4545,17 +4517,16 @@ pretty-format "^26.4.2" "@testing-library/jest-dom@^5.10.1": - version "5.10.1" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.10.1.tgz#6508a9f007bd74e5d3c0b3135b668027ab663989" - integrity sha512-uv9lLAnEFRzwUTN/y9lVVXVXlEzazDkelJtM5u92PsGkEasmdI+sfzhZHxSDzlhZVTrlLfuMh2safMr8YmzXLg== + version "5.11.4" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.4.tgz#f325c600db352afb92995c2576022b35621ddc99" + integrity sha512-6RRn3epuweBODDIv3dAlWjOEHQLpGJHB2i912VS3JQtsD22+ENInhdDNl4ZZQiViLlIfFinkSET/J736ytV9sw== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^4.2.2" chalk "^3.0.0" - css "^2.2.4" + css "^3.0.0" css.escape "^1.5.1" - jest-diff "^25.1.0" - jest-matcher-utils "^25.1.0" lodash "^4.17.15" redent "^3.0.0" @@ -5354,9 +5325,9 @@ "@types/passport" "*" "@types/passport@*", "@types/passport@^1.0.3": - version "1.0.3" - resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.3.tgz#e459ed6c262bf0686684d1b05901be0d0b192a9c" - integrity sha512-nyztuxtDPQv9utCzU0qW7Gl8BY2Dn8BKlYAFFyxKipFxjaVd96celbkLCV/tRqqBUZ+JB8If3UfgV8347DTo3Q== + version "1.0.4" + resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.4.tgz#1b35c4e197560d3974fa5f71711b6e9cce0711f0" + integrity sha512-h5OfAbfBBYSzjeU0GTuuqYEk9McTgWeGQql9g3gUw2/NNCfD7VgExVRYJVVeU13Twn202Mvk9BT0bUrl30sEgA== dependencies: "@types/express" "*" @@ -5819,59 +5790,123 @@ dependencies: "@types/yargs-parser" "*" -"@types/yup@^0.28.2": - version "0.28.3" - resolved "https://registry.npmjs.org/@types/yup/-/yup-0.28.3.tgz#387c35f9a6a36b8d3561f6601eb4e72518b92899" - integrity sha512-0Sir2LxOmupF8HBUvpJoZghLmOqKfZsBk1GYlMwSIccLDDUoN04LHvo0KzDp9qxt1IKf9Fudpj35SrJ8VqetkQ== +"@types/yup@^0.29.8": + version "0.29.8" + resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" + integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== "@types/zen-observable@^0.8.0": version "0.8.0" resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== -"@typescript-eslint/eslint-plugin@^2.14.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.24.0.tgz#a86cf618c965a462cddf3601f594544b134d6d68" - integrity sha512-wJRBeaMeT7RLQ27UQkDFOu25MqFOBus8PtOa9KaT5ZuxC1kAsd7JEHqWt4YXuY9eancX0GK9C68i5OROnlIzBA== +"@typescript-eslint/eslint-plugin@^v3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" + integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== dependencies: - "@typescript-eslint/experimental-utils" "2.24.0" - eslint-utils "^1.4.3" + "@typescript-eslint/experimental-utils" "3.10.1" + debug "^4.1.1" functional-red-black-tree "^1.0.1" regexpp "^3.0.0" + semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@2.24.0", "@typescript-eslint/experimental-utils@^2.5.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.24.0.tgz#a5cb2ed89fedf8b59638dc83484eb0c8c35e1143" - integrity sha512-DXrwuXTdVh3ycNCMYmWhUzn/gfqu9N0VzNnahjiDJvcyhfBy4gb59ncVZVxdp5XzBC77dCncu0daQgOkbvPwBw== +"@typescript-eslint/experimental-utils@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686" + integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/typescript-estree" "2.24.0" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" eslint-scope "^5.0.0" + eslint-utils "^2.0.0" -"@typescript-eslint/parser@^2.14.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.24.0.tgz#2cf0eae6e6dd44d162486ad949c126b887f11eb8" - integrity sha512-H2Y7uacwSSg8IbVxdYExSI3T7uM1DzmOn2COGtCahCC3g8YtM1xYAPi2MAHyfPs61VKxP/J/UiSctcRgw4G8aw== +"@typescript-eslint/experimental-utils@^4.0.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.4.1.tgz#40613b9757fa0170de3e0043254dbb077cafac0c" + integrity sha512-Nt4EVlb1mqExW9cWhpV6pd1a3DkUbX9DeyYsdoeziKOpIJ04S2KMVDO+SEidsXRH/XHDpbzXykKcMTLdTXH6cQ== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/scope-manager" "4.4.1" + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/typescript-estree" "4.4.1" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + +"@typescript-eslint/parser@^v3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" + integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== dependencies: "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "2.24.0" - "@typescript-eslint/typescript-estree" "2.24.0" + "@typescript-eslint/experimental-utils" "3.10.1" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/typescript-estree@2.24.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.24.0.tgz#38bbc8bb479790d2f324797ffbcdb346d897c62a" - integrity sha512-RJ0yMe5owMSix55qX7Mi9V6z2FDuuDpN6eR5fzRJrp+8in9UF41IGNQHbg5aMK4/PjVaEQksLvz0IA8n+Mr/FA== +"@typescript-eslint/scope-manager@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz#d19447e60db2ce9c425898d62fa03b2cce8ea3f9" + integrity sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ== dependencies: + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/visitor-keys" "4.4.1" + +"@typescript-eslint/types@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" + integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== + +"@typescript-eslint/types@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.4.1.tgz#c507b35cf523bc7ba00aae5f75ee9b810cdabbc1" + integrity sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w== + +"@typescript-eslint/typescript-estree@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" + integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== + dependencies: + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/visitor-keys" "3.10.1" debug "^4.1.1" - eslint-visitor-keys "^1.1.0" glob "^7.1.6" is-glob "^4.0.1" lodash "^4.17.15" - semver "^6.3.0" + semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz#598f6de488106c2587d47ca2462c60f6e2797cb8" + integrity sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g== + dependencies: + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/visitor-keys" "4.4.1" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^7.3.2" + tsutils "^3.17.1" + +"@typescript-eslint/visitor-keys@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931" + integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== + dependencies: + eslint-visitor-keys "^1.1.0" + +"@typescript-eslint/visitor-keys@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz#1769dc7a9e2d7d2cfd3318b77ed8249187aed5c3" + integrity sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw== + dependencies: + "@typescript-eslint/types" "4.4.1" + eslint-visitor-keys "^2.0.0" + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -8555,6 +8590,11 @@ command-exists-promise@^2.0.2: resolved "https://registry.npmjs.org/command-exists-promise/-/command-exists-promise-2.0.2.tgz#7beecc4b218299f3c61fa69a4047aa0b36a64a99" integrity sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA== +command-exists@^1.2.9: + version "1.2.9" + resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" + integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== + commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -9227,15 +9267,14 @@ css.escape@1.5.1, css.escape@^1.5.1: resolved "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= -css@^2.2.4: - version "2.2.4" - resolved "https://registry.npmjs.org/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" - integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== +css@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== dependencies: - inherits "^2.0.3" + inherits "^2.0.4" source-map "^0.6.1" - source-map-resolve "^0.5.2" - urix "^0.1.0" + source-map-resolve "^0.6.0" cssesc@^3.0.0: version "3.0.0" @@ -9589,9 +9628,9 @@ dateformat@^3.0.0: integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== dayjs@^1.9.1: - version "1.9.1" - resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.9.1.tgz#201a755f7db5103ed6de63ba93a984141c754541" - integrity sha512-01NCTBg8cuMJG1OQc6PR7T66+AFYiPwgDvdJmvJBn29NGzIG+DIFxPLNjHzwz3cpFIvG+NcwIjP9hSaPVoOaDg== + version "1.9.3" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.9.3.tgz#b7f94b22ad2a136a4ca02a01ab68ae893fe1a268" + integrity sha512-V+1SyIvkS+HmNbN1G7A9+ERbFTV9KTXu6Oor98v2xHmzzpp52OIJhQuJSTywWuBY5pyAEmlwbCi1Me87n/SLOw== de-indent@^1.0.2: version "1.0.2" @@ -10672,7 +10711,7 @@ eslint-plugin-graphql@^4.0.0: lodash.flatten "^4.4.0" lodash.without "^4.4.0" -eslint-plugin-import@^2.20.2, eslint-plugin-import@^2.22.0: +eslint-plugin-import@^2.20.2: version "2.22.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e" integrity sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg== @@ -10691,12 +10730,12 @@ eslint-plugin-import@^2.20.2, eslint-plugin-import@^2.22.0: resolve "^1.17.0" tsconfig-paths "^3.9.0" -eslint-plugin-jest@^23.6.0: - version "23.8.2" - resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-23.8.2.tgz#6f28b41c67ef635f803ebd9e168f6b73858eb8d4" - integrity sha512-xwbnvOsotSV27MtAe7s8uGWOori0nUsrXh2f1EnpmXua8sDfY6VZhHAhHg2sqK7HBNycRQExF074XSZ7DvfoFg== +eslint-plugin-jest@^24.1.0: + version "24.1.0" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.0.tgz#6708037d7602e5288ce877fd0103f329dc978361" + integrity sha512-827YJ+E8B9PvXu/0eiVSNFfxxndbKv+qE/3GSMhdorCaeaOehtqHGX2YDW9B85TEOre9n/zscledkFW/KbnyGg== dependencies: - "@typescript-eslint/experimental-utils" "^2.5.0" + "@typescript-eslint/experimental-utils" "^4.0.1" eslint-plugin-jsx-a11y@^6.2.1: version "6.2.3" @@ -10774,13 +10813,6 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-utils@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" - integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== - dependencies: - eslint-visitor-keys "^1.1.0" - eslint-utils@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" @@ -10793,6 +10825,11 @@ eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== +eslint-visitor-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" + integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== + eslint@^7.1.0: version "7.4.0" resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" @@ -12184,7 +12221,7 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.1, globby@^11.0.0: +globby@11.0.1, globby@^11.0.0, globby@^11.0.1: version "11.0.1" resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== @@ -14121,7 +14158,7 @@ jest-css-modules@^2.1.0: dependencies: identity-obj-proxy "3.0.0" -jest-diff@^25.1.0, jest-diff@^25.2.1: +jest-diff@^25.2.1: version "25.5.0" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== @@ -14200,7 +14237,7 @@ jest-fetch-mock@^3.0.3: cross-fetch "^3.0.4" promise-polyfill "^8.1.3" -jest-get-type@^25.1.0, jest-get-type@^25.2.6: +jest-get-type@^25.2.6: version "25.2.6" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== @@ -14263,16 +14300,6 @@ jest-leak-detector@^26.5.2: jest-get-type "^26.3.0" pretty-format "^26.5.2" -jest-matcher-utils@^25.1.0: - version "25.1.0" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz#fa5996c45c7193a3c24e73066fc14acdee020220" - integrity sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ== - dependencies: - chalk "^3.0.0" - jest-diff "^25.1.0" - jest-get-type "^25.1.0" - pretty-format "^25.1.0" - jest-matcher-utils@^26.5.2: version "26.5.2" resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.5.2.tgz#6aa2c76ce8b9c33e66f8856ff3a52bab59e6c85a" @@ -18584,7 +18611,7 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^25.1.0, pretty-format@^25.2.1, pretty-format@^25.5.0: +pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== @@ -20371,10 +20398,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-dts@1.4.11: - version "1.4.11" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.11.tgz#aedf0b7bb91d51e20b755e2c18e840edfc7af7a1" - integrity sha512-yiScAMKgwH77b44a/IFGgjLsmwSlNfQhEM+eCb2uMrupQMPE1n/12wrnT431+v1u6wYMF1XuHqldh+v/7mTvYA== +rollup-plugin-dts@1.4.13: + version "1.4.13" + resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.13.tgz#4f086e84f4fdcc1f49160799ebc66f6b09db292b" + integrity sha512-7mxoQ6PcmCkBE5ZhrjGDL4k42XLy8BkSqpiRi1MipwiGs+7lwi4mQkp2afX+OzzLjJp/TGM8llfe8uayIUhPEw== optionalDependencies: "@babel/code-frame" "^7.10.4" @@ -21063,7 +21090,7 @@ source-list-map@^2.0.0: resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: +source-map-resolve@^0.5.0: version "0.5.3" resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== @@ -21074,6 +21101,14 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.16, source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.12: version "0.5.19" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -21756,10 +21791,10 @@ subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16: symbol-observable "^1.0.4" ws "^5.2.0" -sucrase@^3.14.1: - version "3.15.0" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.15.0.tgz#78596a78be7264a65b52ed8d873883413ef0220c" - integrity sha512-05TJOUfMgckH7wKqfk/1p4G6q16nIeW/GHQwD44vkT0mQMqqzgfHCwkX3whNmwyOo7nVF0jDLwVu/qOBTtsscw== +sucrase@^3.16.0: + version "3.16.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.16.0.tgz#19b5b886ccca270dd5ca12ff060eeaf0b599735f" + integrity sha512-ovVuswxV5TayCPXfTk8bgBgk6uNRvsinIkEpq0J6zS1xXCx5N/LLGcbsKdRhqn/ToZylMX6+yXaR1LSn1I42Pg== dependencies: commander "^4.0.0" glob "7.1.6" @@ -22726,10 +22761,10 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^3.9.3: - version "3.9.7" - resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" - integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== +typescript@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz#153bbd468ef07725c1df9c77e8b453f8d36abba5" + integrity sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg== ua-parser-js@^0.7.18: version "0.7.21" @@ -24147,7 +24182,7 @@ yn@^4.0.0: resolved "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz#611480051ea43b510da1dfdbe177ed159f00a979" integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== -yup@^0.29.1: +yup@^0.29.3: version "0.29.3" resolved "https://registry.npmjs.org/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" integrity sha512-RNUGiZ/sQ37CkhzKFoedkeMfJM0vNQyaz+wRZJzxdKE7VfDeVKH8bb4rr7XhRLbHJz5hSjoDNwMEIaKhuMZ8gQ==