diff --git a/.changeset/brave-rats-obey.md b/.changeset/brave-rats-obey.md new file mode 100644 index 0000000000..a09d922763 --- /dev/null +++ b/.changeset/brave-rats-obey.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Warn if the app-backend can't start-up because the static directory that should be served is unavailable. diff --git a/.changeset/breezy-cobras-deny.md b/.changeset/breezy-cobras-deny.md deleted file mode 100644 index 5f035c05ce..0000000000 --- a/.changeset/breezy-cobras-deny.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Clear sidebar search field once a search is executed diff --git a/.changeset/chilled-zoos-rush.md b/.changeset/chilled-zoos-rush.md new file mode 100644 index 0000000000..6830cb7fcf --- /dev/null +++ b/.changeset/chilled-zoos-rush.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-api-docs': minor +--- + +APIs now have real entity pages that are customizable in the app. +Therefore the old entity page from this plugin is removed. +See the `packages/app` on how to create and customize the API entity page. diff --git a/.changeset/clever-moons-shake.md b/.changeset/clever-moons-shake.md deleted file mode 100644 index 0fb6c690c2..0000000000 --- a/.changeset/clever-moons-shake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Added readTree support to AzureUrlReader diff --git a/.changeset/cli-links.md b/.changeset/cli-links.md deleted file mode 100644 index 0a9779de93..0000000000 --- a/.changeset/cli-links.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -The CLI now detects and transforms linked packages. You can link in external packages by adding them to both the `lerna.json` and `package.json` workspace paths. diff --git a/.changeset/cost-insights-quiet-bikes-occur.md b/.changeset/cost-insights-quiet-bikes-occur.md deleted file mode 100644 index aac283bbd4..0000000000 --- a/.changeset/cost-insights-quiet-bikes-occur.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -fix product icon configuration diff --git a/.changeset/cost-insights-tiny-llamas-perform.md b/.changeset/cost-insights-tiny-llamas-perform.md deleted file mode 100644 index c81c28f8f8..0000000000 --- a/.changeset/cost-insights-tiny-llamas-perform.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -truncate large percentages > 1000% diff --git a/.changeset/curly-yaks-invite.md b/.changeset/curly-yaks-invite.md deleted file mode 100644 index b9191a3895..0000000000 --- a/.changeset/curly-yaks-invite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Add support for reading groups and users from the Microsoft Graph API. diff --git a/.changeset/dull-pans-sip.md b/.changeset/dull-pans-sip.md deleted file mode 100644 index 13616c1f8d..0000000000 --- a/.changeset/dull-pans-sip.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Move constructing the catalog-info.yaml URL for scaffolded components to the publishers diff --git a/.changeset/empty-kids-look.md b/.changeset/empty-kids-look.md deleted file mode 100644 index 680cd7f25c..0000000000 --- a/.changeset/empty-kids-look.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-register-component': patch ---- - -Remove catalog link on validate popup diff --git a/.changeset/few-kings-agree.md b/.changeset/few-kings-agree.md new file mode 100644 index 0000000000..0019611503 --- /dev/null +++ b/.changeset/few-kings-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added support for passing false as a CSP field value, to drop it from the defaults in the backend diff --git a/.changeset/long-birds-rush.md b/.changeset/long-birds-rush.md new file mode 100644 index 0000000000..db4da34789 --- /dev/null +++ b/.changeset/long-birds-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Make versions:bump install new versions of dependencies that were within the specified range diff --git a/.changeset/pink-goats-sort.md b/.changeset/pink-goats-sort.md new file mode 100644 index 0000000000..828cccba84 --- /dev/null +++ b/.changeset/pink-goats-sort.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-catalog-backend': patch +--- + +Start emitting all known relation types from the core entity kinds, based on their spec data. diff --git a/.changeset/small-worms-check.md b/.changeset/small-worms-check.md deleted file mode 100644 index e39265f8f9..0000000000 --- a/.changeset/small-worms-check.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'example-app': patch -'@backstage/plugin-search': patch ---- - -Using the search field in the sidebar now navigates to the search result page. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index c31e19e35c..a0841fe7d0 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -131,6 +131,7 @@ npm nvm oauth Oauth +oidc Okta Oldsberg onboarding diff --git a/.gitignore b/.gitignore index 5c27601791..3334bf956d 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,7 @@ typings/ .nuxt dist dist-types +dist-workspace # Gatsby files .cache/ diff --git a/app-config.yaml b/app-config.yaml index 32d3470f1b..fcc54a1a35 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -189,8 +189,10 @@ scaffolder: api: token: $env: AZURE_TOKEN - auth: + ### Providing an auth.session.secret will enable session support in the auth-backend + # session: + # secret: custom session secret providers: google: development: @@ -235,6 +237,20 @@ auth: $env: AUTH_OAUTH2_AUTH_URL tokenUrl: $env: AUTH_OAUTH2_TOKEN_URL + oidc: + development: + metadataUrl: + $env: AUTH_OIDC_METADATA_URL + clientId: + $env: AUTH_OIDC_CLIENT_ID + clientSecret: + $env: AUTH_OIDC_CLIENT_SECRET + authorizationUrl: + $env: AUTH_OIDC_AUTH_URL + tokenUrl: + $env: AUTH_OIDC_TOKEN_URL + tokenSignedResponseAlg: + $env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG auth0: development: clientId: diff --git a/docs/assets/techdocs/architecture-basic.drawio.svg b/docs/assets/techdocs/architecture-basic.drawio.svg new file mode 100644 index 0000000000..8e3be76f8b --- /dev/null +++ b/docs/assets/techdocs/architecture-basic.drawio.svg @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + +
+
+
+ Repo 1 +
+ (with docs) +
+
+
+
+ + Repo 1... + +
+
+ + + + +
+
+
+ Repo 2 +
+ (with docs) +
+
+
+
+ + Repo 2... + +
+
+ + + + +
+
+
+ TechDocs plugin +
+
+
+
+ + TechDocs plugin + +
+
+ + + + + +
+
+
+ Request TechDocs site +
+
+
+
+ + Request TechDocs site + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ Source code hosting +
+
+
+
+ + Source code hosting + +
+
+ + + + +
+
+
+ TechDocs Backend Plugin +
+
+
+
+ + TechDocs Backend Plugin + +
+
+ + + + +
+
+
+ (Stages) +
+
+
+
+ + (Stages) + +
+
+ + + + + + +
+
+
+ Prepare +
+
+
+
+ + Prepare + +
+
+ + + + + + +
+
+
+ Generate +
+
+
+
+ + Generate + +
+
+ + + + +
+
+
+ Publish +
+
+
+
+ + Publish + +
+
+ + + + + +
+
+
+ Local Filesystem +
+
+
+
+ + Local File... + +
+
+ + + + +
+
+
+ Fetch markdown files +
+
+
+
+ + Fetch mark... + +
+
+ + + + +
+
+
+ Build doc files using mkdocs +
+
+
+
+ + Build doc... + +
+
+ + + + +
+
+
+ Store generated static content +
+
+
+
+ + Store gene... + +
+
+ + + + +
+
+
+ TechDocs Basic (out-of-the-box) architecture +
+
+
+
+ + TechDocs Basic (out-of-the-box) architect... + +
+
+ + + + + + + + +
+
+
+ + Route handler + +
+
+
+
+ + Route hand... + +
+
+ + + + +
+
+
+ or +
+
+
+
+ + or + +
+
+ + + + +
+
+
+ Trigger new build if docs D.N.E. +
+ or are outdated +
+
+
+
+ + Trigger new build... + +
+
+ + + + +
+
+
+ Fetch files to render +
+
+
+
+ + Fetch files... + +
+
+ + + + + + + + + + + +
+
+
+ + GCP +
+ Bucket +
+
+
+
+
+ + GCP... + +
+
+ + + + +
+
+
+ Azure storage +
+
+
+
+ + Azure st... + +
+
+ + + + + +
+
+
+ Storage solutions +
+
+
+
+ + Storage solutions + +
+
+ + + + + + + + +
+
+
+ + Amazon S3 + +
+
+
+
+ + Amazon S3 + +
+
+ +
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/techdocs/architecture-recommended.drawio.svg b/docs/assets/techdocs/architecture-recommended.drawio.svg new file mode 100644 index 0000000000..e3af4b6b5f --- /dev/null +++ b/docs/assets/techdocs/architecture-recommended.drawio.svg @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + +
+
+
+ Repo 1 +
+ (with docs) +
+
+
+
+ + Repo 1... + +
+
+ + + + +
+
+
+ Repo 2 +
+ (with docs) +
+
+
+
+ + Repo 2... + +
+
+ + + + + + + + +
+
+
+ CI/CD System +
+
+
+
+ + CI/CD System + +
+
+ + + + + + +
+
+
+ techdocs-cli +
+
+
+
+ + techdocs-cli + +
+
+ + + + +
+
+
+ Cloud Storage +
+
+
+
+ + Cloud Storage + +
+
+ + + + + + +
+
+
+ + Amazon S3 + +
+
+
+
+ + Amazon S3 + +
+
+ + + + + + + + + +
+
+
+ + GCP +
+ Bucket +
+
+
+
+
+ + GCP... + +
+
+ + + + +
+
+
+ Azure storage +
+
+
+
+ + Azure st... + +
+
+ + + + +
+
+
+ New commit +
+
+
+
+ + New commit + +
+
+ + + + +
+
+
+ New commit +
+
+
+
+ + New commit + +
+
+ + + + +
+
+
+ Build docs +
+ & +
+ Store generated static content +
+
+
+
+ + Build docs... + +
+
+ + + + + + + +
+
+
+ TechDocs plugin +
+
+
+
+ + TechDocs plugin + +
+
+ + + + +
+
+
+ TechDocs Backend plugin +
+
+
+
+ + TechDocs Backend plu... + +
+
+ + + + + + +
+
+
+ Request TechDocs site +
+
+
+
+ + Request TechDocs site + +
+
+ + + + +
+
+
+ Fetch files to render +
+
+
+
+ + Fetch files to render + +
+
+ + + + + + + + + + + + + + + +
+
+
+ Source code hosting +
+
+
+
+ + Source code hosting + +
+
+ + + + + +
+
+
+ Caching +
+ (Optional) +
+
+
+
+ + Caching... + +
+
+ + + + + + +
+
+
+ Storage solutions +
+
+
+
+ + Storage solutions + +
+
+ + + + +
+
+
+ TechDocs Recommended deployment architecture +
+
+
+
+ + TechDocs Recommended deployment architecture + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/techdocs/techdocs_big_picture.png b/docs/assets/techdocs/techdocs_big_picture.png deleted file mode 100644 index 8b8f7a2338..0000000000 Binary files a/docs/assets/techdocs/techdocs_big_picture.png and /dev/null differ diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 5d7a5b9af0..f1d426f407 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -43,7 +43,7 @@ software catalog API. "kind": "Component", "metadata": { "annotations": { - "backstage.io/managed-by-location": "file:/tmp/component-info.yaml", + "backstage.io/managed-by-location": "file:/tmp/catalog-info.yaml", "example.com/service-discovery": "artistweb", "circleci.com/project-slug": "github/example-org/artist-website" }, @@ -93,6 +93,43 @@ significance and have reserved purposes and distinct shapes. See below for details about these fields. +## Substitutions In The Descriptor Format + +The descriptor format supports substitutions using `$text`, `$json`, and +`$yaml`. + +Placeholders like `$json: https://example.com/entity.json` are substituted by +the content of the referenced file. Files can be referenced from any configured +integration similar to locations by passing an absolute URL. It's also possible +to reference relative files like `./referenced.yaml` from the same location. +Relative references are handled relative to the folder of the +`catalog-info.yaml` that contains the placeholder. There are three different +types of placeholders: + +- `$text`: Interprets the contents of the referenced file as plain text and + embeds it as a string. +- `$json`: Interprets the contents of the referenced file as JSON and embeds the + parsed structure. +- `$yaml`: Interprets the contents of the referenced file as YAML and embeds the + parsed structure. + +For example, this can be used to load the definition of an API entity from a web +server and embed it as a string in the field `spec.definition`: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The Petstore API +spec: + type: openapi + lifecycle: production + owner: petstore@example.com + definition: + $text: https://petstore.swagger.io/v2/swagger.json +``` + ## Common to All Kinds: The Envelope The root envelope object has the following structure. @@ -592,6 +629,9 @@ The current set of well-known and common values for this field is: [OpenAPI](https://swagger.io/specification/) version 2 or version 3 spec. - `asyncapi` - An API definition based on the [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) spec. +- `graphql` - An API definition based on + [GraphQL schemas](https://spec.graphql.org/) for consuming + [GraphQL](https://graphql.org/) based APIs. - `grpc` - An API definition based on [Protocol Buffers](https://developers.google.com/protocol-buffers) to use with [gRPC](https://grpc.io/). @@ -700,6 +740,11 @@ sufficient to enter only the `metadata.name` field of that group. ### `spec.ancestors` [required] +**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be +removed entirely from the model on Dec 6th, 2020 in the repository and will not +be present in released packages following the next release after that. Please +update your code to not consume this field before the removal date. + The recursive list of parents up the hierarchy, by stepping through parents one by one. The list must be present, but may be empty if `parent` is not present. The first entry in the list is equal to `parent`, and then the following ones @@ -728,6 +773,11 @@ sufficient to enter only the `metadata.name` field of those groups. ### `spec.descendants` [required] +**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be +removed entirely from the model on Dec 6th, 2020 in the repository and will not +be present in released packages following the next release after that. Please +update your code to not consume this field before the removal date. + The immediate and recursive child groups of this group in the hierarchy (children, and children's children, etc.). The list must be present, but may be empty if there are no child groups. The items are not guaranteed to be ordered diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 7d16427a3c..3627c74b9a 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -238,22 +238,9 @@ annotation, with the same value format. ### backstage.io/definition-at-location -This annotation allowed to load the API definition from another location. Now -placeholders can be used instead: - -``` -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: petstore - description: The Petstore API -spec: - type: openapi - lifecycle: production - owner: petstore@example.com - definition: - $text: https://petstore.swagger.io/v2/swagger.json -``` +This annotation allowed to load the API definition from another location. Use +[substitution](./descriptor-format.md#substitutions-in-the-descriptor-format) +instead. ## Links diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 75a75003f7..e2b69d6767 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -55,6 +55,10 @@ contains more information about the required fields. Once we have a `template.yaml` ready, we can then add it to the service catalog for use by the scaffolder. +_NOTE_: When the `publish` step is completed, it is currently assumed by the +scaffolder that the final repository should contain a `catalog-info.yaml` in +order to register this with the Catalog in Backstage. + Currently the catalog supports loading definitions from GitHub + Local Files. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 926ec238e1..01497b31bc 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -1,7 +1,153 @@ --- id: architecture -title: Architecture -description: Documentation on Architecture +title: TechDocs Architecture +description: Documentation on TechDocs Architecture --- -![TechDocs Big Picture](../../assets/techdocs/techdocs_big_picture.png) +## Basic (out-of-the-box) + +When you deploy Backstage (with TechDocs enabled by default), you get a basic +out-of-the box experience. + +![TechDocs Architecture diagram](../../assets/techdocs/architecture-basic.drawio.svg) + +> Note: See below for our recommended deployment architecture which takes care +> of stability, scalability and speed. + +When you open a TechDocs site in Backstage, the +[TechDocs Reader](./concepts.md#techdocs-reader) makes a request to +`techdocs-backend` with the entity ID and the path of the current page you are +looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to +render on the page in TechDocs/Backstage. + +The static files consist of HTML, CSS and Images generated by MkDocs. We remove +all the Javascript before adding them to Backstage for security reasons. And +there are some additional techdocs metadata JSON files that TechDocs needs to +render a site. + +The TechDocs Reader then applies a list of "Transformers" (see +[Concepts](./concepts.md)) which modify the generated static HTML files for a +number of use cases e.g. Remove certain headers, filter out some HTML tags, etc. + +Currently, we use the Backstage server's (or techdocs-backend's) local file +system to store the generated files. Publishing to an external storage system +(AWS S3, GCS, etc.) is also possible, but has not been implemented yet. + +A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch +files from its source repository for docs site generation. 1. By using Git +and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is +heavily in progress. Please reach out to us on Discord in the #docs-like-code +channel to talk about it. + +## Recommended deployment + +This is how we recommend deploying TechDocs in production environment. + +![TechDocs Architecture diagram](../../assets/techdocs/architecture-recommended.drawio.svg) + +The key difference in the recommended deployment approach is where the docs are +built. + +We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We +recommend using a CI/CD pipeline with the repository that has a dedicated +step/job to build docs for TechDocs. The generated static files are then stored +in a cloud storage solution of your choice. +[Track progress here](https://github.com/backstage/backstage/issues/3096). + +Similar to how it is done in the Basic setup, the TechDocs Reader requests +`techdocs-backend` plugin for the docs site. `techdocs-backend` then requests +your configured storage solution for the necessary files and returns them to +TechDocs Reader. + +We will provide instructions, scripts and/or templates (e.g. GitHub actions) to +build docs in your CI/CD system. +[Track progress here.](https://github.com/backstage/backstage/issues/3400) You +will be able to use `techdocs-cli` to build docs and publish the generated docs +site files to your cloud storage system. + +Note about caching: We have noticed internally that some storage providers can +be quite slow, which is why we are recommending a cache that sits between the +TechDocs Reader and the Storage. + +_Feel free to suggest better ideas to us in #docs-like-code channel in Discord +or via a GitHub issue._ + +### Security consideration + +Our biggest security concern is managing the access to the docs in the cloud +storage. We also want to have only one security solution for all different types +of storage (GCS, AWS, custom SFTP server, etc.) Restricting access to the +storage and only allowing `techdocs-backend` to fetch files is a good way to +achieve this. + +This would also allow us to use the access control management Backstage when +that is ready. +[Track progress here.](https://github.com/backstage/backstage/issues/3218) + +In theory, you can directly enable TechDocs Reader to read from your storage. +But, you will have to think about how to do it without the docs being public and +how access to user groups is managed. + +For cloud storage access tokens, `techdocs-backend` only needs a token with Read +permissions. But in your CI/CD system, there needs to be a token with Write +permissions to publish the generated docs site files. + +## FAQs + +**Q: Why do you have separate "basic" and "recommended" deployment approaches?** + +A: The basic or out-of-the-box setup is what you get when you create a new app +or do a git clone of the Backstage repository. We want the first experience to +_just work magically_ so that you can have your first experience with TechDocs +which is smooth. However, if you decide to deploy Backstage/TechDocs for +production use, the basic setup would work but there are going to be downsides +as you scale with the number of documentation sites and sizes of them. So you +would want to make sure the deployment is as stable as possible. Hence there is +a recommended approach. There can be even more deployment approaches to TechDocs +and we welcome such "Alternative" ideas from the community. + +**Q: Why don't you recommend techdocs-backend local filesystem to serve static +files?** + +A: It would make scaling a Backstage instance harder. Think about the case where +we have distributed Backstage deployments. Using a separate file storage system +for TechDocs makes it easier to do some operations like delete a docs site and +wipe its contents. + +**Q: Why aren't docs built on the fly i.e. when users visits a page, generate +docs site in real-time?** + +A: Generating the content from Markdown on the fly is not optimal (although that +is how the basic out-of-the-box setup is implemented). Storage solutions act as +a cache for the generated static content. TechDocs is also currently built on +MkDocs which does not allow us to build docs per-page, so we would have to build +all docs for a entity on every request. + +# Future work + +_Ideas here are far fetched and not in the project's milestone for near future +(~6 months)._ + +We currently depend on MkDocs to parse doc sites written in Markdown. And we +store the generated static assets and re-use it later to render in Backstage. A +better (futuristic) approach will be to directly parse whatever type of source +files you have in your docs repository and directly render in Backstage in +real-time. + +# Features status + +Status of all the features mentioned above. + +**In place ✅** + +- Basic setup with techdocs-backend file server as storage. + +**Work in progress 🚧** + +- Basic setup with cloud storage solution. + +**Not implemented yet ❌** + +- `techdocs-cli` is able to generate docs in CI/CD environment. +- `techdocs-cli` is able to publish docs site to any storage. +- `techdocs-backend` integration with Backstage access control management. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 552b6c3ec2..6aef4c48c1 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -14,7 +14,7 @@ need to run Backstage in your own environment. To create a Backstage app, you will need to have [Node.js](https://nodejs.org/en/download/) Active LTS Release installed -(currently v12). +(currently v14). Backstage provides a utility for creating new apps. It guides you through the initial setup of selecting the name of the app and a database for the backend. diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index a30a918fd0..6de9d234e3 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -8,19 +8,19 @@ description: Documentation on How to run Backstage Locally - Node.js -First make sure you are using Node.js with an Active LTS Release, currently v12. +First make sure you are using Node.js with an Active LTS Release, currently v14. This is made easy with a version manager such as [nvm](https://github.com/nvm-sh/nvm) which allows for version switching. ```bash # Installing a new version -nvm install 12 -> Downloading and installing node v12.18.3... -> Now using node v12.18.3 (npm v6.14.6) +nvm install 14 +> Downloading and installing node v14.15.1... +> Now using node v14.15.1 (npm v6.14.8) # Checking your version node --version -> v12.18.3 +> v14.15.1 ``` - Yarn diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml new file mode 100644 index 0000000000..579243d11c --- /dev/null +++ b/microsite/data/plugins/jira.yaml @@ -0,0 +1,9 @@ +--- +title: Jira +author: roadie.io +authorUrl: https://roadie.io +category: Project Management +description: View Jira summary for your projects in Backstage. +documentation: https://roadie.io/backstage/plugins/jira +iconUrl: https://roadie.io/images/logos/jira.png +npmPackageName: '@roadiehq/backstage-plugin-jira' diff --git a/mkdocs.yml b/mkdocs.yml index 8a4a78ddf1..1086259710 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,9 @@ site_name: 'Backstage' site_description: 'Main documentation for Backstage features and platform APIs' +plugins: + - techdocs-core + nav: - Overview: - What is Backstage?: 'overview/what-is-backstage.md' @@ -111,6 +114,3 @@ nav: - 'support/support.md' - 'support/project-structure.md' - FAQ: FAQ.md - -plugins: - - techdocs-core diff --git a/package.json b/package.json index 4140886bc3..3be94c19cf 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ ] }, "resolutions": { - "**/@roadiehq/backstage-plugin-*/@backstage/core": "*" + "**/@roadiehq/**/@backstage/core": "*", + "**/@roadiehq/**/@backstage/catalog-model": "*" }, "version": "1.0.0", "devDependencies": { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index ab00924a26..f70cd198b4 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,42 @@ # example-app +## 0.2.3 + +### Patch Changes + +- 475fc0aaa: Using the search field in the sidebar now navigates to the search result page. +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [29a0ccab2] +- Updated dependencies [8e6728e25] +- Updated dependencies [c93a14b49] +- Updated dependencies [ef2831dde] +- Updated dependencies [2a71f4bab] +- Updated dependencies [1185919f3] +- Updated dependencies [a8de7f554] +- Updated dependencies [faf311c26] +- Updated dependencies [31d8b6979] +- Updated dependencies [991345969] +- Updated dependencies [475fc0aaa] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-kubernetes@0.3.0 + - @backstage/cli@0.3.1 + - @backstage/plugin-cost-insights@0.4.1 + - @backstage/plugin-scaffolder@0.3.1 + - @backstage/plugin-register-component@0.2.2 + - @backstage/plugin-circleci@0.2.2 + - @backstage/plugin-search@0.2.1 + - @backstage/plugin-api-docs@0.2.2 + - @backstage/plugin-catalog@0.2.3 + - @backstage/plugin-cloudbuild@0.2.2 + - @backstage/plugin-github-actions@0.2.2 + - @backstage/plugin-jenkins@0.3.1 + - @backstage/plugin-lighthouse@0.2.3 + - @backstage/plugin-rollbar@0.2.3 + - @backstage/plugin-sentry@0.2.3 + - @backstage/plugin-techdocs@0.2.3 + ## 0.2.2 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 52906bb65a..9e54005cd3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,34 +1,33 @@ { "name": "example-app", - "version": "0.2.1", + "version": "0.2.3", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/cli": "^0.3.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-api-docs": "^0.2.1", - "@backstage/plugin-catalog": "^0.2.2", - "@backstage/plugin-catalog-import": "^0.2.0", - "@backstage/plugin-circleci": "^0.2.1", - "@backstage/plugin-cloudbuild": "^0.2.1", - "@backstage/plugin-cost-insights": "^0.4.0", + "@backstage/catalog-model": "^0.3.0", + "@backstage/cli": "^0.3.1", + "@backstage/core": "^0.3.2", + "@backstage/plugin-api-docs": "^0.2.2", + "@backstage/plugin-catalog": "^0.2.3", + "@backstage/plugin-circleci": "^0.2.2", + "@backstage/plugin-cloudbuild": "^0.2.2", + "@backstage/plugin-cost-insights": "^0.4.1", "@backstage/plugin-explore": "^0.2.1", "@backstage/plugin-gcp-projects": "^0.2.1", - "@backstage/plugin-github-actions": "^0.2.1", + "@backstage/plugin-github-actions": "^0.2.2", "@backstage/plugin-gitops-profiles": "^0.2.1", "@backstage/plugin-graphiql": "^0.2.1", - "@backstage/plugin-jenkins": "^0.3.0", - "@backstage/plugin-kubernetes": "^0.2.1", - "@backstage/plugin-lighthouse": "^0.2.2", + "@backstage/plugin-jenkins": "^0.3.1", + "@backstage/plugin-kubernetes": "^0.3.0", + "@backstage/plugin-lighthouse": "^0.2.3", "@backstage/plugin-newrelic": "^0.2.1", - "@backstage/plugin-register-component": "^0.2.1", - "@backstage/plugin-rollbar": "^0.2.2", - "@backstage/plugin-scaffolder": "^0.3.0", - "@backstage/plugin-sentry": "^0.2.2", - "@backstage/plugin-search": "^0.2.0", + "@backstage/plugin-register-component": "^0.2.2", + "@backstage/plugin-rollbar": "^0.2.3", + "@backstage/plugin-scaffolder": "^0.3.1", + "@backstage/plugin-sentry": "^0.2.3", + "@backstage/plugin-search": "^0.2.1", "@backstage/plugin-tech-radar": "^0.3.0", - "@backstage/plugin-techdocs": "^0.2.2", + "@backstage/plugin-techdocs": "^0.2.3", "@backstage/plugin-user-settings": "^0.2.2", "@backstage/plugin-welcome": "^0.2.1", "@backstage/test-utils": "^0.1.3", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d826bfa25e..068019c622 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,63 +13,66 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ApiEntity, Entity } from '@backstage/catalog-model'; +import { EmptyState } from '@backstage/core'; import { - isPluginApplicableToEntity as isTravisCIAvailable, - RecentTravisCIBuildsWidget, - Router as TravisCIRouter, -} from '@roadiehq/backstage-plugin-travis-ci'; + ApiDefinitionCard, + Router as ApiDocsRouter, +} from '@backstage/plugin-api-docs'; +import { + AboutCard, + EntityPageLayout, + useEntity, +} from '@backstage/plugin-catalog'; +import { + isPluginApplicableToEntity as isCircleCIAvailable, + Router as CircleCIRouter, +} from '@backstage/plugin-circleci'; +import { + isPluginApplicableToEntity as isCloudbuildAvailable, + Router as CloudbuildRouter, +} from '@backstage/plugin-cloudbuild'; import { isPluginApplicableToEntity as isGitHubActionsAvailable, RecentWorkflowRunsCard, Router as GitHubActionsRouter, } from '@backstage/plugin-github-actions'; import { - Router as CloudbuildRouter, - isPluginApplicableToEntity as isCloudbuildAvailable, -} from '@backstage/plugin-cloudbuild'; -import { - Router as JenkinsRouter, isPluginApplicableToEntity as isJenkinsAvailable, LatestRunCard as JenkinsLatestRunCard, + Router as JenkinsRouter, } from '@backstage/plugin-jenkins'; -import { - isPluginApplicableToEntity as isCircleCIAvailable, - Router as CircleCIRouter, -} from '@backstage/plugin-circleci'; -import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; -import { Router as SentryRouter } from '@backstage/plugin-sentry'; -import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; -import { - Router as GitHubInsightsRouter, - isPluginApplicableToEntity as isGitHubAvailable, - ReadMeCard, - LanguagesCard, - ReleasesCard, -} from '@roadiehq/backstage-plugin-github-insights'; -import React, { ReactNode } from 'react'; -import { - AboutCard, - EntityPageLayout, - useEntity, -} from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { Button, Grid } from '@material-ui/core'; -import { EmptyState } from '@backstage/core'; import { EmbeddedRouter as LighthouseRouter, - LastLighthouseAuditCard, isPluginApplicableToEntity as isLighthouseAvailable, -} from '@backstage/plugin-lighthouse/'; + LastLighthouseAuditCard, +} from '@backstage/plugin-lighthouse'; +import { Router as SentryRouter } from '@backstage/plugin-sentry'; +import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { Button, Grid } from '@material-ui/core'; +import { + isPluginApplicableToEntity as isBuildkiteAvailable, + Router as BuildkiteRouter, +} from '@roadiehq/backstage-plugin-buildkite'; +import { + isPluginApplicableToEntity as isGitHubAvailable, + LanguagesCard, + ReadMeCard, + ReleasesCard, + Router as GitHubInsightsRouter, +} from '@roadiehq/backstage-plugin-github-insights'; import { - Router as PullRequestsRouter, isPluginApplicableToEntity as isPullRequestsAvailable, PullRequestsStatsCard, + Router as PullRequestsRouter, } from '@roadiehq/backstage-plugin-github-pull-requests'; import { - Router as BuildkiteRouter, - isPluginApplicableToEntity as isBuildkiteAvailable, -} from '@roadiehq/backstage-plugin-buildkite'; + isPluginApplicableToEntity as isTravisCIAvailable, + RecentTravisCIBuildsWidget, + Router as TravisCIRouter, +} from '@roadiehq/backstage-plugin-travis-ci'; +import React, { ReactNode } from 'react'; export const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -79,10 +82,10 @@ export const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; case isBuildkiteAvailable(entity): return ; - case isGitHubActionsAvailable(entity): - return ; case isCircleCIAvailable(entity): return ; + case isGitHubActionsAvailable(entity): + return ; case isCloudbuildAvailable(entity): return ; case isTravisCIAvailable(entity): @@ -134,7 +137,7 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { ); }; -const OverviewContent = ({ entity }: { entity: Entity }) => ( +const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( @@ -169,7 +172,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( } + element={} /> ( } + element={} /> ( /> ); + const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( } + element={} /> ( ); -export const EntityPage = () => { - const { entity } = useEntity(); +export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { switch (entity?.spec?.type) { case 'service': return ; @@ -279,3 +282,47 @@ export const EntityPage = () => { return ; } }; + +const ApiOverviewContent = ({ entity }: { entity: Entity }) => ( + + + + + +); + +const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( + + + + + +); + +const ApiEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + } + /> + +); + +export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLowerCase()) { + case 'component': + return ; + case 'api': + return ; + default: + return ; + } +}; diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 06ff6b07d3..01828b1405 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -22,9 +22,16 @@ import { samlAuthApiRef, microsoftAuthApiRef, oneloginAuthApiRef, + oidcAuthApiRef, } from '@backstage/core'; export const providers = [ + { + id: 'oidc-auth-provider', + title: 'Oidc', + message: 'Sign In using OpenId Connect', + apiRef: oidcAuthApiRef, + }, { id: 'google-auth-provider', title: 'Google', diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 4822032c7f..793a554905 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/backend-common +## 0.3.1 + +### Patch Changes + +- bff3305aa: Added readTree support to AzureUrlReader +- b47dce06f: Make integration host and url configurations visible in the frontend + ## 0.3.0 ### Minor Changes diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 7ebcd01e8c..c4ec84ab62 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -79,15 +79,25 @@ export interface Config { optionsSuccessStatus?: number; }; - /** */ - csp?: object; + /** + * Content Security Policy options. + * + * The keys are the plain policy ID, e.g. "upgrade-insecure-requests". The + * values are on the format that the helmet library expects them, as an + * array of strings. There is also the special value false, which means to + * remove the default value that Backstage puts in place for that policy. + */ + csp?: { [policyId: string]: string[] | false }; }; /** Configuration for integrations towards various external repository provider systems */ integrations?: { /** Integration configuration for Azure */ azure?: Array<{ - /** The hostname of the given Azure instance */ + /** + * The hostname of the given Azure instance + * @visibility frontend + */ host: string; /** * Token used to authenticate requests. @@ -98,14 +108,20 @@ export interface Config { /** Integration configuration for BitBucket */ bitbucket?: Array<{ - /** The hostname of the given Bitbucket instance */ + /** + * The hostname of the given Bitbucket instance + * @visibility frontend + */ host: string; /** * Token used to authenticate requests. * @visibility secret */ token?: string; - /** The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 */ + /** + * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 + * @visibility frontend + */ apiBaseUrl?: string; /** * The username to use for authenticated requests. @@ -121,22 +137,34 @@ export interface Config { /** Integration configuration for GitHub */ github?: Array<{ - /** The hostname of the given GitHub instance */ + /** + * The hostname of the given GitHub instance + * @visibility frontend + */ host: string; /** * Token used to authenticate requests. * @visibility secret */ token?: string; - /** The base url for the GitHub API, for example https://api.github.com */ + /** + * The base url for the GitHub API, for example https://api.github.com + * @visibility frontend + */ apiBaseUrl?: string; - /** The base url for GitHub raw resources, for example https://raw.githubusercontent.com */ + /** + * The base url for GitHub raw resources, for example https://raw.githubusercontent.com + * @visibility frontend + */ rawBaseUrl?: string; }>; /** Integration configuration for GitLab */ gitlab?: Array<{ - /** The hostname of the given GitLab instance */ + /** + * The hostname of the given GitLab instance + * @visibility frontend + */ host: string; /** * Token used to authenticate requests. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index d4f0052d28..731e2679cb 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -33,7 +33,6 @@ "@backstage/config": "^0.1.1", "@backstage/config-loader": "^0.3.0", "@backstage/integration": "^0.1.1", - "@backstage/test-utils": "^0.1.3", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -68,7 +67,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", + "@backstage/test-utils": "^0.1.3", "@types/archiver": "^3.1.1", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts new file mode 100644 index 0000000000..cffb0a88de --- /dev/null +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { applyCspDirectives } from './ServiceBuilderImpl'; + +describe('ServiceBuilderImpl', () => { + describe('applyCspDirectives', () => { + it('copies actual values', () => { + const result = applyCspDirectives({ key: ['value'] }); + expect(result).toEqual( + expect.objectContaining({ + 'default-src': ["'self'"], + key: ['value'], + }), + ); + }); + + it('removes false value keys', () => { + const result = applyCspDirectives({ 'upgrade-insecure-requests': false }); + expect(result!['upgrade-insecure-requests']).toBeUndefined(); + }); + }); +}); diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 710de6f1d7..d8b9ffa2d9 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -18,7 +18,7 @@ import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; -import helmet from 'helmet'; +import helmet, { HelmetOptions } from 'helmet'; import * as http from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; @@ -56,7 +56,7 @@ const DEFAULT_CSP = { 'script-src': ["'self'"], 'script-src-attr': ["'none'"], 'style-src': ["'self'", 'https:', "'unsafe-inline'"], - 'upgrade-insecure-requests': [], + 'upgrade-insecure-requests': [] as string[], }; export class ServiceBuilderImpl implements ServiceBuilder { @@ -64,7 +64,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; - private cspOptions: CspOptions | undefined; + private cspOptions: Record | undefined; private httpsSettings: HttpsSettings | undefined; private enableMetrics: boolean = true; private routers: [string, Router][]; @@ -154,20 +154,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { host, logger, corsOptions, - cspOptions, httpsSettings, + helmetOptions, } = this.getOptions(); - app.use( - helmet({ - contentSecurityPolicy: { - directives: { - ...DEFAULT_CSP, - ...cspOptions, - }, - }, - }), - ); + app.use(helmet(helmetOptions)); if (corsOptions) { app.use(cors(corsOptions)); } @@ -214,16 +205,38 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: string; logger: Logger; corsOptions?: cors.CorsOptions; - cspOptions?: CspOptions; httpsSettings?: HttpsSettings; + helmetOptions: HelmetOptions; } { return { port: this.port ?? DEFAULT_PORT, host: this.host ?? DEFAULT_HOST, logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, - cspOptions: this.cspOptions, httpsSettings: this.httpsSettings, + helmetOptions: { + contentSecurityPolicy: { + directives: applyCspDirectives(this.cspOptions), + }, + }, }; } } + +export function applyCspDirectives( + directives: Record | undefined, +): CspOptions | undefined { + const result: CspOptions = { ...DEFAULT_CSP }; + + if (directives) { + for (const [key, value] of Object.entries(directives)) { + if (value === false) { + delete result[key]; + } else { + result[key] = value; + } + } + } + + return result; +} diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts new file mode 100644 index 0000000000..8a35f147ca --- /dev/null +++ b/packages/backend-common/src/service/lib/config.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { readCspOptions } from './config'; + +describe('config', () => { + describe('readCspOptions', () => { + it('reads valid values', () => { + const config = ConfigReader.fromConfigs([ + { context: '', data: { csp: { key: ['value'] } } }, + ]); + expect(readCspOptions(config)).toEqual( + expect.objectContaining({ + key: ['value'], + }), + ); + }); + + it('accepts false', () => { + const config = ConfigReader.fromConfigs([ + { context: '', data: { csp: { key: false } } }, + ]); + expect(readCspOptions(config)).toEqual( + expect.objectContaining({ + key: false, + }), + ); + }); + + it('rejects invalid value types', () => { + const config = ConfigReader.fromConfigs([ + { context: '', data: { csp: { key: [4] } } }, + ]); + expect(() => readCspOptions(config)).toThrow(/wanted string-array/); + }); + }); +}); diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index bfd966b499..541170b704 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -134,24 +134,33 @@ export function readCorsOptions(config: Config): CorsOptions | undefined { * Attempts to read a CSP options object from the root of a config object. * * @param config The root of a backend config object - * @returns A CSP options object, or undefined if not specified + * @returns A CSP options object, or undefined if not specified. Values can be + * false as well, which means to remove the default behavior for that + * key. * * @example * ```yaml * backend: * csp: * connect-src: ["'self'", 'http:', 'https:'] + * upgrade-insecure-requests: false * ``` */ -export function readCspOptions(config: Config): CspOptions | undefined { +export function readCspOptions( + config: Config, +): Record | undefined { const cc = config.getOptionalConfig('csp'); if (!cc) { return undefined; } - const result: CspOptions = {}; + const result: Record = {}; for (const key of cc.keys()) { - result[key] = cc.getStringArray(key); + if (cc.get(key) === false) { + result[key] = false; + } else { + result[key] = cc.getStringArray(key); + } } return result; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index a8bc1fef7d..bc2a08e983 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,26 @@ # example-backend +## 0.2.3 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [0c2121240] +- Updated dependencies [ef2831dde] +- Updated dependencies [1185919f3] +- Updated dependencies [475fc0aaa] +- Updated dependencies [b47dce06f] +- Updated dependencies [5a1d8dca3] + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-kubernetes-backend@0.2.0 + - @backstage/backend-common@0.3.1 + - @backstage/plugin-catalog-backend@0.2.2 + - @backstage/plugin-scaffolder-backend@0.3.2 + - example-app@0.2.3 + - @backstage/plugin-auth-backend@0.2.3 + - @backstage/plugin-techdocs-backend@0.2.2 + ## 0.2.2 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 410aca1087..b9daf224f0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.1", + "version": "0.2.3", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -18,24 +18,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@backstage/plugin-app-backend": "^0.3.0", - "@backstage/plugin-auth-backend": "^0.2.2", + "@backstage/plugin-auth-backend": "^0.2.3", "@backstage/plugin-catalog-backend": "^0.3.0", "@backstage/plugin-graphql-backend": "^0.1.3", - "@backstage/plugin-kubernetes-backend": "^0.1.3", + "@backstage/plugin-kubernetes-backend": "^0.2.0", "@backstage/plugin-proxy-backend": "^0.2.1", "@backstage/plugin-rollbar-backend": "^0.1.3", - "@backstage/plugin-scaffolder-backend": "^0.3.1", + "@backstage/plugin-scaffolder-backend": "^0.3.2", "@backstage/plugin-sentry-backend": "^0.1.3", - "@backstage/plugin-techdocs-backend": "^0.2.1", + "@backstage/plugin-techdocs-backend": "^0.2.2", "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.0", - "example-app": "^0.2.1", + "example-app": "^0.2.3", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -45,7 +45,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 574cd301bf..3454a6c8e0 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.3.1 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/catalog-model@0.3.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 2482f9579d..4509b7d547 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,12 +20,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 051aa82c80..10bf540b46 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/catalog-model +## 0.3.0 + +### Minor Changes + +- 1166fcc36: add kubernetes selector to component model + +### Patch Changes + +- 1185919f3: Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details. + + Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields. + + The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation. + + After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either. + + If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code. + ## 0.2.0 ### Minor Changes diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index e712e66f58..7956454ca9 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.2.0", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts index 2cacfce53a..9f6f175d8d 100644 --- a/packages/catalog-model/src/entity/ref.ts +++ b/packages/catalog-model/src/entity/ref.ts @@ -84,6 +84,14 @@ export function parseEntityName( * @param context The context of defaults that the parsing happens within * @returns The compound form of the reference */ +export function parseEntityRef( + ref: EntityRef, + context?: { defaultKind: string; defaultNamespace: string }, +): { + kind: string; + namespace: string; + name: string; +}; export function parseEntityRef( ref: EntityRef, context?: { defaultKind: string }, @@ -100,14 +108,6 @@ export function parseEntityRef( namespace: string; name: string; }; -export function parseEntityRef( - ref: EntityRef, - context?: { defaultKind: string; defaultNamespace: string }, -): { - kind: string; - namespace: string; - name: string; -}; export function parseEntityRef( ref: EntityRef, context: EntityRefContext = {}, diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 9d46dea03f..0e1e369e44 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -30,6 +30,15 @@ const schema = yup.object>({ lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), implementsApis: yup.array(yup.string().required()).notRequired(), + kubernetes: yup + .object({ + selector: yup + .object({ + matchLabels: yup.object().required(), + }) + .required(), + }) + .notRequired(), }) .required(), }); @@ -42,6 +51,13 @@ export interface ComponentEntityV1alpha1 extends Entity { lifecycle: string; owner: string; implementsApis?: string[]; + kubernetes?: { + selector: { + matchLabels: { + [key: string]: string; + }; + }; + }; }; } diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index f6345741f7..7be432ad1f 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -57,8 +57,22 @@ export interface GroupEntityV1alpha1 extends Entity { spec: { type: string; parent?: string; + /** + * @deprecated This field will disappear on Dec 6th, 2020. Please remove + * any consuming code. Producers can stop producing this field + * before that date, as long as the catalog backend uses the + * BuiltinKindsEntityProcessor which inserts the fields in the + * mean time. + */ ancestors: string[]; children: string[]; + /** + * @deprecated This field will disappear on Dec 6th, 2020. Please remove + * any consuming code. Producers can stop producing this field + * before that date, as long as the catalog backend uses the + * BuiltinKindsEntityProcessor which inserts the fields in the + * mean time. + */ descendants: string[]; }; } diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts index 846313ec8c..3d5d629b9e 100644 --- a/packages/catalog-model/src/kinds/relations.ts +++ b/packages/catalog-model/src/kinds/relations.ts @@ -33,7 +33,9 @@ export const RELATION_OWNER_OF = 'ownerOf'; * A relation with an API entity, typically from a component or system */ export const RELATION_CONSUMES_API = 'consumesApi'; +export const RELATION_API_CONSUMED_BY = 'apiConsumedBy'; export const RELATION_PROVIDES_API = 'providesApi'; +export const RELATION_API_PROVIDED_BY = 'apiProvidedBy'; /** * A relation denoting a dependency on another entity. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a5bcc6bcfd..d198444c86 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/cli +## 0.3.1 + +### Patch Changes + +- 29a0ccab2: The CLI now detects and transforms linked packages. You can link in external packages by adding them to both the `lerna.json` and `package.json` workspace paths. +- faf311c26: New lint rule to disallow assertions and promote `as` assertions. - @typescript-eslint/consistent-type-assertions +- 31d8b6979: Add experimental backend:bundle command +- 991345969: Add new `versions:check` and `versions:bump` commands to simplify version management and avoid conflicts + ## 0.3.0 ### Minor Changes diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 9b1880c1db..e2a807e42e 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -58,6 +58,7 @@ module.exports = { ], 'no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'error', + '@typescript-eslint/consistent-type-assertions': 'error', '@typescript-eslint/no-unused-vars': [ 'warn', { diff --git a/packages/cli/package.json b/packages/cli/package.json index ec3aaacdee..034c8a3de5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.3.0", + "version": "0.3.1", "private": false, "publishConfig": { "access": "public" @@ -51,6 +51,7 @@ "@types/webpack-node-externals": "^2.5.0", "@typescript-eslint/eslint-plugin": "^v3.10.1", "@typescript-eslint/parser": "^v3.10.1", + "@yarnpkg/lockfile": "^1.1.0", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", @@ -92,6 +93,7 @@ "rollup-plugin-postcss": "^3.1.1", "rollup-plugin-typescript2": "^0.27.3", "rollup-pluginutils": "^2.8.2", + "semver": "^7.3.2", "start-server-webpack-plugin": "^2.2.5", "style-loader": "^1.2.1", "sucrase": "^3.16.0", @@ -109,9 +111,9 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.3.0", + "@backstage/backend-common": "^0.3.1", "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@backstage/theme": "^0.2.1", @@ -131,6 +133,7 @@ "@types/tar": "^4.0.3", "@types/webpack": "^4.41.7", "@types/webpack-dev-server": "^3.11.0", + "@types/yarnpkg__lockfile": "^1.1.4", "del": "^5.1.0", "mock-fs": "^4.13.0", "nodemon": "^2.0.2", diff --git a/packages/cli/src/commands/backend/bundle.ts b/packages/cli/src/commands/backend/bundle.ts new file mode 100644 index 0000000000..e604ae9733 --- /dev/null +++ b/packages/cli/src/commands/backend/bundle.ts @@ -0,0 +1,39 @@ +/* + * 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 { Command } from 'commander'; +import fs from 'fs-extra'; +import { createDistWorkspace } from '../../lib/packager'; +import { paths } from '../../lib/paths'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; + +const PKG_PATH = 'package.json'; +const TARGET_DIR = 'dist-workspace'; + +export default async (cmd: Command) => { + const targetDir = paths.resolveTarget(TARGET_DIR); + const pkgPath = paths.resolveTarget(PKG_PATH); + const pkg = await fs.readJson(pkgPath); + + await fs.remove(targetDir); + await fs.mkdir(targetDir); + await createDistWorkspace([pkg.name], { + targetDir: targetDir, + buildDependencies: Boolean(cmd.build), + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + skeleton: 'skeleton.tar', + }); +}; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 1ebe518cac..7302559498 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -44,6 +44,11 @@ export function registerCommands(program: CommanderStatic) { .description('Build a backend plugin') .action(lazy(() => import('./backend/build').then(m => m.default))); + program + .command('backend:__experimental__bundle__', { hidden: true }) + .description('Bundle all backend packages into dist-workspace') + .action(lazy(() => import('./backend/bundle').then(m => m.default))); + program .command('backend:build-image') .allowUnknownOption(true) @@ -154,6 +159,17 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./config/validate').then(m => m.default))); + program + .command('versions:bump') + .description('Bump Backstage packages to the latest versions') + .action(lazy(() => import('./versions/bump').then(m => m.default))); + + program + .command('versions:check') + .option('--fix', 'Fix any auto-fixable versioning problems') + .description('Check Backstage package versioning') + .action(lazy(() => import('./versions/lint').then(m => m.default))); + program .command('prepack') .description('Prepares a package for packaging before publishing') diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts new file mode 100644 index 0000000000..f4d69c52be --- /dev/null +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -0,0 +1,168 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { resolve as resolvePath } from 'path'; +import { paths } from '../../lib/paths'; +import { mapDependencies } from '../../lib/versioning'; +import * as runObj from '../../lib/run'; +import bump from './bump'; +import { withLogCollector } from '@backstage/test-utils'; + +const REGISTRY_VERSIONS: { [name: string]: string } = { + '@backstage/core': '1.0.6', + '@backstage/theme': '2.0.0', +}; + +const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +`; + +const lockfileMock = `${HEADER} +"@backstage/core@^1.0.5": + version "1.0.6" + dependencies: + "@backstage/core-api" "^1.0.6" + +"@backstage/core@^1.0.3": + version "1.0.3" + dependencies: + "@backstage/core-api" "^1.0.3" + +"@backstage/theme@^1.0.0": + version "1.0.0" + +"@backstage/core-api@^1.0.6": + version "1.0.6" + +"@backstage/core-api@^1.0.3": + version "1.0.3" +`; + +// This resulting lockfile isn't a real world example, since it doesn't include the package bumps +const lockfileMockResult = `${HEADER} +"@backstage/core-api@^1.0.3", "@backstage/core-api@^1.0.6": + version "1.0.6" + +"@backstage/core@^1.0.5": + version "1.0.6" + dependencies: + "@backstage/core-api" "^1.0.6" + +"@backstage/theme@^1.0.0": + version "1.0.0" +`; + +describe('bump', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should bump backstage dependencies', async () => { + // Make sure all modules involved in package discovery are in the module cache before we mock fs + await mapDependencies(paths.targetDir); + + mockFs({ + '/yarn.lock': lockfileMock, + '/lerna.json': JSON.stringify({ + packages: ['packages/*'], + }), + '/packages/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + '/packages/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), + }); + + paths.targetDir = '/'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...paths) => resolvePath('/', ...paths)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name: name, + 'dist-tags': { + latest: REGISTRY_VERSIONS[name], + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + const { log: logs } = await withLogCollector(['log'], async () => { + await bump(); + }); + expect(logs.filter(Boolean)).toEqual([ + 'Checking for updates of @backstage/theme', + 'Checking for updates of @backstage/core', + 'Some packages are outdated, updating', + 'Removing lockfile entry for @backstage/core@^1.0.3 to bump to 1.0.6', + 'Bumping @backstage/theme in b to ^2.0.0', + "Running 'yarn install' to install new versions", + 'Removing duplicate dependencies from yarn.lock', + "Running 'yarn install' to remove duplicates from node_modules", + ]); + + expect(runObj.runPlain).toHaveBeenCalledTimes(2); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/core', + ); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/theme', + ); + + expect(runObj.run).toHaveBeenCalledTimes(2); + expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']); + + const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + expect(lockfileContents).toBe(lockfileMockResult); + + const packageA = await fs.readJson('/packages/a/package.json'); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', // not bumped since new version is within range + }, + }); + const packageB = await fs.readJson('/packages/b/package.json'); + expect(packageB).toEqual({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', // not bumped + '@backstage/theme': '^2.0.0', // bumped since newer + }, + }); + }); +}); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts new file mode 100644 index 0000000000..1883d87bac --- /dev/null +++ b/packages/cli/src/commands/versions/bump.ts @@ -0,0 +1,183 @@ +/* + * 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 fs from 'fs-extra'; +import semver from 'semver'; +import { resolve as resolvePath } from 'path'; +import { run } from '../../lib/run'; +import { paths } from '../../lib/paths'; +import { + mapDependencies, + fetchPackageInfo, + Lockfile, +} from '../../lib/versioning'; +import { includedFilter, forbiddenDuplicatesFilter } from './lint'; + +const DEP_TYPES = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +type PkgVersionInfo = { + range: string; + name: string; + location: string; +}; + +export default async () => { + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + + // First we discover all Backstage dependencies within our own repo + const dependencyMap = await mapDependencies(paths.targetDir); + + // Next check with the package registry to see which dependency ranges we need to bump + const versionBumps = new Map(); + // Track package versions that we want to remove from yarn.lock in order to trigger a bump + const unlocked = Array<{ name: string; range: string; latest: string }>(); + await workerThreads(16, dependencyMap.entries(), async ([name, pkgs]) => { + console.log(`Checking for updates of ${name}`); + const info = await fetchPackageInfo(name); + const latest = info['dist-tags'].latest; + if (!latest) { + throw new Error(`No latest version found for ${name}`); + } + + for (const pkg of pkgs) { + if (semver.satisfies(latest, pkg.range)) { + if (semver.minVersion(pkg.range)?.version !== latest) { + unlocked.push({ name, range: pkg.range, latest }); + } + continue; + } + versionBumps.set( + pkg.name, + (versionBumps.get(pkg.name) ?? []).concat({ + name, + location: pkg.location, + range: `^${latest}`, // TODO(Rugvip): Option to use something else than ^? + }), + ); + } + }); + + console.log(); + + // Write all discovered version bumps to package.json in this repo + if (versionBumps.size === 0 && unlocked.length === 0) { + console.log('All Backstage packages are up to date!'); + } else { + console.log('Some packages are outdated, updating'); + console.log(); + + if (unlocked.length > 0) { + const lockfile = await Lockfile.load(lockfilePath); + for (const { name, range, latest } of unlocked) { + // Don't bother removing lockfile entries if they're already on the correct version + const existingEntry = lockfile.get(name)?.find(e => e.range === range); + if (existingEntry?.version === latest) { + continue; + } + console.log( + `Removing lockfile entry for ${name}@${range} to bump to ${latest}`, + ); + lockfile.remove(name, range); + } + await lockfile.save(); + } + + await workerThreads(16, versionBumps.entries(), async ([name, deps]) => { + const pkgPath = resolvePath(deps[0].location, 'package.json'); + const pkgJson = await fs.readJson(pkgPath); + + for (const dep of deps) { + console.log(`Bumping ${dep.name} in ${name} to ${dep.range}`); + + for (const depType of DEP_TYPES) { + if (depType in pkgJson && dep.name in pkgJson[depType]) { + pkgJson[depType][dep.name] = dep.range; + } + } + } + + await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); + }); + + console.log(); + console.log("Running 'yarn install' to install new versions"); + console.log(); + await run('yarn', ['install']); + } + + console.log(); + + // Finally we make sure the new lockfile doesn't have any duplicates + const lockfile = await Lockfile.load(lockfilePath); + const result = lockfile.analyze({ + filter: includedFilter, + }); + + if (result.newVersions.length > 0) { + console.log(); + console.log('Removing duplicate dependencies from yarn.lock'); + lockfile.replaceVersions(result.newVersions); + await lockfile.save(); + + console.log( + "Running 'yarn install' to remove duplicates from node_modules", + ); + console.log(); + await run('yarn', ['install']); + + console.log(); + } + + const forbiddenNewRanges = result.newRanges.filter(({ name }) => + forbiddenDuplicatesFilter(name), + ); + if (forbiddenNewRanges.length > 0) { + throw new Error( + `Version bump failed for ${forbiddenNewRanges + .map(i => i.name) + .join(', ')}`, + ); + } +}; + +async function workerThreads( + count: number, + items: IterableIterator, + fn: (item: T) => Promise, +) { + const queue = Array.from(items); + + async function pop() { + const item = queue.pop(); + if (!item) { + return; + } + + await fn(item); + await pop(); + } + + return Promise.all( + Array(count) + .fill(0) + .map(() => pop()), + ); +} diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts new file mode 100644 index 0000000000..aa0bcd6f6a --- /dev/null +++ b/packages/cli/src/commands/versions/lint.ts @@ -0,0 +1,117 @@ +/* + * 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 { Command } from 'commander'; +import { Lockfile } from '../../lib/versioning'; +import { paths } from '../../lib/paths'; +import partition from 'lodash/partition'; + +// Packages that we try to avoid duplicates for +const INCLUDED = [/^@backstage\//]; + +export const includedFilter = (name: string) => + INCLUDED.some(pattern => pattern.test(name)); + +// Packages that are not allowed to have any duplicates +const FORBID_DUPLICATES = [ + /^@backstage\/core$/, + /^@backstage\/core-api$/, + /^@backstage\/plugin-/, +]; + +export const forbiddenDuplicatesFilter = (name: string) => + FORBID_DUPLICATES.some(pattern => pattern.test(name)); + +export default async (cmd: Command) => { + const fix = Boolean(cmd.fix); + + let success = true; + + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + const result = lockfile.analyze({ + filter: includedFilter, + }); + + logArray( + result.invalidRanges, + "The following packages versions are invalid and can't be analyzed:", + e => ` ${e.name} @ ${e.range}`, + ); + + if (fix) { + lockfile.replaceVersions(result.newVersions); + await lockfile.save(); + } else { + const [ + newVersionsForbidden, + newVersionsAllowed, + ] = partition(result.newVersions, ({ name }) => + forbiddenDuplicatesFilter(name), + ); + if (newVersionsForbidden.length && !fix) { + success = false; + } + + logArray( + newVersionsForbidden, + 'The following packages must be deduplicated, this can be done automatically with --fix', + e => + ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`, + ); + logArray( + newVersionsAllowed, + 'The following packages can be deduplicated, this can be done automatically with --fix', + e => + ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`, + ); + } + + const [newRangesForbidden, newRangesAllowed] = partition( + result.newRanges, + ({ name }) => forbiddenDuplicatesFilter(name), + ); + if (newRangesForbidden.length) { + success = false; + } + + logArray( + newRangesForbidden, + 'The following packages must be deduplicated by updating dependencies in package.json', + e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`, + ); + logArray( + newRangesAllowed, + 'The following packages can be deduplicated by updating dependencies in package.json', + e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`, + ); + + if (!success) { + throw new Error('Failed versioning check'); + } +}; + +function logArray(arr: T[], header: string, each: (item: T) => string) { + if (arr.length === 0) { + return; + } + + console.log(header); + console.log(); + for (const e of arr) { + console.log(each(e)); + } + console.log(); +} diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index d198f5507c..a5cdd1300c 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -60,7 +60,7 @@ export async function serveBundle(options: ServeOptions) { proxy: pkg.proxy, }); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { server.listen(port, url.hostname, (err?: Error) => { if (err) { reject(err); diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 69d3d92640..571c75a39d 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -102,7 +102,7 @@ export async function waitForExit( return; } - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { child.once('error', error => reject(error)); child.once('exit', code => { if (code) { diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts new file mode 100644 index 0000000000..89126c7ce1 --- /dev/null +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -0,0 +1,155 @@ +/* + * 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 fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { Lockfile } from './Lockfile'; + +const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +`; + +const mockA = `${HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x: + version "2.0.1" + +b@^2: + version "2.0.0" +`; + +const mockADedup = `${HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x, b@^2: + version "2.0.1" +`; + +const mockB = `${HEADER} +"@s/a@*", "@s/a@1 || 2", "@s/a@^1": + version "1.0.1" + +"@s/a@^2.0.x": + version "2.0.0" +`; + +const mockBDedup = `${HEADER} +"@s/a@*", "@s/a@1 || 2", "@s/a@^2.0.x": + version "2.0.0" + +"@s/a@^1": + version "1.0.1" +`; + +describe('Lockfile', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('should load and serialize mockA', async () => { + mockFs({ + '/yarn.lock': mockA, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + expect(lockfile.get('a')).toEqual([{ range: '^1', version: '1.0.1' }]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1' }, + { range: '^2', version: '2.0.0' }, + ]); + expect(lockfile.toString()).toBe(mockA); + }); + + it('should deduplicate and save mockA', async () => { + mockFs({ + '/yarn.lock': mockA, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + const result = lockfile.analyze(); + expect(result).toEqual({ + invalidRanges: [], + newRanges: [], + newVersions: [ + { + name: 'b', + range: '^2', + oldVersion: '2.0.0', + newVersion: '2.0.1', + }, + ], + }); + + expect(lockfile.toString()).toBe(mockA); + lockfile.replaceVersions(result.newVersions); + expect(lockfile.toString()).toBe(mockADedup); + + await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA); + await expect(lockfile.save()).resolves.toBeUndefined(); + await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup); + }); + + it('should deduplicate mockB', async () => { + mockFs({ + '/yarn.lock': mockB, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + const result = lockfile.analyze(); + expect(result).toEqual({ + invalidRanges: [], + newRanges: [ + { + name: '@s/a', + oldRange: '^1', + newRange: '^2.0.x', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + ], + newVersions: [ + { + name: '@s/a', + range: '*', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + { + name: '@s/a', + range: '1 || 2', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + ], + }); + + expect(lockfile.toString()).toBe(mockB); + lockfile.replaceVersions(result.newVersions); + expect(lockfile.toString()).toBe(mockBDedup); + }); +}); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts new file mode 100644 index 0000000000..f9bc789d95 --- /dev/null +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -0,0 +1,266 @@ +/* + * 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 fs from 'fs-extra'; +import semver from 'semver'; +import { + parse as parseLockfile, + stringify as stringifyLockfile, +} from '@yarnpkg/lockfile'; + +const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; + +type LockfileData = { + [entry: string]: { + version: string; + resolved?: string; + integrity?: string; + dependencies?: { [name: string]: string }; + }; +}; + +type LockfileQueryEntry = { + range: string; + version: string; +}; + +/** Entries that have an invalid version range, for example an NPM tag */ +type AnalyzeResultInvalidRange = { + name: string; + range: string; +}; + +/** Entries that can be deduplicated by bumping to an existing higher version */ +type AnalyzeResultNewVersion = { + name: string; + range: string; + oldVersion: string; + newVersion: string; +}; + +/** Entries that would need a dependency update in package.json to be deduplicated */ +type AnalyzeResultNewRange = { + name: string; + oldRange: string; + newRange: string; + oldVersion: string; + newVersion: string; +}; + +type AnalyzeResult = { + invalidRanges: AnalyzeResultInvalidRange[]; + newVersions: AnalyzeResultNewVersion[]; + newRanges: AnalyzeResultNewRange[]; +}; + +export class Lockfile { + static async load(path: string) { + const lockfileContents = await fs.readFile(path, 'utf8'); + const lockfile = parseLockfile(lockfileContents); + if (lockfile.type !== 'success') { + throw new Error(`Failed yarn.lock parse with ${lockfile.type}`); + } + + const data = lockfile.object as LockfileData; + const packages = new Map(); + + for (const [key, value] of Object.entries(data)) { + const [, name, range] = ENTRY_PATTERN.exec(key) ?? []; + if (!name) { + throw new Error(`Failed to parse yarn.lock entry '${key}'`); + } + + let queries = packages.get(name); + if (!queries) { + queries = []; + packages.set(name, queries); + } + queries.push({ range, version: value.version }); + } + + return new Lockfile(path, packages, data); + } + + private constructor( + private readonly path: string, + private readonly packages: Map, + private readonly data: LockfileData, + ) {} + + get(name: string): LockfileQueryEntry[] | undefined { + return this.packages.get(name); + } + + /** Analyzes the lockfile to identify possible actions and warnings for the entries */ + analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult { + const { filter } = options ?? {}; + const result: AnalyzeResult = { + invalidRanges: [], + newVersions: [], + newRanges: [], + }; + + for (const [name, allEntries] of this.packages) { + if (filter && !filter(name)) { + continue; + } + + // Get rid of and signal any invalid ranges upfront + const invalid = allEntries.filter(e => !semver.validRange(e.range)); + result.invalidRanges.push( + ...invalid.map(({ range }) => ({ name, range })), + ); + + // Grab all valid entries, if there aren't at least 2 different valid ones we're done + const entries = allEntries.filter(e => semver.validRange(e.range)); + if (entries.length < 2) { + continue; + } + + // Find all versions currently in use + const versions = Array.from( + new Set(entries.map(e => e.version)), + ).sort((v1, v2) => semver.rcompare(v1, v2)); + + // If we're not using at least 2 different versions we're done + if (versions.length < 2) { + continue; + } + + const acceptedVersions = new Set(); + for (const { version, range } of entries) { + // Finds the highest matching version from the the known versions + // TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one + const acceptedVersion = versions.find(v => semver.satisfies(v, range)); + if (!acceptedVersion) { + throw new Error( + `No existing version was accepted for range ${range}, searching through ${versions}`, + ); + } + + if (acceptedVersion !== version) { + result.newVersions.push({ + name, + range, + newVersion: acceptedVersion, + oldVersion: version, + }); + } + + acceptedVersions.add(acceptedVersion); + } + + // If all ranges were able to accept the same version, we're done + if (acceptedVersions.size === 1) { + continue; + } + + // Find the max version that we may want bump older packages to + const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0]; + // Find all existing ranges that satisfy the new max version, and pick the one that + // results in the highest minimum allowed version, usually being the more specific one + const maxEntry = entries + .filter(e => semver.satisfies(maxVersion, e.range)) + .map(e => ({ e, min: semver.minVersion(e.range) })) + .filter(p => p.min) + .sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e; + if (!maxEntry) { + throw new Error( + `No entry found that satisfies max version '${maxVersion}'`, + ); + } + + // Find all entries that don't satisfy the max version + for (const { version, range } of entries) { + if (semver.satisfies(maxVersion, range)) { + continue; + } + + result.newRanges.push({ + name, + oldRange: range, + newRange: maxEntry.range, + oldVersion: version, + newVersion: maxVersion, + }); + } + } + + return result; + } + + remove(name: string, range: string): boolean { + const query = `${name}@${range}`; + const existed = Boolean(this.data[query]); + delete this.data[query]; + + const newEntries = this.packages.get(name)?.filter(e => e.range !== range); + if (newEntries) { + this.packages.set(name, newEntries); + } + + return existed; + } + + /** Modifies the lockfile by bumping packages to the suggested versions */ + replaceVersions(results: AnalyzeResultNewVersion[]) { + for (const { name, range, oldVersion, newVersion } of results) { + const query = `${name}@${range}`; + + // Update the backing data + const entryData = this.data[query]; + if (!entryData) { + throw new Error(`No entry data for ${query}`); + } + if (entryData.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + + // Modifying the data in the entry is not enough, we need to reference an existing version object + const matchingEntry = Object.entries(this.data).find( + ([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion, + ); + if (!matchingEntry) { + throw new Error( + `No matching entry found for ${name} at version ${newVersion}`, + ); + } + this.data[query] = matchingEntry[1]; + + // Update our internal data structure + const entry = this.packages.get(name)?.find(e => e.range === range); + if (!entry) { + throw new Error(`No entry data for ${query}`); + } + if (entry.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + entry.version = newVersion; + } + } + + async save() { + await fs.writeFile(this.path, this.toString(), 'utf8'); + } + + toString() { + return stringifyLockfile(this.data); + } +} diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli/src/lib/versioning/index.ts new file mode 100644 index 0000000000..71fb7647ce --- /dev/null +++ b/packages/cli/src/lib/versioning/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { Lockfile } from './Lockfile'; +export { fetchPackageInfo, mapDependencies } from './packages'; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts new file mode 100644 index 0000000000..8155d4b5c0 --- /dev/null +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -0,0 +1,109 @@ +/* + * 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 mockFs from 'mock-fs'; +import * as runObj from '../run'; +import { paths } from '../paths'; +import { fetchPackageInfo, mapDependencies } from './packages'; + +describe('fetchPackageInfo', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should forward info', async () => { + jest + .spyOn(runObj, 'runPlain') + .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`); + + await expect(fetchPackageInfo('my-package')).resolves.toEqual({ + the: 'data', + }); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + 'my-package', + ); + }); +}); + +describe('mapDependencies', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should read dependencies', async () => { + // Make sure all modules involved in package discovery are in the module cache before we mock fs + const LernaProject = require('@lerna/project'); + const project = new LernaProject(paths.targetDir); + await project.getPackages(); + + mockFs({ + '/lerna.json': JSON.stringify({ + packages: ['pkgs/*'], + }), + '/pkgs/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '1 || 2', + }, + }), + '/pkgs/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '3', + '@backstage/cli': '^0', + }, + }), + }); + + const oldDir = paths.targetDir; + paths.targetDir = '/'; + + const dependencyMap = await mapDependencies(paths.targetDir); + expect(Array.from(dependencyMap)).toEqual([ + [ + '@backstage/core', + [ + { + name: 'a', + range: '1 || 2', + location: '/pkgs/a', + }, + { + name: 'b', + range: '3', + location: '/pkgs/b', + }, + ], + ], + [ + '@backstage/cli', + [ + { + name: 'b', + range: '^0', + location: '/pkgs/b', + }, + ], + ], + ]); + + paths.targetDir = oldDir; + }); +}); diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts new file mode 100644 index 0000000000..76e5dc49b0 --- /dev/null +++ b/packages/cli/src/lib/versioning/packages.ts @@ -0,0 +1,89 @@ +/* + * 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 { runPlain } from '../../lib/run'; + +const PREFIX = '@backstage'; + +const DEP_TYPES = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +// Package data as returned by `yarn info` +type YarnInfoInspectData = { + name: string; + 'dist-tags': { latest: string }; + versions: string[]; + time: { [version: string]: string }; +}; + +// Possible `yarn info` output +type YarnInfo = { + type: 'inspect'; + data: YarnInfoInspectData | { type: string; data: unknown }; +}; + +type PkgVersionInfo = { + range: string; + name: string; + location: string; +}; + +export async function fetchPackageInfo( + name: string, +): Promise { + const output = await runPlain('yarn', 'info', '--json', name); + const info = JSON.parse(output) as YarnInfo; + if (info.type !== 'inspect') { + throw new Error(`Received unknown yarn info for ${name}, ${output}`); + } + + return info.data as YarnInfoInspectData; +} + +/** Map all dependencies in the repo as dependency => dependents */ +export async function mapDependencies( + targetDir: string, +): Promise> { + const LernaProject = require('@lerna/project'); + const project = new LernaProject(targetDir); + const packages = await project.getPackages(); + + const dependencyMap = new Map(); + for (const pkg of packages) { + const deps = DEP_TYPES.flatMap( + t => Object.entries(pkg.get(t) ?? {}) as [string, string][], + ); + + for (const [name, range] of deps) { + if (name.startsWith(PREFIX)) { + dependencyMap.set( + name, + (dependencyMap.get(name) ?? []).concat({ + range, + name: pkg.name, + location: pkg.location, + }), + ); + } + } + } + + return dependencyMap; +} diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index b936715c70..63926daf24 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core-api +## 0.2.2 + +### Patch Changes + +- 9b9e86f8a: export oidc provider + ## 0.2.1 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 212d4c9046..c39dca3b7a 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.1", + "version": "0.2.2", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index c538065f3a..30b07887ad 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -311,6 +311,20 @@ export const oauth2ApiRef: ApiRef< description: 'Example of how to use oauth2 custom provider', }); +/** + * Provides authentication for custom OpenID Connect identity providers. + */ +export const oidcAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.oidc', + description: 'Example of how to use oidc custom provider', +}); + /** * Provides authentication for saml based identity providers */ diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts index 5d539dca27..523f713f45 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts @@ -31,7 +31,7 @@ export default class MockOAuthApi implements OAuthRequestApi { async triggerAll() { await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - return new Promise(resolve => { + return new Promise(resolve => { const subscription = this.authRequest$().subscribe(requests => { subscription.unsubscribe(); Promise.all(requests.map(request => request.trigger())).then(() => @@ -44,7 +44,7 @@ export default class MockOAuthApi implements OAuthRequestApi { async rejectAll() { await Promise.resolve(); // Wait a tick to allow new requests to get forwarded - return new Promise(resolve => { + return new Promise(resolve => { const subscription = this.authRequest$().subscribe(requests => { subscription.unsubscribe(); requests.map(request => request.reject()); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts index 971b023629..81140ffcc9 100644 --- a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -63,7 +63,7 @@ describe('WebStorage Storage API', () => { const selectedKeyNextHandler = jest.fn(); const mockData = { hello: 'im a great new value' }; - await new Promise(resolve => { + await new Promise(resolve => { storage.observe$('correctKey').subscribe({ next: (...args) => { selectedKeyNextHandler(...args); @@ -93,7 +93,7 @@ describe('WebStorage Storage API', () => { storage.set('correctKey', mockData); - await new Promise(resolve => { + await new Promise(resolve => { storage.observe$('correctKey').subscribe({ next: (...args) => { selectedKeyNextHandler(...args); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 213e2b5246..d29bc53452 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core +## 0.3.2 + +### Patch Changes + +- 475fc0aaa: Clear sidebar search field once a search is executed + ## 0.3.1 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index d65f0a16e5..f667ffe7be 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.3.1", + "version": "0.3.2", "private": false, "publishConfig": { "access": "public", @@ -64,7 +64,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index 61d4f3a7e1..1f18f54d2a 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -46,8 +46,11 @@ import { SamlAuth, oneloginAuthApiRef, OneLoginAuth, + oidcAuthApiRef, } from '@backstage/core-api'; +import OAuth2Icon from '@material-ui/icons/AcUnit'; + export const defaultApis = [ createApiFactory({ api: discoveryApiRef, @@ -153,4 +156,21 @@ export const defaultApis = [ factory: ({ discoveryApi, oauthRequestApi }) => OneLoginAuth.create({ discoveryApi, oauthRequestApi }), }), + createApiFactory({ + api: oidcAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider: { + id: 'oidc', + title: 'Your Identity Provider', + icon: OAuth2Icon, + }, + }), + }), ]; diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 498946577d..929828cee4 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -14,24 +14,23 @@ * limitations under the License. */ +import { IconComponent } from '@backstage/core-api'; +import { BackstageTheme } from '@backstage/theme'; import { + Badge, makeStyles, styled, TextField, Typography, - Badge, } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { IconComponent } from '@backstage/core-api'; import SearchIcon from '@material-ui/icons/Search'; import clsx from 'clsx'; import React, { - FC, + forwardRef, + KeyboardEventHandler, + ReactNode, useContext, useState, - KeyboardEventHandler, - forwardRef, - ReactNode, } from 'react'; import { NavLink } from 'react-router-dom'; import { sidebarConfig, SidebarContext } from './config'; @@ -121,7 +120,7 @@ type SidebarItemProps = { // If 'to' is set the item will act as a nav link with highlight, otherwise it's just a button to?: string; hasNotifications?: boolean; - onClick?: () => void; + onClick?: (ev: React.MouseEvent) => void; children?: ReactNode; }; @@ -212,16 +211,21 @@ export const SidebarItem = forwardRef( type SidebarSearchFieldProps = { onSearch: (input: string) => void; + to?: string; }; -export const SidebarSearchField: FC = props => { +export const SidebarSearchField = (props: SidebarSearchFieldProps) => { const [input, setInput] = useState(''); const classes = useStyles(); + const search = () => { + props.onSearch(input); + setInput(''); + }; + const handleEnter: KeyboardEventHandler = ev => { if (ev.key === 'Enter') { - props.onSearch(input); - setInput(''); + search(); } }; @@ -229,12 +233,25 @@ export const SidebarSearchField: FC = props => { setInput(ev.target.value); }; + const handleInputClick = (ev: React.MouseEvent) => { + // Clicking into the search fields shouldn't navigate to the search page + ev.preventDefault(); + ev.stopPropagation(); + }; + + const handleItemClick = (ev: React.MouseEvent) => { + // Clicking on the search icon while should execute a query with the current field content + search(); + ev.preventDefault(); + }; + return (
- + boolean, maxSeconds: number = 120) { let count = 0; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const handle = setInterval(() => { if (count++ > maxSeconds * 10) { reject(new Error('Timed out while waiting for condition')); @@ -112,7 +112,7 @@ export async function waitForExit(child: ChildProcess) { if (child.exitCode !== null) { throw new Error(`Child already exited with code ${child.exitCode}`); } - await new Promise((resolve, reject) => + await new Promise((resolve, reject) => child.once('exit', code => { if (code) { reject(new Error(`Child exited with code ${code}`)); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts index 668d973830..7c92c71460 100644 --- a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts @@ -58,7 +58,7 @@ describe('WebStorage Storage API', () => { const selectedKeyNextHandler = jest.fn(); const mockData = { hello: 'im a great new value' }; - await new Promise(resolve => { + await new Promise(resolve => { storage.observe$('correctKey').subscribe({ next: (...args) => { selectedKeyNextHandler(...args); @@ -88,7 +88,7 @@ describe('WebStorage Storage API', () => { storage.set('correctKey', mockData); - await new Promise(resolve => { + await new Promise(resolve => { storage.observe$('correctKey').subscribe({ next: (...args) => { selectedKeyNextHandler(...args); diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 5f1ad97e64..60a5caf55b 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-api-docs +## 0.2.2 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.1 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index bfefaef3a5..f364475bee 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,15 +20,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@kyma-project/asyncapi-react": "^0.14.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/react": "^16.9", "graphiql": "^1.0.0-alpha.10", "graphql": "^15.3.0", "react": "^16.13.1", @@ -39,7 +40,7 @@ "swagger-ui-react": "^3.31.1" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", @@ -47,7 +48,6 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "@types/react": "^16.9", "@types/swagger-ui-react": "^3.23.3", "cross-fetch": "^3.0.6", "msw": "^0.21.2" diff --git a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.test.tsx b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.test.tsx deleted file mode 100644 index 9a2bc0cfb1..0000000000 --- a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.test.tsx +++ /dev/null @@ -1,71 +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 { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render, waitFor } from '@testing-library/react'; -import * as React from 'react'; -import { ApiEntityPage } from './ApiEntityPage'; - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - const mockNavigate = jest.fn(); - return { - ...actual, - useNavigate: jest.fn(() => mockNavigate), - useParams: jest.fn(), - }; -}); - -const { - useParams, - useNavigate, -}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock( - 'react-router-dom', -); - -const errorApi = { post: () => {} }; - -describe('ApiEntityPage', () => { - it('should redirect to catalog page when name is not provided', async () => { - useParams.mockReturnValue({ - kind: 'Component', - optionalNamespaceAndName: '', - }); - - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - - , - ), - ); - - await waitFor(() => - expect(useNavigate()).toHaveBeenCalledWith('/api-docs'), - ); - }); -}); diff --git a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx b/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx deleted file mode 100644 index f755705ac7..0000000000 --- a/plugins/api-docs/src/components/ApiEntityPage/ApiEntityPage.tsx +++ /dev/null @@ -1,127 +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 { ApiEntity, Entity } from '@backstage/catalog-model'; -import { - Content, - errorApiRef, - Header, - Page, - Progress, - useApi, -} from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { Box } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import React, { useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; -import { ApiDefinitionCard } from '../ApiDefinitionCard'; - -const REDIRECT_DELAY = 1000; - -function headerProps( - kind: string, - namespace: string | undefined, - name: string, - entity: Entity | undefined, -): { headerTitle: string; headerType: string } { - return { - headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, - headerType: (() => { - let t = kind.toLowerCase(); - if (entity && entity.spec && 'type' in entity.spec) { - t += ' — '; - t += (entity.spec as { type: string }).type.toLowerCase(); - } - return t; - })(), - }; -} - -type EntityPageTitleProps = { - title: string; - entity: Entity | undefined; -}; - -const EntityPageTitle = ({ title }: EntityPageTitleProps) => ( - - {title} - -); - -export const ApiEntityPage = () => { - const { optionalNamespaceAndName } = useParams() as { - optionalNamespaceAndName: string; - }; - const navigate = useNavigate(); - const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - - const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); - - const { value: entity, error, loading } = useAsync( - () => catalogApi.getEntityByName({ kind: 'API', namespace, name }), - [catalogApi, namespace, name], - ); - - useEffect(() => { - if (!error && !loading && !entity) { - errorApi.post(new Error('Entity not found!')); - setTimeout(() => { - navigate('/'); - }, REDIRECT_DELAY); - } - }, [errorApi, navigate, error, loading, entity]); - - if (!name) { - navigate('/api-docs'); - return null; - } - - const { headerTitle, headerType } = headerProps( - 'API', - namespace, - name, - entity, - ); - - return ( - -
} - pageTitleOverride={headerTitle} - type={headerType} - /> - - {loading && } - - {error && ( - - {error.toString()} - - )} - - {entity && ( - <> - - - - - )} - - ); -}; diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 9222623aed..f4a66f02f2 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -23,12 +23,12 @@ import { useApi, useQueryParamState, } from '@backstage/core'; +import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; import { Chip, Link } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { apiDocsConfigRef } from '../../config'; -import { entityRoute } from '../../routes'; const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => { const config = useApi(apiDocsConfigRef); @@ -46,16 +46,10 @@ const columns: TableColumn[] = [ render: (entity: any) => ( {entity.metadata.name} diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index 958355f063..dbb32cee7b 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ -export { Router } from './catalog'; +export * from './catalog'; +export * from './components'; export { plugin } from './plugin'; diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 3fcbe8a1f4..06db03f2e1 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -18,8 +18,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { createApiFactory, createPlugin } from '@backstage/core'; import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; -import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage'; -import { entityRoute, rootRoute } from './routes'; +import { rootRoute } from './routes'; import { apiDocsConfigRef } from './config'; export const plugin = createPlugin({ @@ -40,6 +39,5 @@ export const plugin = createPlugin({ ], register({ router }) { router.addRoute(rootRoute, ApiExplorerPage); - router.addRoute(entityRoute, ApiEntityPage); }, }); diff --git a/plugins/api-docs/src/routes.ts b/plugins/api-docs/src/routes.ts index d2d8ef10c5..6adff78e47 100644 --- a/plugins/api-docs/src/routes.ts +++ b/plugins/api-docs/src/routes.ts @@ -24,12 +24,6 @@ export const rootRoute = createRouteRef({ title: 'APIs', }); -export const entityRoute = createRouteRef({ - icon: NoIcon, - path: '/api-docs/:optionalNamespaceAndName/', - title: 'API', -}); - export const catalogRoute = createRouteRef({ icon: NoIcon, path: '', diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index b435ec15f1..65cbddaae6 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -21,6 +21,7 @@ import { Logger } from 'winston'; import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { injectConfig, readConfigs } from '../lib/config'; +import fs from 'fs-extra'; export interface RouterOptions { config: Config; @@ -35,9 +36,18 @@ export async function createRouter( const { config, logger, appPackageName, staticFallbackHandler } = options; const appDistDir = resolvePackagePath(appPackageName, 'dist'); - logger.info(`Serving static app content from ${appDistDir}`); const staticDir = resolvePath(appDistDir, 'static'); + if (!(await fs.pathExists(staticDir))) { + logger.warn( + `Can't serve static app content from ${staticDir}, directory doesn't exist`, + ); + + return Router(); + } + + logger.info(`Serving static app content from ${appDistDir}`); + const appConfigs = await readConfigs({ config, appDistDir, diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 546855c92f..4a2161ad71 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-auth-backend +## 0.2.3 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + - @backstage/catalog-client@0.3.1 + ## 0.2.2 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8ce955350a..43e2c59f88 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-client": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-client": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -31,6 +31,7 @@ "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", + "express-session": "^1.17.1", "fs-extra": "^9.0.0", "got": "^11.5.2", "helmet": "^4.0.0", @@ -39,6 +40,7 @@ "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", + "openid-client": "^4.2.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", @@ -53,19 +55,44 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", + "@types/express-session": "^1.17.2", "@types/jwt-decode": "2.2.1", + "@types/nock": "^11.1.0", + "@types/openid-client": "^3.7.0", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", - "msw": "^0.21.2" + "msw": "^0.21.2", + "nock": "^13.0.5" }, "files": [ "dist", "migrations" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/auth-backend", + "type": "object", + "properties": { + "auth": { + "type": "object", + "properties": { + "session": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "visibility": "secret" + } + } + } + } + } + } + } } diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index a764d0b50e..d4a29c0959 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -18,6 +18,7 @@ import { createGithubProvider } from './github'; import { createGitlabProvider } from './gitlab'; import { createGoogleProvider } from './google'; import { createOAuth2Provider } from './oauth2'; +import { createOidcProvider } from './oidc'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; @@ -34,6 +35,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { auth0: createAuth0Provider, microsoft: createMicrosoftProvider, oauth2: createOAuth2Provider, + oidc: createOidcProvider, onelogin: createOneLoginProvider, }; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index baa66f0662..a09d5871d2 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -163,7 +163,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { }); } - private getUserPhoto(accessToken: string): Promise { + private getUserPhoto(accessToken: string): Promise { return new Promise(resolve => { got .get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', { @@ -184,7 +184,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, ); // User profile photo is optional, ignore errors and resolve undefined - resolve(); + resolve(undefined); }); }); } diff --git a/plugins/api-docs/src/components/ApiEntityPage/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts similarity index 92% rename from plugins/api-docs/src/components/ApiEntityPage/index.ts rename to plugins/auth-backend/src/providers/oidc/index.ts index 561350744b..acacbde73d 100644 --- a/plugins/api-docs/src/components/ApiEntityPage/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ApiEntityPage } from './ApiEntityPage'; +export { createOidcProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts new file mode 100644 index 0000000000..f2bd8dcb90 --- /dev/null +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -0,0 +1,128 @@ +/* + * 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 express from 'express'; +import { Session } from 'express-session'; +import nock from 'nock'; +import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import { createOidcProvider, OidcAuthProvider } from './provider'; +import { JWT, JWK } from 'jose'; +import { AuthProviderFactoryOptions } from '../types'; +import { Config } from '@backstage/config'; +import { OAuthAdapter } from '../../lib/oauth'; + +const issuerMetadata = { + issuer: 'https://oidc.test', + authorization_endpoint: 'https://oidc.test/as/authorization.oauth2', + token_endpoint: 'https://oidc.test/as/token.oauth2', + revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2', + userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid', + introspection_endpoint: 'https://oidc.test/as/introspect.oauth2', + jwks_uri: 'https://oidc.test/pf/JWKS', + scopes_supported: ['openid'], + claims_supported: ['email'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], +}; + +const clientMetadata = { + callbackUrl: 'https://oidc.test/callback', + clientId: 'testclientid', + clientSecret: 'testclientsecret', + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + tokenSignedResponseAlg: 'none', +}; + +describe('OidcAuthProvider', () => { + it('hit the metadata url', async () => { + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata); + const provider = new OidcAuthProvider(clientMetadata); + const { strategy } = ((await (provider as any).implementation) as any) as { + strategy: { + _client: ClientMetadata; + _issuer: IssuerMetadata; + }; + }; + // Assert that the expected request to the metadaurl was made. + expect(scope.isDone()).toBeTruthy(); + const { _client, _issuer } = strategy; + expect(_client.client_id).toBe(clientMetadata.clientId); + expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); + }); + + it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => { + const jwt = { + sub: 'alice', + iss: 'https://oidc.test', + aud: clientMetadata.clientId, + exp: Date.now() + 10000, + }; + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata) + .post('/as/token.oauth2') + .reply(200, { + id_token: JWT.sign(jwt, JWK.None), + access_token: 'test', + authorization_signed_response_alg: 'HS256', + }) + .get('/idp/userinfo.openid') + .reply(200, { + sub: 'alice', + email: 'alice@oidc.test', + }); + const provider = new OidcAuthProvider(clientMetadata); + const req = { + method: 'GET', + url: 'https://oidc.test/?code=test2', + session: ({ 'oidc:oidc.test': 'test' } as any) as Session, + } as express.Request; + await provider.handler(req); + expect(scope.isDone()).toBeTruthy(); + }); + + it('createOidcProvider', async () => { + const scope = nock('https://oidc.test') + .get('/.well-known/openid-configuration') + .reply(200, issuerMetadata); + const options = { + globalConfig: { + appUrl: 'https://oidc.test', + baseUrl: 'https://oidc.test', + }, + config: ({ + keys: jest.fn(() => ['test']), + getConfig: jest.fn(() => ({ + getString: (key: string) => { + const conf = { + ...clientMetadata, + metadataUrl: 'https://oidc.test/.well-known/openid-configuration', + } as any; + return conf[key] as string; + }, + })), + } as any) as Config, + } as AuthProviderFactoryOptions; + const provider = createOidcProvider(options) as OAuthAdapter; + expect(provider.start).toBeDefined(); + await new Promise(resolve => process.nextTick(resolve)); // advance a tick to give nock a chance to intercept the request + expect(scope.isDone()).toBeTruthy(); + }); +}); diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts new file mode 100644 index 0000000000..fbf38c69c4 --- /dev/null +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -0,0 +1,201 @@ +/* + * 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 express from 'express'; +import { + Issuer, + Client, + Strategy as OidcStrategy, + TokenSet, + UserinfoResponse, +} from 'openid-client'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types'; + +type PrivateInfo = { + refreshToken: string; +}; + +type OidcImpl = { + strategy: OidcStrategy; + client: Client; +}; + +export type OidcAuthProviderOptions = OAuthProviderOptions & { + metadataUrl: string; + tokenSignedResponseAlg?: string; +}; + +export class OidcAuthProvider implements OAuthHandlers { + private readonly implementation: Promise; + + constructor(options: OidcAuthProviderOptions) { + this.implementation = this.setupStrategy(options); + } + + async start(req: OAuthStartRequest): Promise { + const { strategy } = await this.implementation; + return await executeRedirectStrategy(req, strategy, { + accessType: 'offline', + prompt: 'none', + scope: `${req.scope} default`, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { strategy } = await this.implementation; + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest): Promise { + const { client } = await this.implementation; + const tokenset = await client.refresh(req.refreshToken); + if (!tokenset.access_token) { + throw new Error('Refresh failed'); + } + const profile = await client.userinfo(tokenset.access_token); + + return this.populateIdentity({ + providerInfo: { + accessToken: tokenset.access_token, + refreshToken: tokenset.refresh_token, + expiresInSeconds: tokenset.expires_in, + idToken: tokenset.id_token, + scope: tokenset.scope || '', + }, + profile, + }); + } + + private async setupStrategy( + options: OidcAuthProviderOptions, + ): Promise { + const issuer = await Issuer.discover(options.metadataUrl); + const client = new issuer.Client({ + client_id: options.clientId, + client_secret: options.clientSecret, + redirect_uris: [options.callbackUrl], + response_types: ['code'], + id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', + }); + + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false as true, + }, + ( + tokenset: TokenSet, + userinfo: UserinfoResponse, + done: PassportDoneCallback, + ) => { + const profile: ProfileInfo = { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }; + + done( + undefined, + { + providerInfo: { + idToken: tokenset.id_token || '', + accessToken: tokenset.access_token || '', + scope: tokenset.scope || '', + expiresInSeconds: tokenset.expires_in, + }, + profile, + }, + { + refreshToken: tokenset.refresh_token || '', + }, + ); + }, + ); + strategy.error = console.error; + return { strategy, client }; + } + + // Use this function to grab the user profile info from the token + // Then populate the profile with it + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Profile does not contain an email'); + } + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export const createOidcProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'oidc'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const metadataUrl = envConfig.getString('metadataUrl'); + const tokenSignedResponseAlg = envConfig.getString( + 'tokenSignedResponseAlg', + ); + + const provider = new OidcAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + metadataUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 0d19e67fc4..47e0d14caa 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -27,6 +27,8 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity'; import { createAuthProvider } from '../providers'; +import session from 'express-session'; +import passport from 'passport'; export interface RouterOptions { logger: Logger; @@ -59,7 +61,16 @@ export async function createRouter({ }); const catalogApi = new CatalogClient({ discoveryApi: discovery }); - router.use(cookieParser()); + const secret = config.getOptionalString('auth.session.secret'); + if (secret) { + router.use(cookieParser(secret)); + // TODO: Configure the server-side session storage. The default MemoryStore is not designed for production + router.use(session({ secret, saveUninitialized: false, resave: false })); + router.use(passport.initialize()); + router.use(passport.session()); + } else { + router.use(cookieParser()); + } router.use(express.urlencoded({ extended: false })); router.use(express.json()); diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 1a3199ee14..3bf25a494b 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-catalog-backend +## 0.2.2 + +### Patch Changes + +- 0c2121240: Add support for reading groups and users from the Microsoft Graph API. +- 1185919f3: Marked the `Group` entity fields `ancestors` and `descendants` for deprecation on Dec 6th, 2020. See https://github.com/backstage/backstage/issues/3049 for details. + + Code that consumes these fields should remove those usages as soon as possible. There is no current or planned replacement for these fields. + + The BuiltinKindsEntityProcessor has been updated to inject these fields as empty arrays if they are missing. Therefore, if you are on a catalog instance that uses the updated version of this code, you can start removing the fields from your source catalog-info.yaml data as well, without breaking validation. + + After Dec 6th, the fields will be removed from types and classes of the Backstage repository. At the first release after that, they will not be present in released packages either. + + If your catalog-info.yaml files still contain these fields after the deletion, they will still be valid and your ingestion will not break, but they won't be visible in the types for consuming code. + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + ## 0.2.1 ### Patch Changes diff --git a/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js b/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js new file mode 100644 index 0000000000..a8cf62b4c4 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201123205611_relations_table_uniq.js @@ -0,0 +1,93 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + // sqlite doesn't support dropPrimary so we recreate it properly instead + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + table.index('source_full_name', 'source_full_name_idx'); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropPrimary(); + table.index('source_full_name', 'source_full_name_idx'); + }); + } +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('entities_relations'); + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } else { + await knex.schema.alterTable('entities_relations', table => { + table.dropIndex([], 'source_full_name_idx'); + table.primary(['source_full_name', 'type', 'target_full_name']); + }); + } +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index c55e5a947b..d3c77db6dc 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -21,11 +21,12 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@octokit/graphql": "^4.5.6", "@types/express": "^4.17.6", + "@types/ldapjs": "^1.0.9", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", @@ -47,11 +48,10 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/test-utils": "^0.1.3", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", - "@types/ldapjs": "^1.0.9", "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index f86ab9e598..287756b0a8 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -345,7 +345,11 @@ export class CommonDatabase implements Database { ); // TODO(blam): translate constraint failures to sane NotFoundError instead - await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE); + await tx.batchInsert( + 'entities_relations', + deduplicateRelations(relationsRows), + BATCH_SIZE, + ); } async addLocation( @@ -506,7 +510,7 @@ export class CommonDatabase implements Database { .orderBy(['type', 'target_full_name']) .select(); - entity.relations = relations.map(r => ({ + entity.relations = deduplicateRelations(relations).map(r => ({ target: parseEntityName(r.target_full_name), type: r.type, })); @@ -517,3 +521,12 @@ export class CommonDatabase implements Database { }; } } + +function deduplicateRelations( + rows: DbEntitiesRelationsRow[], +): DbEntitiesRelationsRow[] { + return lodash.uniqBy( + rows, + r => `${r.source_full_name}:${r.target_full_name}:${r.type}`, + ); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts new file mode 100644 index 0000000000..94d35cc109 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -0,0 +1,227 @@ +/* + * 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 { + ApiEntity, + ComponentEntity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; +import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; + +describe('BuiltinKindsEntityProcessor', () => { + it('fills in fields for #3049', async () => { + const p = new BuiltinKindsEntityProcessor(); + const result = await p.preProcessEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'n', + }, + spec: { + type: 't', + children: [], + } as any, + }); + expect(result).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'n', + }, + spec: { + type: 't', + children: [], + ancestors: [], + descendants: [], + }, + }); + }); + + describe('postProcessEntity', () => { + const processor = new BuiltinKindsEntityProcessor(); + const location = { type: 'a', target: 'b' }; + const emit = jest.fn(); + + afterEach(() => jest.resetAllMocks()); + + it('generates relations for component entities', async () => { + const entity: ComponentEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + lifecycle: 'l', + implementsApis: ['a'], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'a' }, + type: 'apiProvidedBy', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'providesApi', + target: { kind: 'API', namespace: 'default', name: 'a' }, + }, + }); + }); + + it('generates relations for api entities', async () => { + const entity: ApiEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'n' }, + spec: { + type: 'service', + owner: 'o', + lifecycle: 'l', + definition: 'd', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'API', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); + + it('generates relations for user entities', async () => { + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'n' }, + spec: { + memberOf: ['g'], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'User', namespace: 'default', name: 'n' }, + type: 'memberOf', + target: { kind: 'Group', namespace: 'default', name: 'g' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'g' }, + type: 'hasMember', + target: { kind: 'User', namespace: 'default', name: 'n' }, + }, + }); + }); + + it('generates relations for group entities', async () => { + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { + type: 't', + parent: 'p', + ancestors: [], + children: ['c'], + descendants: [], + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'n' }, + type: 'childOf', + target: { kind: 'Group', namespace: 'default', name: 'p' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'p' }, + type: 'parentOf', + target: { kind: 'Group', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'c' }, + type: 'childOf', + target: { kind: 'Group', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'n' }, + type: 'parentOf', + target: { kind: 'Group', namespace: 'default', name: 'c' }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 4d1eb5e583..a4e62e5b8c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -15,15 +15,31 @@ */ import { + ApiEntity, apiEntityV1alpha1Validator, + ComponentEntity, componentEntityV1alpha1Validator, Entity, + getEntityName, + GroupEntity, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, + LocationSpec, + parseEntityRef, + RELATION_API_PROVIDED_BY, + RELATION_CHILD_OF, + RELATION_HAS_MEMBER, + RELATION_MEMBER_OF, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + RELATION_PARENT_OF, + RELATION_PROVIDES_API, templateEntityV1alpha1Validator, + UserEntity, userEntityV1alpha1Validator, } from '@backstage/catalog-model'; -import { CatalogProcessor } from './types'; +import * as result from './results'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ @@ -35,6 +51,25 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { userEntityV1alpha1Validator, ]; + async preProcessEntity(entity: Entity): Promise { + // NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields + // scheduled for removal Dec 6th, 2020. This code can be deleted after that + // point. See https://github.com/backstage/backstage/issues/3049 + if ( + entity.apiVersion === 'backstage.io/v1alpha1' && + entity.kind === 'Group' && + entity.spec + ) { + if (!entity.spec.ancestors) { + entity.spec.ancestors = []; + } + if (!entity.spec.descendants) { + entity.spec.descendants = []; + } + } + return entity; + } + async validateEntityKind(entity: Entity): Promise { for (const validator of this.validators) { const result = await validator.check(entity); @@ -45,4 +80,114 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { return false; } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + const selfRef = getEntityName(entity); + + /* + * Utilities + */ + + function doEmit( + targets: string | string[] | undefined, + context: { defaultKind: string; defaultNamespace: string }, + outgoingRelation: string, + incomingRelation: string, + ): void { + if (!targets) { + return; + } + for (const target of [targets].flat()) { + const targetRef = parseEntityRef(target, context); + emit( + result.relation({ + source: selfRef, + type: outgoingRelation, + target: targetRef, + }), + ); + emit( + result.relation({ + source: targetRef, + type: incomingRelation, + target: selfRef, + }), + ); + } + } + + /* + * Emit relations for the Component kind + */ + + if (entity.kind === 'Component') { + const component = entity as ComponentEntity; + doEmit( + component.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + component.spec.implementsApis, + { defaultKind: 'API', defaultNamespace: selfRef.namespace }, + RELATION_PROVIDES_API, + RELATION_API_PROVIDED_BY, + ); + } + + /* + * Emit relations for the API kind + */ + + if (entity.kind === 'API') { + const api = entity as ApiEntity; + doEmit( + api.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + + /* + * Emit relations for the User kind + */ + + if (entity.kind === 'User') { + const user = entity as UserEntity; + doEmit( + user.spec.memberOf, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_MEMBER_OF, + RELATION_HAS_MEMBER, + ); + } + + /* + * Emit relations for the Group kind + */ + + if (entity.kind === 'Group') { + const group = entity as GroupEntity; + doEmit( + group.spec.parent, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_CHILD_OF, + RELATION_PARENT_OF, + ); + doEmit( + group.spec.children, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_PARENT_OF, + RELATION_CHILD_OF, + ); + } + + return entity; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts deleted file mode 100644 index 4c67589789..0000000000 --- a/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts +++ /dev/null @@ -1,74 +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 { - Entity, - ENTITY_DEFAULT_NAMESPACE, - LocationSpec, - parseEntityRef, - ApiEntityV1alpha1, - ComponentEntityV1alpha1, - RELATION_OWNED_BY, - RELATION_OWNER_OF, - getEntityName, -} from '@backstage/catalog-model'; -import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import * as result from './results'; - -const includedKinds = new Set(['api', 'component']); - -export class OwnerRelationProcessor implements CatalogProcessor { - async postProcessEntity( - entity: Entity, - _location: LocationSpec, - emit: CatalogProcessorEmit, - ): Promise { - if (!includedKinds.has(entity.kind.toLowerCase())) { - return entity; - } - const apiOrComponentEntity = entity as - | ApiEntityV1alpha1 - | ComponentEntityV1alpha1; - - const owner = apiOrComponentEntity.spec?.owner; - if (owner) { - const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE; - - const selfRef = getEntityName(entity); - const ownerRef = parseEntityRef(owner, { - defaultKind: 'group', - defaultNamespace: namespace, - }); - - emit( - result.relation({ - source: selfRef, - type: RELATION_OWNED_BY, - target: ownerRef, - }), - ); - emit( - result.relation({ - source: ownerRef, - type: RELATION_OWNER_OF, - target: selfRef, - }), - ); - } - - return entity; - } -} diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 118f977125..49d4e03b64 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -22,10 +22,11 @@ export * from './types'; export { parseEntityYaml } from './util/parse'; export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; -export { OwnerRelationProcessor } from './OwnerRelationProcessor'; +export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor'; export { LocationRefProcessor } from './LocationEntityProcessor'; export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index ee7c6f2246..58897a10a7 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -144,7 +144,7 @@ describe('CatalogBuilder', () => { owner: 'o', lifecycle: 'l', }, - relations: [], + relations: expect.anything(), }, ]); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 61e8a834eb..856d5e54f2 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -37,15 +37,16 @@ import { import { DatabaseManager } from '../database'; import { AnnotateLocationEntityProcessor, + BuiltinKindsEntityProcessor, CatalogProcessor, CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, HigherOrderOperation, HigherOrderOperations, + LdapOrgReaderProcessor, LocationReaders, LocationRefProcessor, - OwnerRelationProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -54,8 +55,6 @@ import { } from '../ingestion'; import { CatalogRulesEnforcer } from '../ingestion/CatalogRules'; import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; -import { BuiltinKindsEntityProcessor } from '../ingestion/processors/BuiltinKindsEntityProcessor'; -import { LdapOrgReaderProcessor } from '../ingestion/processors/LdapOrgReaderProcessor'; import { jsonPlaceholderResolver, textPlaceholderResolver, @@ -288,7 +287,6 @@ export class CatalogBuilder { new UrlReaderProcessor({ reader, logger }), new CodeOwnersProcessor({ reader }), new LocationRefProcessor(), - new OwnerRelationProcessor(), new AnnotateLocationEntityProcessor(), ); } diff --git a/plugins/catalog-backend/src/util/runPeriodically.ts b/plugins/catalog-backend/src/util/runPeriodically.ts index 2f65d0e6f3..c6d478fcbe 100644 --- a/plugins/catalog-backend/src/util/runPeriodically.ts +++ b/plugins/catalog-backend/src/util/runPeriodically.ts @@ -27,7 +27,7 @@ export function runPeriodically(fn: () => any, delayMs: number): () => void { let cancel: () => void; let cancelled = false; - const cancellationPromise = new Promise(resolve => { + const cancellationPromise = new Promise(resolve => { cancel = () => { resolve(); cancelled = true; diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index 35737b7dd1..56911b9c45 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-graphql +## 0.2.2 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + ## 0.2.1 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index c55be89b62..a489d66e5b 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", @@ -32,7 +32,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/test-utils": "^0.1.3", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 1b8b884f1c..9137b7e948 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-catalog +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [ef2831dde] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-scaffolder@0.3.1 + - @backstage/catalog-client@0.3.1 + - @backstage/plugin-techdocs@0.2.3 + ## 0.2.2 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 2c65096fd6..7b58358734 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-scaffolder": "^0.3.0", - "@backstage/plugin-techdocs": "^0.2.2", + "@backstage/catalog-client": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-scaffolder": "^0.3.1", + "@backstage/plugin-techdocs": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +43,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 2894ce1bd3..a81531e28d 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-circleci +## 0.2.2 + +### Patch Changes + +- a8de7f554: Improved CircleCI builds table to show more information and relevant links +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.1 ### Patch Changes diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 00a39ce6c8..4802e8bee2 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -61,7 +61,7 @@ proxy: ``` 5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) -6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) +6. Add `circleci.com/project-slug` annotation to your catalog-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) ```yaml apiVersion: backstage.io/v1alpha1 @@ -88,4 +88,4 @@ spec: ## Limitations - CircleCI has pretty strict rate limits per token, be careful with opened tabs -- CircelCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356) +- CircleCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356) diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index c9ad54659f..fd32ff9891 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,14 +21,16 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "circleci-api": "^4.0.0", + "dayjs": "^1.9.4", + "lodash": "^4.17.15", "moment": "^2.25.3", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -38,7 +40,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 30994f5ce3..18ae5a2d04 100644 --- a/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/circleci/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, useEffect } from 'react'; + +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { InfoCard, Progress, Link } from '@backstage/core'; import { BuildWithSteps, BuildStepAction } from '../../api'; @@ -31,7 +32,8 @@ import LaunchIcon from '@material-ui/icons/Launch'; import { useBuildWithSteps } from '../../state/useBuildWithSteps'; const IconLink = (IconButton as any) as typeof MaterialLink; -const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( + +const BuildName = ({ build }: { build?: BuildWithSteps }) => ( #{build?.build_num} - {build?.subject} @@ -39,6 +41,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); + const useStyles = makeStyles(theme => ({ neutral: {}, failed: { @@ -96,7 +99,7 @@ const pickClassName = ( return classes.neutral; }; -const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( +const BuildsList = ({ build }: { build?: BuildWithSteps }) => ( {build && build.steps && @@ -108,8 +111,11 @@ const BuildsList: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); -const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({ +const ActionsList = ({ actions, +}: { + actions: BuildStepAction[]; + name: string; }) => { const classes = useStyles(); return ( diff --git a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx index bed96bb24c..685e61a5af 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/Builds/Builds.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { CITable } from '../CITable'; import { useBuilds } from '../../../../state'; -export const Builds: FC<{}> = () => { +export const Builds = () => { const [ { total, loading, value, projectName, page, pageSize }, { setPage, retry, setPageSize }, diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index 8e693429c2..e5e8b951be 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,10 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton } from '@material-ui/core'; + +import React from 'react'; +import { + Avatar, + Link, + Typography, + Box, + IconButton, + makeStyles, +} from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; +import LaunchIcon from '@material-ui/icons/Launch'; import { Link as RouterLink, generatePath } from 'react-router-dom'; import { StatusError, @@ -27,17 +36,22 @@ import { Table, TableColumn, } from '@backstage/core'; +import { durationHumanized, relativeTimeTo } from '../../../../util'; import { circleCIBuildRouteRef } from '../../../../route-refs'; export type CITableBuildInfo = { id: string; buildName: string; buildUrl?: string; + startTime?: string; + stopTime?: string; source: { branchName: string; commit: { hash: string; + shortHash: string; url: string; + committerName?: string; }; }; status: string; @@ -48,6 +62,18 @@ export type CITableBuildInfo = { failed: number; testUrl: string; // fixme better name }; + workflow: { + id: string; + url: string; + name?: string; + jobName?: string; + }; + user: { + isUser: boolean; + login: string; + name?: string; + avatarUrl?: string; + }; onRestartClick: () => void; }; @@ -69,6 +95,43 @@ const getStatusComponent = (status: string | undefined = '') => { } }; +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + '& > *': { + margin: theme.spacing(1), + verticalAlign: 'center', + }, + }, + small: { + width: theme.spacing(3), + height: theme.spacing(3), + }, +})); + +const SourceInfo = ({ build }: { build: CITableBuildInfo }) => { + const classes = useStyles(); + const { user, source } = build; + + return ( + + + + {source?.branchName} + + {source?.commit?.url !== undefined ? ( + + {source?.commit.shortHash} + + ) : ( + source?.commit.shortHash + )} + + + + ); +}; + const generatedColumns: TableColumn[] = [ { title: 'ID', @@ -80,26 +143,43 @@ const generatedColumns: TableColumn[] = [ title: 'Build', field: 'buildName', highlight: true, + width: '20%', render: (row: Partial) => ( - {row.buildName} + {row.buildName ? row.buildName : row?.workflow?.name} + + ), + }, + { + title: 'Job', + field: 'buildName', + highlight: true, + render: (row: Partial) => ( + + + + + {row?.workflow?.jobName} + ), }, { title: 'Source', + field: 'source.commit.hash', + highlight: true, render: (row: Partial) => ( - <> -

{row.source?.branchName}

-

{row.source?.commit.hash}

- + ), }, { title: 'Status', + field: 'status', render: (row: Partial) => ( {getStatusComponent(row.status)} @@ -108,14 +188,32 @@ const generatedColumns: TableColumn[] = [ ), }, + { + title: 'Time', + field: 'startTime', + render: (row: Partial) => ( + <> + + run {relativeTimeTo(row?.startTime)} + + + took {durationHumanized(row?.startTime, row?.stopTime)} + + + ), + }, + { + title: 'Workflow', + field: 'workflow.name', + }, { title: 'Actions', + width: '10%', render: (row: Partial) => ( ), - width: '10%', }, ]; @@ -130,7 +228,8 @@ type Props = { pageSize: number; onChangePageSize: (pageSize: number) => void; }; -export const CITable: FC = ({ + +export const CITable = ({ projectName, loading, pageSize, @@ -140,11 +239,16 @@ export const CITable: FC = ({ onChangePage, onChangePageSize, total, -}) => { +}: Props) => { return ( { if (!status) return ''; @@ -41,6 +43,39 @@ const makeReadableStatus = (status: string | undefined) => { } as Record)[status]; }; +const mapWorkflowDetails = (buildData: BuildSummary) => { + // Workflows should be an object: fixed in https://github.com/worldturtlemedia/circleci-api/pull/787 + const { workflows } = (buildData as any) ?? {}; + + return { + id: workflows?.workflow_id, + url: `${buildData.build_url}/workflows/${workflows?.workflow_id}`, + jobName: workflows?.job_name, + name: workflows?.workflow_name, + }; +}; + +const mapSourceDetails = (buildData: BuildSummary) => { + const commitDetails = getOr({}, 'all_commit_details[0]', buildData); + + return { + branchName: String(buildData.branch), + commit: { + hash: String(buildData.vcs_revision), + shortHash: String(buildData.vcs_revision).substr(0, 7), + committerName: buildData.committer_name, + url: commitDetails.commit_url, + }, + }; +}; + +const mapUser = (buildData: BuildSummary) => ({ + isUser: buildData?.user?.is_user || false, + login: buildData?.user?.login || 'none', + name: (buildData?.user as any)?.name, + avatarUrl: (buildData?.user as any)?.avatar_url, +}); + export const transform = ( buildsData: BuildSummary[], restartBuild: { (buildId: number): Promise }, @@ -52,16 +87,14 @@ export const transform = ( ? buildData.subject + (buildData.retry_of ? ` (retry of #${buildData.retry_of})` : '') : '', + startTime: buildData.start_time, + stopTime: buildData.stop_time, onRestartClick: () => typeof buildData.build_num !== 'undefined' && restartBuild(buildData.build_num), - source: { - branchName: String(buildData.branch), - commit: { - hash: String(buildData.vcs_revision), - url: 'todo', - }, - }, + source: mapSourceDetails(buildData), + workflow: mapWorkflowDetails(buildData), + user: mapUser(buildData), status: makeReadableStatus(buildData.status), buildUrl: buildData.build_url, }; @@ -79,6 +112,7 @@ export const useProjectSlugFromEntity = () => { export function mapVcsType(vcs: string): GitType { switch (vcs) { + case 'gh': case 'github': return GitType.GITHUB; default: @@ -93,7 +127,7 @@ export function useBuilds() { const [total, setTotal] = useState(0); const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(5); + const [pageSize, setPageSize] = useState(10); const getBuilds = useCallback( async ({ limit, offset }: { limit: number; offset: number }) => { diff --git a/plugins/circleci/src/util/index.ts b/plugins/circleci/src/util/index.ts new file mode 100644 index 0000000000..55bb001ae4 --- /dev/null +++ b/plugins/circleci/src/util/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './time'; diff --git a/plugins/circleci/src/util/time.test.ts b/plugins/circleci/src/util/time.test.ts new file mode 100644 index 0000000000..dae029c8d2 --- /dev/null +++ b/plugins/circleci/src/util/time.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { durationHumanized, relativeTimeTo } from './time'; + +describe('times utils', () => { + describe('toRelativeTime', () => { + it('should give a relative time of x from today', () => { + expect(relativeTimeTo('2020-01-01')).toEqual(expect.any(String)); + }); + }); + + describe('durationHumanized', () => { + it('should give a humanized duration', () => { + expect(durationHumanized('2020-11-01', '2020-11-03')).toBe('2 days'); + }); + }); +}); diff --git a/plugins/circleci/src/util/time.ts b/plugins/circleci/src/util/time.ts new file mode 100644 index 0000000000..8756333a87 --- /dev/null +++ b/plugins/circleci/src/util/time.ts @@ -0,0 +1,35 @@ +/* + * 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 dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import relativeTimePlugin from 'dayjs/plugin/relativeTime'; + +dayjs.extend(durationPlugin); +dayjs.extend(relativeTimePlugin); + +type DateTimeObject = Date | string | number | undefined; + +export function relativeTimeTo(time: DateTimeObject, withoutSuffix = false) { + return dayjs().to(dayjs(time), withoutSuffix); +} + +export function durationHumanized( + startTime: DateTimeObject, + endTime: DateTimeObject, +) { + return dayjs.duration(dayjs(startTime).diff(dayjs(endTime))).humanize(); +} diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index 9d02fd1ea3..4cb2d01377 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cloudbuild +## 0.2.2 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.1 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index c0b36ef1e7..3e7f0c9175 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -39,7 +39,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index 157d92f251..b7bdfa9d50 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-cost-insights +## 0.4.1 + +### Patch Changes + +- 8e6728e25: fix product icon configuration +- c93a14b49: truncate large percentages > 1000% +- Updated dependencies [475fc0aaa] + - @backstage/core@0.3.2 + ## 0.4.0 ### Minor Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 8ebeac91c0..06083b855b 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.4.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ }, "dependencies": { "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/test-utils": "^0.1.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", @@ -46,7 +46,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 153184bdb1..f5f73b4faa 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -21,7 +21,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 0415a5ffb3..59bdcc547c 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -31,7 +31,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index b679d6844b..96f3876802 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-actions +## 0.2.2 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.1 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 49ecfc5423..b1924034f1 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", "@backstage/core-api": "^0.2.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -40,7 +40,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index ad80d26e61..67edcca51e 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -21,7 +21,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -32,7 +32,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index a5523afc6f..bde5dea9a3 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 44febbc63d..aeeb069f19 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-jenkins +## 0.3.1 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.3.0 ### Minor Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 1d3e7f60e5..5e0a701caf 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -41,7 +41,7 @@ export JENKINS_BASIC_AUTH_HEADER="Basic $HEADER" ``` 5. Run app with `yarn start` -6. Add the Jenkins folder annotation to your `component-info.yaml`, (note: currently this plugin only supports folders and Git SCM) +6. Add the Jenkins folder annotation to your `catalog-info.yaml`, (note: currently this plugin only supports folders and Git SCM) ```yaml apiVersion: backstage.io/v1alpha1 diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 0279592a75..961f551dfa 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -36,7 +36,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 7c55a7407d..2877264e54 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-kubernetes-backend +## 0.2.0 + +### Minor Changes + +- 1166fcc36: add kubernetes selector to component model + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + ## 0.1.3 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index e58bb4f047..8fe2f4c58f 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.1.3", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,7 @@ "main": "dist/index.cjs.js", "types": "dist/index.d.ts" }, + "configSchema": "schema.d.ts", "scripts": { "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", @@ -20,7 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@kubernetes/client-node": "^0.12.1", "@types/express": "^4.17.6", @@ -37,10 +39,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "supertest": "^4.0.2" }, "files": [ - "dist" + "dist", + "schema.d.ts" ] } diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts new file mode 100644 index 0000000000..862cb6d2ce --- /dev/null +++ b/plugins/kubernetes-backend/schema.d.ts @@ -0,0 +1,27 @@ +/* + * 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 interface Config { + kubernetes?: { + serviceLocatorMethod: 'multiTenant'; + clusterLocatorMethods: 'config'[]; + clusters: { + url: string; + name: string; + serviceAccountToken: string; + authProvider: 'serviceAccount'; + }[]; + }; +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts index 7f9b2f0bae..0dd2a4bcc2 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -15,13 +15,13 @@ */ import { KubernetesAuthTranslator } from './types'; -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export class GoogleKubernetesAuthTranslator implements KubernetesAuthTranslator { async decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise { const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( {}, diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts index ecf2f12b72..6433e41546 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -15,7 +15,7 @@ */ import { KubernetesAuthTranslator } from './types'; -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export class ServiceAccountKubernetesAuthTranslator implements KubernetesAuthTranslator { @@ -23,7 +23,7 @@ export class ServiceAccountKubernetesAuthTranslator clusterDetails: ClusterDetails, // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. // @ts-ignore-start - requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars + requestBody: KubernetesRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars // @ts-ignore-end ): Promise { return clusterDetails; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts index f89e04456f..c01a57889c 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { AuthRequestBody, ClusterDetails } from '../types/types'; +import { KubernetesRequestBody, ClusterDetails } from '../types/types'; export interface KubernetesAuthTranslator { decorateClusterDetailsWithAuth( clusterDetails: ClusterDetails, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise; } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 6294b3a7ed..29233cf7fd 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -16,6 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { ObjectFetchParams } from '..'; describe('KubernetesClientProvider', () => { let clientMock: any; @@ -57,16 +58,17 @@ describe('KubernetesClientProvider', () => { clientMock.listServiceForAllNamespaces.mockRejectedValue(errorResponse); - const result = await sut.fetchObjectsByServiceId( - 'some-service', - { + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['pods', 'services']), - ); + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); expect(result).toStrictEqual({ errors: [expectedResult], @@ -120,16 +122,17 @@ describe('KubernetesClientProvider', () => { }, }); - const result = await sut.fetchObjectsByServiceId( - 'some-service', - { + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['pods', 'services']), - ); + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); expect(result).toStrictEqual({ errors: [], @@ -169,16 +172,17 @@ describe('KubernetesClientProvider', () => { }); it('should throw error on unknown type', () => { expect(() => - sut.fetchObjectsByServiceId( - 'some-service', - { + sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { name: 'cluster1', url: 'http://localhost:9999', serviceAccountToken: 'token', authProvider: 'serviceAccount', }, - new Set(['foo']), - ), + objectTypesToFetch: new Set(['foo']), + labelSelector: '', + }), ).toThrow('unrecognised type=foo'); expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(0); @@ -254,4 +258,46 @@ describe('KubernetesClientProvider', () => { }, ); }); + it('should always add a labelSelector query', async () => { + clientMock.listPodForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + }); + + clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'service-name', + }, + }, + ], + }, + }); + + await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + objectTypesToFetch: new Set(['pods', 'services']), + labelSelector: '', + }); + + const mockCall = clientMock.listPodForAllNamespaces.mock.calls[0]; + const actualSelector = mockCall[mockCall.length - 1]; + const expectedSelector = 'backstage.io/kubernetes-id=some-service'; + expect(actualSelector).toBe(expectedSelector); + }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index f66a45816a..d9c6ee6d1a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -38,6 +38,7 @@ import { FetchResponseWrapper, KubernetesFetchError, KubernetesErrorTypes, + ObjectFetchParams, } from '..'; import lodash, { Dictionary } from 'lodash'; @@ -105,15 +106,16 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { this.logger = logger; } - fetchObjectsByServiceId( - serviceId: string, - clusterDetails: ClusterDetails, - objectTypesToFetch: Set, + fetchObjectsForService( + params: ObjectFetchParams, ): Promise { - const fetchResults = Array.from(objectTypesToFetch).map(type => { - return this.fetchByObjectType(serviceId, clusterDetails, type).catch( - captureKubernetesErrorsRethrowOthers, - ); + const fetchResults = Array.from(params.objectTypesToFetch).map(type => { + return this.fetchByObjectType( + params.clusterDetails, + type, + params.labelSelector || + `backstage.io/kubernetes-id=${params.serviceId}`, + ).catch(captureKubernetesErrorsRethrowOthers); }); return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); @@ -121,45 +123,47 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { // TODO could probably do with a tidy up private fetchByObjectType( - serviceId: string, clusterDetails: ClusterDetails, type: KubernetesObjectTypes, + labelSelector: string, ): Promise { switch (type) { case 'pods': - return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({ - type: type, - resources: r, - })); + return this.fetchPodsForService(clusterDetails, labelSelector).then( + r => ({ + type: type, + resources: r, + }), + ); case 'configmaps': - return this.fetchConfigMapsByServiceId( - serviceId, + return this.fetchConfigMapsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'deployments': - return this.fetchDeploymentsByServiceId( - serviceId, + return this.fetchDeploymentsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'replicasets': - return this.fetchReplicaSetsByServiceId( - serviceId, + return this.fetchReplicaSetsForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'services': - return this.fetchServicesByServiceId( - serviceId, + return this.fetchServicesForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'horizontalpodautoscalers': - return this.fetchHorizontalPodAutoscalersByServiceId( - serviceId, + return this.fetchHorizontalPodAutoscalersForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); case 'ingresses': - return this.fetchIngressesByServiceId( - serviceId, + return this.fetchIngressesForService( clusterDetails, + labelSelector, ).then(r => ({ type: type, resources: r })); default: // unrecognised type @@ -192,79 +196,54 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { }); } - private fetchServicesByServiceId( - serviceId: string, + private fetchServicesForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listServiceForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listServiceForAllNamespaces(false, '', '', labelSelector), ); } - private fetchPodsByServiceId( - serviceId: string, + private fetchPodsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listPodForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listPodForAllNamespaces(false, '', '', labelSelector), ); } - private fetchConfigMapsByServiceId( - serviceId: string, + private fetchConfigMapsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ core }) => - core.listConfigMapForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + core.listConfigMapForAllNamespaces(false, '', '', labelSelector), ); } - private fetchDeploymentsByServiceId( - serviceId: string, + private fetchDeploymentsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ apps }) => - apps.listDeploymentForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + apps.listDeploymentForAllNamespaces(false, '', '', labelSelector), ); } - private fetchReplicaSetsByServiceId( - serviceId: string, + private fetchReplicaSetsForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch(clusterDetails, ({ apps }) => - apps.listReplicaSetForAllNamespaces( - false, - '', - '', - `backstage.io/kubernetes-id=${serviceId}`, - ), + apps.listReplicaSetForAllNamespaces(false, '', '', labelSelector), ); } - private fetchHorizontalPodAutoscalersByServiceId( - serviceId: string, + private fetchHorizontalPodAutoscalersForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch( clusterDetails, @@ -273,14 +252,14 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { false, '', '', - `backstage.io/kubernetes-id=${serviceId}`, + labelSelector, ), ); } - private fetchIngressesByServiceId( - serviceId: string, + private fetchIngressesForService( clusterDetails: ClusterDetails, + labelSelector: string, ): Promise> { return this.singleClusterFetch( clusterDetails, @@ -289,7 +268,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { false, '', '', - `backstage.io/kubernetes-id=${serviceId}`, + labelSelector, ), ); } diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 8c26ded2d4..adebebe1c5 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -14,18 +14,39 @@ * limitations under the License. */ -import { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler'; +import { handleGetKubernetesObjectsForService } from './getKubernetesObjectsForServiceHandler'; import { getVoidLogger } from '@backstage/backend-common'; -import { ClusterDetails } from '..'; +import { ComponentEntityV1alpha1 } from '@backstage/catalog-model'; +import { ObjectFetchParams } from '..'; const TEST_SERVICE_ID = 'my-service'; -const fetchObjectsByServiceId = jest.fn(); +const fetchObjectsForService = jest.fn(); const getClustersByServiceId = jest.fn(); +const goodEntity: ComponentEntityV1alpha1 = { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + kubernetes: { + selector: { + matchLabels: { + 'backstage.io/test-label': 'test-component', + }, + }, + }, + }, +}; + const mockFetch = (mock: jest.Mock) => { - mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) => + mock.mockImplementation((params: ObjectFetchParams) => Promise.resolve({ errors: [], responses: [ @@ -34,7 +55,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-pods-${serviceId}-${clusterDetails.name}`, + name: `my-pods-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -44,7 +65,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-configmaps-${serviceId}-${clusterDetails.name}`, + name: `my-configmaps-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -54,7 +75,7 @@ const mockFetch = (mock: jest.Mock) => { resources: [ { metadata: { - name: `my-services-${serviceId}-${clusterDetails.name}`, + name: `my-services-${params.serviceId}-${params.clusterDetails.name}`, }, }, ], @@ -64,7 +85,7 @@ const mockFetch = (mock: jest.Mock) => { ); }; -describe('handleGetKubernetesObjectsByServiceId', () => { +describe('handleGetKubernetesObjectsForService', () => { beforeEach(() => { jest.resetAllMocks(); }); @@ -79,22 +100,22 @@ describe('handleGetKubernetesObjectsByServiceId', () => { ]), ); - mockFetch(fetchObjectsByServiceId); + mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsByServiceId( + const result = await handleGetKubernetesObjectsForService( TEST_SERVICE_ID, { - fetchObjectsByServiceId, + fetchObjectsForService: fetchObjectsForService, }, { getClustersByServiceId, }, getVoidLogger(), - {}, + { entity: goodEntity }, ); expect(getClustersByServiceId.mock.calls.length).toBe(1); - expect(fetchObjectsByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls.length).toBe(1); expect(result).toStrictEqual({ items: [ { @@ -153,18 +174,19 @@ describe('handleGetKubernetesObjectsByServiceId', () => { ]), ); - mockFetch(fetchObjectsByServiceId); + mockFetch(fetchObjectsForService); - const result = await handleGetKubernetesObjectsByServiceId( + const result = await handleGetKubernetesObjectsForService( TEST_SERVICE_ID, { - fetchObjectsByServiceId, + fetchObjectsForService: fetchObjectsForService, }, { getClustersByServiceId, }, getVoidLogger(), { + entity: goodEntity, auth: { google: 'google_token_123', }, @@ -172,7 +194,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { ); expect(getClustersByServiceId.mock.calls.length).toBe(1); - expect(fetchObjectsByServiceId.mock.calls.length).toBe(2); + expect(fetchObjectsForService.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/getKubernetesObjectsForServiceHandler.ts similarity index 66% rename from plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts rename to plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts index 6fc6092bd6..82220f5d6c 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts @@ -15,25 +15,27 @@ */ import { Logger } from 'winston'; +import { ComponentEntityV1alpha1 } from '@backstage/catalog-model'; import { - AuthRequestBody, + KubernetesRequestBody, ClusterDetails, KubernetesServiceLocator, KubernetesFetcher, KubernetesObjectTypes, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, + ObjectFetchParams, } from '../types/types'; import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; -export type GetKubernetesObjectsByServiceIdHandler = ( +export type GetKubernetesObjectsForServiceHandler = ( serviceId: string, fetcher: KubernetesFetcher, serviceLocator: KubernetesServiceLocator, logger: Logger, - requestBody: AuthRequestBody, - objectsToFetch?: Set, -) => Promise; + requestBody: KubernetesRequestBody, + objectTypesToFetch?: Set, +) => Promise; const DEFAULT_OBJECTS = new Set([ 'pods', @@ -45,14 +47,26 @@ const DEFAULT_OBJECTS = new Set([ 'ingresses', ]); +function parseLabelSelector(entity: ComponentEntityV1alpha1): string { + const matchLabels = entity?.spec?.kubernetes?.selector?.matchLabels; + if (matchLabels) { + // TODO: figure out how to convert the selector to the full query param from the yaml + // (as shown here https://github.com/kubernetes/apimachinery/blob/master/pkg/labels/selector.go) + return Object.keys(matchLabels) + .map(key => `${key}=${matchLabels[key.toString()]}`) + .join(','); + } + return ''; +} + // Fans out the request to all clusters that the service lives in, aggregates their responses together -export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( +export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServiceHandler = async ( serviceId, fetcher, serviceLocator, logger, requestBody, - objectsToFetch = DEFAULT_OBJECTS, + objectTypesToFetch = DEFAULT_OBJECTS, ) => { const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId( serviceId, @@ -78,14 +92,21 @@ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServic .join(', ')}]`, ); + const labelSelector = parseLabelSelector(requestBody.entity); + return Promise.all( - clusterDetailsDecoratedForAuth.map(cd => { + clusterDetailsDecoratedForAuth.map(clusterDetails => { return fetcher - .fetchObjectsByServiceId(serviceId, cd, objectsToFetch) + .fetchObjectsForService({ + serviceId, + clusterDetails, + objectTypesToFetch, + labelSelector, + } as ObjectFetchParams) .then(result => { return { cluster: { - name: cd.name, + name: clusterDetails.name, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 6f9fbbf70f..a407ce9693 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -21,18 +21,18 @@ import { makeRouter } from './router'; import { KubernetesServiceLocator, KubernetesFetcher, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, } from '..'; describe('router', () => { let app: express.Express; let kubernetesFetcher: jest.Mocked; let kubernetesServiceLocator: jest.Mocked; - let handleGetByServiceId: jest.Mock>; + let handleGetByServiceId: jest.Mock>; beforeAll(async () => { kubernetesFetcher = { - fetchObjectsByServiceId: jest.fn(), + fetchObjectsForService: jest.fn(), }; kubernetesServiceLocator = { diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index b73115e1e4..38a02ad970 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -22,11 +22,11 @@ import { MultiTenantServiceLocator } from '../service-locator/MultiTenantService import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesClientProvider } from './KubernetesClientProvider'; import { - GetKubernetesObjectsByServiceIdHandler, - handleGetKubernetesObjectsByServiceId, -} from './getKubernetesObjectsByServiceIdHandler'; + GetKubernetesObjectsForServiceHandler, + handleGetKubernetesObjectsForService, +} from './getKubernetesObjectsForServiceHandler'; import { - AuthRequestBody, + KubernetesRequestBody, KubernetesServiceLocator, KubernetesFetcher, ServiceLocatorMethod, @@ -64,16 +64,16 @@ export const makeRouter = ( logger: Logger, fetcher: KubernetesFetcher, serviceLocator: KubernetesServiceLocator, - handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler, + handleGetByEntity: GetKubernetesObjectsForServiceHandler, ): express.Router => { const router = Router(); router.use(express.json()); router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; - const requestBody: AuthRequestBody = req.body; + const requestBody: KubernetesRequestBody = req.body; try { - const response = await handleGetByServiceId( + const response = await handleGetByEntity( serviceId, fetcher, serviceLocator, @@ -119,6 +119,6 @@ export async function createRouter( logger, fetcher, serviceLocator, - handleGetKubernetesObjectsByServiceId, + handleGetKubernetesObjectsForService, ); } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 1422511c21..b2a5ef432c 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -23,6 +23,7 @@ import { V1ReplicaSet, V1Service, } from '@kubernetes/client-node'; +import { ComponentEntityV1alpha1 } from '@backstage/catalog-model'; export interface ClusterDetails { name: string; @@ -31,10 +32,11 @@ export interface ClusterDetails { serviceAccountToken?: string | undefined; } -export interface AuthRequestBody { +export interface KubernetesRequestBody { auth?: { google?: string; }; + entity: ComponentEntityV1alpha1; } export interface ClusterObjects { @@ -43,7 +45,7 @@ export interface ClusterObjects { errors: KubernetesFetchError[]; } -export interface ObjectsByServiceIdResponse { +export interface ObjectsByEntityResponse { items: ClusterObjects[]; } @@ -107,13 +109,18 @@ export interface IngressesFetchResponse { resources: Array; } +export interface ObjectFetchParams { + serviceId: string; + clusterDetails: ClusterDetails; + objectTypesToFetch: Set; + labelSelector: string; +} + // Fetches information from a kubernetes cluster using the cluster details object // to target a specific cluster export interface KubernetesFetcher { - fetchObjectsByServiceId( - serviceId: string, - clusterDetails: ClusterDetails, - objectTypesToFetch: Set, + fetchObjectsForService( + params: ObjectFetchParams, ): Promise; } diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index e5611e4953..652fc394a2 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-kubernetes +## 0.3.0 + +### Minor Changes + +- 1166fcc36: add kubernetes selector to component model + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-kubernetes-backend@0.2.0 + ## 0.2.1 ### Patch Changes diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md index 1ad478bd89..8f3542a46e 100644 --- a/plugins/kubernetes/README.md +++ b/plugins/kubernetes/README.md @@ -14,7 +14,28 @@ It is only meant for local development, and the setup for it can be found inside ## Surfacing your Kubernetes components as part of an entity -### Adding the entity annotation +There are two ways to surface your kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Full label selector + +#### Adding the entity label selector to the spec + +In order for Backstage to detect that an entity has kubernetes components the `kubernetes.selector` must have a valid selector. (Currently only matchLabels is supported) + +```yaml +spec: + kubernetes: + selector: + matchLabels: + someKey: someValue + other-key: other-value + app.kubernetes.io/name: dice-roller +``` + +### Common `backstage.io/kubernetes-id` label on objects + +#### Adding the entity annotation In order for Backstage to detect that an entity has Kubernetes components, the following annotation should be added to the entity. @@ -24,7 +45,7 @@ annotations: 'backstage.io/kubernetes-id': dice-roller ``` -### Labeling Kubernetes components +#### Labeling Kubernetes components In order for Kubernetes components to show up in the service catalog as a part of an entity, Kubernetes components must be labeled with the following label: diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 36954cc5b9..66929077f3 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.2.1", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -9,6 +9,7 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "configSchema": "schema.d.ts", "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -20,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.1", - "@backstage/plugin-kubernetes-backend": "^0.1.3", + "@backstage/core": "^0.3.2", + "@backstage/plugin-kubernetes-backend": "^0.2.0", "@backstage/theme": "^0.2.1", "@kubernetes/client-node": "^0.12.1", "@material-ui/core": "^4.11.0", @@ -35,7 +36,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", @@ -47,6 +48,7 @@ "msw": "^0.21.2" }, "files": [ - "dist" + "dist", + "schema.d.ts" ] } diff --git a/plugins/kubernetes/schema.d.ts b/plugins/kubernetes/schema.d.ts new file mode 100644 index 0000000000..f3f3c98f8c --- /dev/null +++ b/plugins/kubernetes/schema.d.ts @@ -0,0 +1,45 @@ +/* + * 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 interface Config { + kubernetes?: { + /** + * @visibility frontend + */ + serviceLocatorMethod: 'multiTenant'; + /** + * @visibility frontend + */ + clusterLocatorMethods: 'config'[]; + clusters: { + /** + * @visibility frontend + */ + url: string; + /** + * @visibility frontend + */ + name: string; + /** + * @visibility secret + */ + serviceAccountToken: string; + /** + * @visibility frontend + */ + authProvider: 'serviceAccount'; + }[]; + }; +} diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 86bb830046..b81a799d6b 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -17,8 +17,8 @@ import { DiscoveryApi } from '@backstage/core'; import { KubernetesApi } from './types'; import { - AuthRequestBody, - ObjectsByServiceIdResponse, + KubernetesRequestBody, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; export class KubernetesBackendClient implements KubernetesApi { @@ -30,7 +30,7 @@ export class KubernetesBackendClient implements KubernetesApi { private async getRequired( path: string, - requestBody: AuthRequestBody, + requestBody: KubernetesRequestBody, ): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; const response = await fetch(url, { @@ -50,10 +50,12 @@ export class KubernetesBackendClient implements KubernetesApi { return await response.json(); } - async getObjectsByServiceId( - serviceId: String, - requestBody: AuthRequestBody, - ): Promise { - return await this.getRequired(`/services/${serviceId}`, requestBody); + async getObjectsByEntity( + requestBody: KubernetesRequestBody, + ): Promise { + return await this.getRequired( + `/services/${requestBody.entity.metadata.name}`, + requestBody, + ); } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 8e44d58e6a..498e74a626 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -16,8 +16,8 @@ import { createApiRef } from '@backstage/core'; import { - AuthRequestBody, - ObjectsByServiceIdResponse, + KubernetesRequestBody, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; export const kubernetesApiRef = createApiRef({ @@ -27,8 +27,7 @@ export const kubernetesApiRef = createApiRef({ }); export interface KubernetesApi { - getObjectsByServiceId( - serviceId: String, - requestBody: AuthRequestBody, - ): Promise; + getObjectsByEntity( + requestBody: KubernetesRequestBody, + ): Promise; } diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index a3e82e79a4..14cda3b5f7 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -26,13 +26,13 @@ import { TabbedCard, useApi, } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; +import { ComponentEntityV1alpha1, Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { - AuthRequestBody, + KubernetesRequestBody, ClusterObjects, FetchResponse, - ObjectsByServiceIdResponse, + ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-backend'; import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; import { DeploymentTables } from '../DeploymentTables'; @@ -104,7 +104,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { const kubernetesApi = useApi(kubernetesApiRef); const [kubernetesObjects, setKubernetesObjects] = useState< - ObjectsByServiceIdResponse | undefined + ObjectsByEntityResponse | undefined >(undefined); const [error, setError] = useState(undefined); @@ -120,7 +120,9 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { useEffect(() => { (async () => { // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider - let requestBody: AuthRequestBody = {}; + let requestBody: KubernetesRequestBody = { + entity: entity as ComponentEntityV1alpha1, + }; for (const authProviderStr of authProviders) { // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( @@ -131,7 +133,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { // TODO: Add validation on contents/format of requestBody kubernetesApi - .getObjectsByServiceId(entity.metadata.name, requestBody) + .getObjectsByEntity(requestBody) .then(result => { setKubernetesObjects(result); }) diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 7de5c7c0a9..570806a1a4 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -16,7 +16,7 @@ import { OAuthApi } from '@backstage/core'; import { KubernetesAuthProvider } from './types'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { authProvider: OAuthApi; @@ -26,8 +26,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } async decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { const googleAuthToken: string = await this.authProvider.getAccessToken( 'https://www.googleapis.com/auth/cloud-platform', ); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index ac909d6695..9f64af9c2e 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -15,7 +15,7 @@ */ import { OAuthApi } from '@backstage/core'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; @@ -40,8 +40,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { async decorateRequestBodyForAuth( authProvider: string, - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { const kubernetesAuthProvider: | KubernetesAuthProvider | undefined = this.kubernetesAuthProviderMap.get(authProvider); diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts index 3ac5a9494b..5d33521ae4 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts @@ -15,13 +15,13 @@ */ import { KubernetesAuthProvider } from './types'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export class ServiceAccountKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise { + requestBody: KubernetesRequestBody, + ): Promise { // No-op, with service account for auth, cluster config/details should already have serviceAccountToken return requestBody; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index 24fee0f94c..5bd253d2c1 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -15,12 +15,12 @@ */ import { createApiRef } from '@backstage/core'; -import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-backend'; export interface KubernetesAuthProvider { decorateRequestBodyForAuth( - requestBody: AuthRequestBody, - ): Promise; + requestBody: KubernetesRequestBody, + ): Promise; } export const kubernetesAuthProvidersApiRef = createApiRef< @@ -33,6 +33,6 @@ export const kubernetesAuthProvidersApiRef = createApiRef< export interface KubernetesAuthProvidersApi { decorateRequestBodyForAuth( authProvider: string, - requestBody: AuthRequestBody, - ): Promise; + requestBody: KubernetesRequestBody, + ): Promise; } diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 666e7c4411..e35f3dece0 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-lighthouse +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.2 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 90230f5248..ad1264cfbb 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/core-api": "^0.2.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -38,7 +38,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 0e1f4e1352..1c2b951bc6 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -21,7 +21,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -31,7 +31,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 3e966ef0d0..c569046855 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-register-component +## 0.2.2 + +### Patch Changes + +- 2a71f4bab: Remove catalog link on validate popup +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.1 ### Patch Changes diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 06ae17cb0e..1564faae0e 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -36,7 +36,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index e4a6671f90..1ccfe16c41 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-rollbar +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.2 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index c107573ca8..1177a83eb6 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -37,7 +37,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index a388a29a00..ea7e5c85ee 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-scaffolder-backend +## 0.3.2 + +### Patch Changes + +- ef2831dde: Move constructing the catalog-info.yaml URL for scaffolded components to the publishers +- 5a1d8dca3: Fix React entity YAML filename to new standard +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + ## 0.3.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 73c07d158f..a095167a08 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.3.1", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@gitbeaker/core": "^25.2.0", "@gitbeaker/node": "^25.2.0", @@ -48,7 +48,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", 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}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml 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}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml 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}}/catalog-info.yaml similarity index 100% rename from plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml rename to plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts index da33149c24..0fcd8a4f15 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra.ts @@ -48,7 +48,7 @@ export class CreateReactAppTemplater implements TemplaterBase { }, }); - // Need to also make a component-info.yaml to store the data about the service. + // Need to also make a catalog-info.yaml to store the data about the service. const componentInfo = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -69,7 +69,7 @@ export class CreateReactAppTemplater implements TemplaterBase { ); await fs.promises.writeFile( - `${finalDir}/component-info.yaml`, + `${finalDir}/catalog-info.yaml`, yaml.stringify(componentInfo), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index e79f2cb066..645133a4ae 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -66,7 +66,7 @@ export const runCommand = async ({ args, logStream = new PassThrough(), }: RunCommandOptions) => { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const process = spawn(command, args); process.stdout.on('data', stream => { @@ -109,7 +109,7 @@ export const runDockerContainer = async ({ dockerClient, createOptions = {}, }: RunDockerContainerOptions) => { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { dockerClient.pull(imageName, {}, (err, stream) => { if (err) return reject(err); stream.pipe(logStream, { end: false }); @@ -120,6 +120,7 @@ export const runDockerContainer = async ({ }); const userOptions: UserOptions = {}; + // @ts-ignore if (process.getuid && process.getgid) { // Files that are created inside the Docker container will be owned by // root on the host system on non Mac systems, because of reasons. Mainly the fact that diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 9eac595997..c78e05c3e0 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-scaffolder +## 0.3.1 + +### Patch Changes + +- ef2831dde: Move constructing the catalog-info.yaml URL for scaffolded components to the publishers +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.3.0 ### Minor Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 439fc0ad9a..791221bc62 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,7 +41,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index d8b194eb41..01a6346d24 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -13,43 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Button } from '@backstage/core'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; import { + Button as Action, Dialog, DialogActions, DialogContent, DialogTitle, LinearProgress, } from '@material-ui/core'; -import React, { useEffect, useState } from 'react'; -import { generatePath } from 'react-router-dom'; + +import React, { useCallback, useState } from 'react'; import { Job } from '../../types'; import { JobStage } from '../JobStage/JobStage'; -import { useJobPolling } from './useJobPolling'; type Props = { - onComplete: (job: Job) => void; - jobId: string; - entity: TemplateEntityV1alpha1 | null; + job: Job | null; + toCatalogLink?: string; }; -export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => { - const job = useJobPolling(jobId); - const [dialogTitle, setDialogTitle] = useState('Creating component...'); +export const JobStatusModal = ({ job, toCatalogLink }: Props) => { + const [isOpen, setOpen] = useState(true); - useEffect(() => { - if (job?.status === 'COMPLETED') { - setDialogTitle('Successfully created component'); - onComplete(job); - } else if (job?.status === 'FAILED') - setDialogTitle('Failed to create component'); - }, [job, onComplete, setDialogTitle]); + const renderTitle = () => { + switch (job?.status) { + case 'COMPLETED': + return 'Successfully created component'; + case 'FAILED': + return 'Failed to create component'; + default: + return 'Create component'; + } + }; + + const onClose = useCallback(() => { + if (!job) { + return; + } + if (job.status === 'COMPLETED' || job.status === 'FAILED') { + setOpen(false); + } + }, [job]); return ( - - {dialogTitle} + + {renderTitle()} {!job ? ( @@ -66,16 +74,14 @@ export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => { )) )} - {entity && ( + {job?.status && toCatalogLink && ( - + + + )} + {job?.status === 'FAILED' && ( + + setOpen(false)}>Close )} diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index d8b803f893..0754b64d7f 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -23,18 +23,22 @@ import { Page, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import React, { useState, useCallback } from 'react'; -import { Navigate } from 'react-router'; +import { generatePath, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; -import { Job } from '../../types'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; +import { useJobPolling } from '../hooks/useJobPolling'; const useTemplate = ( templateName: string, @@ -81,10 +85,10 @@ export const TemplatePage = () => { const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); + const [catalogLink, setCatalogLink] = useState(); const { template, loading } = useTemplate(templateName, catalogApi); - const [formState, setFormState] = useState({}); - + const [modalOpen, setModalOpen] = useState(false); const handleFormReset = () => setFormState({}); const handleChange = useCallback( (e: IChangeEvent) => setFormState({ ...formState, ...e.formData }), @@ -92,35 +96,42 @@ export const TemplatePage = () => { ); const [jobId, setJobId] = useState(null); - - const handleCreate = async () => { - try { - const job = await scaffolderApi.scaffold(templateName, formState); - setJobId(job); - } catch (e) { - errorApi.post(e); - } - }; - - const [entity, setEntity] = React.useState( - null, - ); - - const handleCreateComplete = async (job: Job) => { + const job = useJobPolling(jobId, async job => { if (!job.metadata.catalogInfoUrl) { errorApi.post( - new Error( - `Failed to find catalog-info.yaml file in ${job.metadata.remoteUrl}.`, - ), + new Error(`No catalogInfoUrl returned from the scaffolder`), ); return; } - const { - entities: [createdEntity], - } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); + try { + const { + entities: [createdEntity], + } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); - setEntity((createdEntity as any) as TemplateEntityV1alpha1); + const resolvedPath = generatePath( + `/catalog/${entityRoute.path}`, + entityRouteParams(createdEntity), + ); + + setCatalogLink(resolvedPath); + } catch (ex) { + errorApi.post( + new Error( + `Something went wrong trying to add the new 'catalog-info.yaml' to the catalog`, + ), + ); + } + }); + + const handleCreate = async () => { + try { + const jobId = await scaffolderApi.scaffold(templateName, formState); + setJobId(jobId); + setModalOpen(true); + } catch (e) { + errorApi.post(e); + } }; if (!loading && !template) { @@ -150,13 +161,7 @@ export const TemplatePage = () => { /> {loading && } - {jobId && ( - - )} + {modalOpen && } {template && ( Promise, ms: number) => { - let shouldStop = false; - (async () => { - while (!shouldStop) { - await thunk(); - await new Promise(res => setTimeout(res, ms)); - } - })(); - - return () => { - shouldStop = true; - }; -}; export const useJobPolling = ( jobId: string | null, + onFinish?: (j: Job) => void, pollingInterval = DEFAULT_POLLING_INTERVAL, ) => { const scaffolderApi = useApi(scaffolderApiRef); - const [job, setJob] = useState(null); - - useEffect(() => { - if (!jobId) return () => {}; - - const stopPolling = poll(async () => { - const nextJobState = await scaffolderApi.getJob(jobId); - if ( - nextJobState.status === 'FAILED' || - nextJobState.status === 'COMPLETED' - ) { - stopPolling(); + const [currentJob, setCurrentJob] = useState(null); + const shouldBeRunningInterval = + jobId && + currentJob?.status !== 'COMPLETED' && + currentJob?.status !== 'FAILED'; + useInterval( + async () => { + if (jobId) { + const job = await scaffolderApi.getJob(jobId); + if (job?.status === 'COMPLETED' || job?.status === 'FAILED') { + onFinish?.(job); + } + setCurrentJob(job); } - setJob(nextJobState); - }, pollingInterval); - return () => { - stopPolling(); - }; - }, [jobId, setJob, scaffolderApi, pollingInterval]); + }, + shouldBeRunningInterval ? pollingInterval : null, + ); - return job; + return currentJob; }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index e07581ad28..45672c603d 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; export type Job = { id: string; metadata: { @@ -21,7 +23,7 @@ export type Job = { remoteUrl?: string; catalogInfoUrl?: string; }; - status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + status: JobStatus; stages: Stage[]; error?: Error; }; @@ -29,7 +31,7 @@ export type Job = { export type Stage = { name: string; log: string[]; - status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + status: JobStatus; startedAt: string; endedAt?: string; }; diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md new file mode 100644 index 0000000000..3545914e96 --- /dev/null +++ b/plugins/search/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-search + +## 0.2.1 + +### Patch Changes + +- 475fc0aaa: Using the search field in the sidebar now navigates to the search result page. +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 diff --git a/plugins/search/package.json b/plugins/search/package.json index 0b1941d9e0..021e3f71f3 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.2.0", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/core": "^0.3.2", + "@backstage/plugin-catalog": "^0.2.3", + "@backstage/catalog-model": "^0.3.0", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +34,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index f3a050b2a2..e74741e27b 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -15,13 +15,15 @@ */ import { CatalogApi } from '@backstage/plugin-catalog'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; export type Result = { name: string; - description: string; - owner: string; + description: string | undefined; + owner: string | undefined; kind: string; - lifecycle: string; + lifecycle: string | undefined; + url: string; }; export type SearchResults = Array; @@ -35,12 +37,19 @@ class SearchApi { private async entities() { const entities = await this.catalogApi.getEntities(); - return entities.items.map((result: any) => ({ - name: result.metadata.name, - description: result.metadata.description, - owner: result.spec.owner, - kind: result.kind, - lifecycle: result.spec.lifecycle, + return entities.items.map((entity: Entity) => ({ + name: entity.metadata.name, + description: entity.metadata.description, + owner: + typeof entity.spec?.owner === 'string' ? entity.spec?.owner : undefined, + kind: entity.kind, + lifecycle: + typeof entity.spec?.lifecycle === 'string' + ? entity.spec?.lifecycle + : undefined, + url: `/catalog/${ + entity.metadata.namespace?.toLowerCase() || ENTITY_DEFAULT_NAMESPACE + }/${entity.kind.toLowerCase()}/${entity.metadata.name}`, })); } diff --git a/plugins/search/src/components/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx index 19d4e53d72..7bd344e12e 100644 --- a/plugins/search/src/components/Filters/Filters.tsx +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -49,8 +49,14 @@ export type FiltersState = { checked: Array; }; +export type FilterOptions = { + kind: Array; + lifecycle: Array; +}; + type FiltersProps = { filters: FiltersState; + filterOptions: FilterOptions; resetFilters: () => void; updateSelected: (filter: string) => void; updateChecked: (filter: string) => void; @@ -58,16 +64,13 @@ type FiltersProps = { export const Filters = ({ filters, + filterOptions, resetFilters, updateSelected, updateChecked, }: FiltersProps) => { const classes = useStyles(); - // TODO: move mocked filters out of filters component to make it more generic - const filter1 = ['All', 'API', 'Component', 'Location', 'Template']; - const filter2 = ['deprecated', 'recommended', 'experimental', 'production']; - return ( - - Kind - - - - Lifecycle - - {filter2.map(filter => ( - updateChecked(filter)} - > - + + Filters cannot be applied to available results + + + )} + {filterOptions.kind.length > 0 && ( + + Kind + + + )} + {filterOptions.lifecycle.length > 0 && ( + + Lifecycle + + {filterOptions.lifecycle.map(filter => ( + updateChecked(filter)} + > + + + + ))} + + + )} ); }; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index 1670c13963..9fd54ac06c 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -19,6 +19,7 @@ import { useAsync } from 'react-use'; import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import { + Link, EmptyState, Progress, Table, @@ -31,8 +32,9 @@ import { FiltersButton, Filters, FiltersState } from '../Filters'; import SearchApi, { Result, SearchResults } from '../../apis'; const useStyles = makeStyles(theme => ({ - searchTerm: { - background: '#eee', + searchQuery: { + color: theme.palette.text.primary, + background: theme.palette.background.default, borderRadius: '10%', }, tableHeader: { @@ -65,9 +67,12 @@ type Filters = { // TODO: move out column to make the search result component more generic const columns: TableColumn[] = [ { - title: 'Component Id', + title: 'Name', field: 'name', highlight: true, + render: (result: Partial) => ( + {result.name} + ), }, { title: 'Description', @@ -107,7 +112,7 @@ const TableHeader = ({ {`${numberOfResults} `} {numberOfResults > 1 ? `results for ` : `result for `} - "{searchQuery}"{' '} + "{searchQuery}"{' '} ) : ( {`${numberOfResults} results`} @@ -121,7 +126,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { const catalogApi = useApi(catalogApiRef); const [showFilters, toggleFilters] = useState(false); - const [filters, setFilters] = useState({ + const [selectedFilters, setSelectedFilters] = useState({ selected: 'All', checked: [], }); @@ -141,16 +146,18 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { // apply filters // filter on selected - if (filters.selected !== 'All') { + if (selectedFilters.selected !== 'All') { withFilters = results.filter((result: Result) => - filters.selected.includes(result.kind), + selectedFilters.selected.includes(result.kind), ); } // filter on checked - if (filters.checked.length > 0) { - withFilters = withFilters.filter((result: Result) => - filters.checked.includes(result.lifecycle), + if (selectedFilters.checked.length > 0) { + withFilters = withFilters.filter( + (result: Result) => + result.lifecycle && + selectedFilters.checked.includes(result.lifecycle), ); } @@ -168,7 +175,7 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { setFilteredResults(withFilters); } - }, [filters, searchQuery, results]); + }, [selectedFilters, searchQuery, results]); if (loading) { return ; } @@ -184,41 +191,58 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { } const resetFilters = () => { - setFilters({ + setSelectedFilters({ selected: 'All', checked: [], }); }; const updateSelected = (filter: string) => { - setFilters(prevState => ({ + setSelectedFilters(prevState => ({ ...prevState, selected: filter, })); }; const updateChecked = (filter: string) => { - if (filters.checked.includes(filter)) { - setFilters(prevState => ({ + if (selectedFilters.checked.includes(filter)) { + setSelectedFilters(prevState => ({ ...prevState, checked: prevState.checked.filter(item => item !== filter), })); return; } - setFilters(prevState => ({ + setSelectedFilters(prevState => ({ ...prevState, checked: [...prevState.checked, filter], })); }; + const filterOptions = results.reduce( + (acc, curr) => { + if (curr.kind && acc.kind.indexOf(curr.kind) < 0) { + acc.kind.push(curr.kind); + } + if (curr.lifecycle && acc.lifecycle.indexOf(curr.lifecycle) < 0) { + acc.lifecycle.push(curr.lifecycle); + } + return acc; + }, + { + kind: [] as Array, + lifecycle: [] as Array, + }, + ); + return ( <> {showFilters && ( { searchQuery={searchQuery} numberOfResults={filteredResults.length} numberOfSelectedFilters={ - (filters.selected !== 'All' ? 1 : 0) + filters.checked.length + (selectedFilters.selected !== 'All' ? 1 : 0) + + selectedFilters.checked.length } handleToggleFilters={() => toggleFilters(!showFilters)} /> diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 6a279e0743..7e72e83b02 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -31,5 +31,5 @@ export const SidebarSearch = () => { [navigate], ); - return ; + return ; }; diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 92327c1668..b388548d8e 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-sentry +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 80a37c574e..4c10936148 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -36,7 +36,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 0296bd0416..9ebd73b812 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-sonarqube +## 0.1.4 + +### Patch Changes + +- 26484d413: Add configuration schema +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + ## 0.1.3 ### Patch Changes diff --git a/plugins/sonarqube/config.d.ts b/plugins/sonarqube/config.d.ts new file mode 100644 index 0000000000..5facd9cef7 --- /dev/null +++ b/plugins/sonarqube/config.d.ts @@ -0,0 +1,26 @@ +/* + * 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 interface Config { + /** Optional configurations for the SonarQube plugin */ + sonarQube?: { + /** + * The base url of the sonarqube installation. Defaults to https://sonarcloud.io. + * @visibility frontend + */ + baseUrl: string; + }; +} diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index dcde8fab14..6a9d0fe0d4 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -35,7 +35,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", @@ -47,6 +47,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 7f5707764b..849ce3bc4d 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -21,7 +21,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/test-utils": "^0.1.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", @@ -35,7 +35,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 4a3fbf6821..d9054230e2 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-techdocs-backend +## 0.2.2 + +### Patch Changes + +- Updated dependencies [1166fcc36] +- Updated dependencies [bff3305aa] +- Updated dependencies [1185919f3] +- Updated dependencies [b47dce06f] + - @backstage/catalog-model@0.3.0 + - @backstage/backend-common@0.3.1 + ## 0.2.1 ### Patch Changes diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index b139bb9acf..1d9f3ea7c1 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.2.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.3.0", - "@backstage/catalog-model": "^0.2.0", + "@backstage/backend-common": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", "@backstage/config": "^0.1.1", "@types/dockerode": "^2.5.34", "@types/express": "^4.17.6", @@ -37,7 +37,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts index d68bb74aa6..d0ffa72f1c 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts @@ -63,7 +63,7 @@ export async function runDockerContainer({ ); } - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { dockerClient.pull(imageName, {}, (err, stream) => { if (err) return reject(err); stream.pipe(logStream, { end: false }); @@ -119,7 +119,7 @@ export const runCommand = async ({ options, logStream = new PassThrough(), }: RunCommandOptions) => { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const process = spawn(command, args, options); process.stdout.on('data', stream => { diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts index 07cef2f4d2..464c4b90a0 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts @@ -53,9 +53,7 @@ export class LocalPublish implements PublisherBase { ); if (!fs.existsSync(publishDir)) { - this.logger.info( - `[TechDocs]: Could not find ${publishDir}, creates the directory.`, - ); + this.logger.info(`Could not find ${publishDir}, creating the directory.`); fs.mkdirSync(publishDir, { recursive: true }); } @@ -63,7 +61,7 @@ export class LocalPublish implements PublisherBase { fs.copy(directory, publishDir, err => { if (err) { this.logger.debug( - `[TechDocs]: Failed to copy docs from ${directory} to ${publishDir}`, + `Failed to copy docs from ${directory} to ${publishDir}`, ); reject(err); } diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index bd3ae21ab8..3f46e1358b 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-techdocs +## 0.2.3 + +### Patch Changes + +- Updated dependencies [475fc0aaa] +- Updated dependencies [1166fcc36] +- Updated dependencies [1185919f3] + - @backstage/core@0.3.2 + - @backstage/catalog-model@0.3.0 + - @backstage/plugin-catalog@0.2.3 + ## 0.2.2 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 35b196e124..23d7a25b57 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.2.2", + "version": "0.2.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.2.0", - "@backstage/core": "^0.3.1", + "@backstage/catalog-model": "^0.3.0", + "@backstage/core": "^0.3.2", "@backstage/core-api": "^0.2.1", - "@backstage/plugin-catalog": "^0.2.2", + "@backstage/plugin-catalog": "^0.2.3", "@backstage/test-utils": "^0.1.3", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", @@ -39,7 +39,7 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 31e4a10e4c..7281ebdf13 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -41,6 +41,11 @@ export interface TechDocs { getMetadata(metadataType: string, entityId: ParsedEntityId): Promise; } +/** + * API to talk to techdocs-backend. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ export class TechDocsApi implements TechDocs { public apiOrigin: string; @@ -60,6 +65,11 @@ export class TechDocsApi implements TechDocs { } } +/** + * API which talks to TechDocs storage to fetch files to render. + * + * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API + */ export class TechDocsStorageApi implements TechDocsStorage { public apiOrigin: string; @@ -67,6 +77,14 @@ export class TechDocsStorageApi implements TechDocsStorage { this.apiOrigin = apiOrigin; } + /** + * Fetch HTML content as text for an individual docs page in an entity's docs site. + * + * @param {ParsedEntityId} entityId Object containing entity data like name, namespace, etc. + * @param {string} path The unique path to an individual docs page e.g. overview/what-is-new + * @returns {string} HTML content of the docs page as string + * @throws {Error} Throws error when the page is not found. + */ async getEntityDocs(entityId: ParsedEntityId, path: string) { const { kind, namespace, name } = entityId; @@ -77,7 +95,13 @@ export class TechDocsStorageApi implements TechDocsStorage { ); if (request.status === 404) { - throw new Error('Page not found'); + let errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new Error(errorMessage); } return request.text(); diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index ee2cab60fa..576ba0a292 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -57,6 +57,7 @@ export const rootCatalogDocsRouteRef = createRouteRef({ title: 'Docs', }); +// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl export const plugin = createPlugin({ id: 'techdocs', apis: [ diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 785c6f1c6b..fe3a34564c 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -153,7 +153,7 @@ export const Reader = ({ entityId, onReady }: Props) => { ]); if (error) { - return ; + return ; } return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx index 7b0ec80619..d3c98fec78 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx @@ -27,3 +27,17 @@ describe('', () => { expect(rendered.getByTestId('go-back-link')).toBeDefined(); }); }); + +describe('', () => { + it('should render with status code, custom error message and go back link', () => { + const rendered = render( + wrapInTestApp( + , + ), + ); + rendered.getByText(/This is a custom error message/i); + rendered.getByText(/404/i); + rendered.getByText(/Looks like someone dropped the mic!/i); + expect(rendered.getByTestId('go-back-link')).toBeDefined(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx index c774000bd5..744242e343 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -17,11 +17,15 @@ import React from 'react'; import { ErrorPage } from '@backstage/core'; -export const TechDocsNotFound = () => { +type Props = { + errorMessage?: string; +}; + +export const TechDocsNotFound = ({ errorMessage }: Props) => { return ( ); diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts index ec907cc6fa..341d3f0c92 100644 --- a/plugins/techdocs/src/types.ts +++ b/plugins/techdocs/src/types.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +/** + * To uniquely identify an entity in Backstage. + * @property {string} kind + * @property {string} namespace + * @property {string} name + */ export type ParsedEntityId = { kind: string; namespace?: string; diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 1277ae214a..80bcdeffdb 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -21,7 +21,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -32,7 +32,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 47b02f971c..5d81e569b8 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -21,7 +21,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.3.1", + "@backstage/core": "^0.3.2", "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -32,7 +32,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.3.0", + "@backstage/cli": "^0.3.1", "@backstage/dev-utils": "^0.1.4", "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/yarn.lock b/yarn.lock index c2058691b6..3a64cb0f25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,13 +1307,27 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" -"@backstage/plugin-catalog-backend@^0.2.1": - version "0.2.1" - resolved "https://registry.npmjs.org/@backstage/plugin-catalog-backend/-/plugin-catalog-backend-0.2.1.tgz#d63d1d145d97a5f1021c3e6c2ab680729be43e74" - integrity sha512-9OwM7JobjCvmNomsT2HbMyzgoWX+ztVGG4ebN3x/r75+sI55Dj3J29O9CRP7r86BIJ7bi1RWaLTycXX+CmuQ0Q== +"@backstage/catalog-model@^0.2.0": + version "0.2.0" + resolved "https://registry.npmjs.org/@backstage/catalog-model/-/catalog-model-0.2.0.tgz#e3fe2a4ddeb6a9b6ec480c80cb2b9c39cb245576" + integrity sha512-Y1ocdRpBlxK/VrJQjHlQd0bgADECd1B2NRjwd8ss46ibT5hwLvMOfD80+Fa7oPLu0ktJrH4lq0pNIIJIml48zA== dependencies: - "@backstage/backend-common" "^0.3.0" - "@backstage/catalog-model" "^0.2.0" + "@backstage/config" "^0.1.1" + "@types/json-schema" "^7.0.5" + "@types/yup" "^0.29.8" + json-schema "^0.2.5" + lodash "^4.17.15" + uuid "^8.0.0" + yup "^0.29.3" + +"@backstage/plugin-catalog-backend@^0.2.2": + version "0.2.2" + resolved "https://registry.npmjs.org/@backstage/plugin-catalog-backend/-/plugin-catalog-backend-0.2.2.tgz#4e17e0376a920f1425de5b0997d34134ff101a11" + integrity sha512-2pxcqQ3C+FGqYDwL7dkMm76zNM75IF1P8rb3iKrbv8qRRwPqtT0wUx+Gctv3RSL8WgGyCxP1Ho9dQ+FC7o2YSQ== + dependencies: + "@azure/msal-node" "^1.0.0-alpha.8" + "@backstage/backend-common" "^0.3.1" + "@backstage/catalog-model" "^0.3.0" "@backstage/config" "^0.1.1" "@octokit/graphql" "^4.5.6" "@types/express" "^4.17.6" @@ -1329,6 +1343,7 @@ lodash "^4.17.15" morgan "^1.10.0" p-limit "^3.0.2" + qs "^6.9.4" sqlite3 "^5.0.0" uuid "^8.0.0" winston "^3.2.1" @@ -5192,6 +5207,13 @@ "@types/qs" "*" "@types/range-parser" "*" +"@types/express-session@^1.17.2": + version "1.17.2" + resolved "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.2.tgz#ed6a36dd9f267c7fef86004f653bfb9b5cea3c21" + integrity sha512-QRm/fUuvr/BAosL9CvK351SDQP7wpD8+h3S8ZEE/8IvHJ/ZqHrjZbjx/flYfazyPw7yNi9O5fbjFZbh0vZ1ccg== + dependencies: + "@types/express" "*" + "@types/express@*", "@types/express@4.17.7", "@types/express@^4.17.6", "@types/express@^4.17.7": version "4.17.7" resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" @@ -5488,9 +5510,9 @@ "@types/node" "*" "@types/lodash@^4.14.151": - version "4.14.161" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18" - integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA== + version "4.14.165" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" + integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== "@types/long@^4.0.0": version "4.0.1" @@ -5568,6 +5590,13 @@ dependencies: "@types/node" "*" +"@types/nock@^11.1.0": + version "11.1.0" + resolved "https://registry.npmjs.org/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538" + integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw== + dependencies: + nock "*" + "@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" @@ -5620,6 +5649,13 @@ dependencies: "@types/node" "*" +"@types/openid-client@^3.7.0": + version "3.7.0" + resolved "https://registry.npmjs.org/@types/openid-client/-/openid-client-3.7.0.tgz#8406e12798d16083df09cc3625973f5a00dd57fa" + integrity sha512-hW+QbAeUyfUHABwUjUECOcv56Mf5tN29xK3GPN7wy3R3XfIQdkm37XELA4wbe2RNG9GYUpN8l2ytfoW05XmpgQ== + dependencies: + openid-client "*" + "@types/ora@^3.2.0": version "3.2.0" resolved "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz#b2f65d1283a8f36d8b0f9ee767e0732a2f429362" @@ -6142,6 +6178,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/yarnpkg__lockfile@^1.1.4": + version "1.1.4" + resolved "https://registry.npmjs.org/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.4.tgz#445251eb00bd9c1e751f82c7c6bf4f714edfd464" + integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw== + "@types/yup@^0.29.8": version "0.29.8" resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" @@ -6438,6 +6479,11 @@ resolved "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + "@zkochan/cmd-shim@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz#2ab8ed81f5bb5452a85f25758eb9b8681982fd2e" @@ -11176,9 +11222,9 @@ eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-cypress@^2.10.3: - version "2.11.1" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862" - integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ== + version "2.11.2" + resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.2.tgz#a8f3fe7ec840f55e4cea37671f93293e6c3e76a0" + integrity sha512-1SergF1sGbVhsf7MYfOLiBhdOg6wqyeV9pXUAIDIffYTGMN3dTBQS9nFAzhLsHhO+Bn0GaVM1Ecm71XUidQ7VA== dependencies: globals "^11.12.0" @@ -11618,6 +11664,20 @@ express-promise-router@^3.0.3: lodash.flattendeep "^4.0.0" methods "^1.0.0" +express-session@^1.17.1: + version "1.17.1" + resolved "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" + integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~2.0.0" + on-headers "~1.0.2" + parseurl "~1.3.3" + safe-buffer "5.2.0" + uid-safe "~2.1.5" + express@^4.0.0, express@^4.17.0, express@^4.17.1: version "4.17.1" resolved "https://registry.npmjs.org/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -12846,7 +12906,7 @@ got@^11.6.2: p-cancelable "^2.0.0" responselike "^2.0.0" -got@^11.7.0: +got@^11.7.0, got@^11.8.0: version "11.8.0" resolved "https://registry.npmjs.org/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f" integrity sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ== @@ -17165,6 +17225,16 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +nock@*, nock@^13.0.5: + version "13.0.5" + resolved "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414" + integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-addon-api@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" @@ -17794,6 +17864,20 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw== +openid-client@*, openid-client@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" + integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== + dependencies: + base64url "^3.0.1" + got "^11.8.0" + jose "^2.0.2" + lru-cache "^6.0.0" + make-error "^1.3.6" + object-hash "^2.0.1" + oidc-token-hash "^5.0.0" + p-any "^3.0.0" + openid-client@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a" @@ -19397,6 +19481,11 @@ prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, object-assign "^4.1.1" react-is "^16.8.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + property-expr@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" @@ -19627,6 +19716,11 @@ ramda@^0.26, ramda@~0.26.1: resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -21140,7 +21234,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== @@ -23411,6 +23505,13 @@ uid-number@0.0.6: resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= +uid-safe@~2.1.5: + version "2.1.5" + resolved "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + uid2@0.0.3, uid2@0.0.x: version "0.0.3" resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"