Merge remote-tracking branch 'upstream/master' into mcalus3/add-catalog-import-plugin
This commit is contained in:
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Clear sidebar search field once a search is executed
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added readTree support to AzureUrlReader
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
fix product icon configuration
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
truncate large percentages > 1000%
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Add support for reading groups and users from the Microsoft Graph API.
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@backstage/plugin-register-component': patch
|
||||
---
|
||||
|
||||
Remove catalog link on validate popup
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Make versions:bump install new versions of dependencies that were within the specified range
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -131,6 +131,7 @@ npm
|
||||
nvm
|
||||
oauth
|
||||
Oauth
|
||||
oidc
|
||||
Okta
|
||||
Oldsberg
|
||||
onboarding
|
||||
|
||||
@@ -96,6 +96,7 @@ typings/
|
||||
.nuxt
|
||||
dist
|
||||
dist-types
|
||||
dist-workspace
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
|
||||
+17
-1
@@ -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:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,7 +1,153 @@
|
||||
---
|
||||
id: architecture
|
||||
title: Architecture
|
||||
description: Documentation on Architecture
|
||||
title: TechDocs Architecture
|
||||
description: Documentation on TechDocs Architecture
|
||||
---
|
||||
|
||||

|
||||
## Basic (out-of-the-box)
|
||||
|
||||
When you deploy Backstage (with TechDocs enabled by default), you get a basic
|
||||
out-of-the box experience.
|
||||
|
||||

|
||||
|
||||
> 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"**/@roadiehq/backstage-plugin-*/@backstage/core": "*"
|
||||
"**/@roadiehq/**/@backstage/core": "*",
|
||||
"**/@roadiehq/**/@backstage/catalog-model": "*"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-20
@@ -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",
|
||||
|
||||
@@ -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 <JenkinsRouter entity={entity} />;
|
||||
case isBuildkiteAvailable(entity):
|
||||
return <BuildkiteRouter entity={entity} />;
|
||||
case isGitHubActionsAvailable(entity):
|
||||
return <GitHubActionsRouter entity={entity} />;
|
||||
case isCircleCIAvailable(entity):
|
||||
return <CircleCIRouter entity={entity} />;
|
||||
case isGitHubActionsAvailable(entity):
|
||||
return <GitHubActionsRouter entity={entity} />;
|
||||
case isCloudbuildAvailable(entity):
|
||||
return <CloudbuildRouter entity={entity} />;
|
||||
case isTravisCIAvailable(entity):
|
||||
@@ -134,7 +137,7 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const OverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} variant="gridItem" />
|
||||
@@ -169,7 +172,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout.Content
|
||||
path="/"
|
||||
title="Overview"
|
||||
element={<OverviewContent entity={entity} />}
|
||||
element={<ComponentOverviewContent entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/ci-cd/*"
|
||||
@@ -214,7 +217,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout.Content
|
||||
path="/"
|
||||
title="Overview"
|
||||
element={<OverviewContent entity={entity} />}
|
||||
element={<ComponentOverviewContent entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/ci-cd/*"
|
||||
@@ -253,12 +256,13 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<OverviewContent entity={entity} />}
|
||||
element={<ComponentOverviewContent entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/docs/*"
|
||||
@@ -268,8 +272,7 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
export const EntityPage = () => {
|
||||
const { entity } = useEntity();
|
||||
export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
|
||||
switch (entity?.spec?.type) {
|
||||
case 'service':
|
||||
return <ServiceEntityPage entity={entity} />;
|
||||
@@ -279,3 +282,47 @@ export const EntityPage = () => {
|
||||
return <DefaultEntityPage entity={entity} />;
|
||||
}
|
||||
};
|
||||
|
||||
const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<ApiDefinitionCard apiEntity={entity} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const ApiEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<ApiOverviewContent entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/definition/*"
|
||||
title="Definition"
|
||||
element={<ApiDefinitionContent entity={entity as ApiEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
export const EntityPage = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
switch (entity?.kind?.toLowerCase()) {
|
||||
case 'component':
|
||||
return <ComponentEntityPage entity={entity} />;
|
||||
case 'api':
|
||||
return <ApiEntityPage entity={entity} />;
|
||||
default:
|
||||
return <DefaultEntityPage entity={entity} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+37
-9
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string[] | false> | 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<string, string[] | false> | 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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string[] | false> | undefined {
|
||||
const cc = config.getOptionalConfig('csp');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: CspOptions = {};
|
||||
const result: Record<string, string[] | false> = {};
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -30,6 +30,15 @@ const schema = yup.object<Partial<ComponentEntityV1alpha1>>({
|
||||
lifecycle: yup.string().required().min(1),
|
||||
owner: yup.string().required().min(1),
|
||||
implementsApis: yup.array(yup.string().required()).notRequired(),
|
||||
kubernetes: yup
|
||||
.object<any>({
|
||||
selector: yup
|
||||
.object<any>({
|
||||
matchLabels: yup.object<any>().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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <type> 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
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, PkgVersionInfo[]>();
|
||||
// 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<T>(
|
||||
count: number,
|
||||
items: IterableIterator<T>,
|
||||
fn: (item: T) => Promise<void>,
|
||||
) {
|
||||
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()),
|
||||
);
|
||||
}
|
||||
@@ -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<T>(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();
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export async function serveBundle(options: ServeOptions) {
|
||||
proxy: pkg.proxy,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, url.hostname, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function waitForExit(
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('error', error => reject(error));
|
||||
child.once('exit', code => {
|
||||
if (code) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, LockfileQueryEntry[]>();
|
||||
|
||||
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<string, LockfileQueryEntry[]>,
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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<YarnInfoInspectData> {
|
||||
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<Map<string, PkgVersionInfo[]>> {
|
||||
const LernaProject = require('@lerna/project');
|
||||
const project = new LernaProject(targetDir);
|
||||
const packages = await project.getPackages();
|
||||
|
||||
const dependencyMap = new Map<string, PkgVersionInfo[]>();
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @backstage/core-api
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9b9e86f8a: export oidc provider
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<void>(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<void>(resolve => {
|
||||
const subscription = this.authRequest$().subscribe(requests => {
|
||||
subscription.unsubscribe();
|
||||
requests.map(request => request.reject());
|
||||
|
||||
@@ -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<void>(resolve => {
|
||||
storage.observe$<String>('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<void>(resolve => {
|
||||
storage.observe$('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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<any, SidebarItemProps>(
|
||||
|
||||
type SidebarSearchFieldProps = {
|
||||
onSearch: (input: string) => void;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export const SidebarSearchField: FC<SidebarSearchFieldProps> = 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<SidebarSearchFieldProps> = props => {
|
||||
setInput(ev.target.value);
|
||||
};
|
||||
|
||||
const handleInputClick = (ev: React.MouseEvent<HTMLInputElement>) => {
|
||||
// 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 (
|
||||
<div className={classes.searchRoot}>
|
||||
<SidebarItem icon={SearchIcon}>
|
||||
<SidebarItem icon={SearchIcon} to={props.to} onClick={handleItemClick}>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
value={input}
|
||||
onClick={handleInputClick}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleEnter}
|
||||
className={classes.searchContainer}
|
||||
|
||||
@@ -37,28 +37,28 @@
|
||||
"recursive-readdir": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "^0.3.0",
|
||||
"@backstage/catalog-model": "^0.2.0",
|
||||
"@backstage/cli": "^0.3.0",
|
||||
"@backstage/backend-common": "^0.3.1",
|
||||
"@backstage/catalog-model": "^0.3.0",
|
||||
"@backstage/cli": "^0.3.1",
|
||||
"@backstage/config": "^0.1.1",
|
||||
"@backstage/core": "^0.3.1",
|
||||
"@backstage/plugin-api-docs": "^0.2.1",
|
||||
"@backstage/core": "^0.3.2",
|
||||
"@backstage/plugin-api-docs": "^0.2.2",
|
||||
"@backstage/plugin-app-backend": "^0.3.0",
|
||||
"@backstage/plugin-auth-backend": "^0.2.2",
|
||||
"@backstage/plugin-catalog": "^0.2.2",
|
||||
"@backstage/plugin-catalog-backend": "^0.2.1",
|
||||
"@backstage/plugin-circleci": "^0.2.1",
|
||||
"@backstage/plugin-auth-backend": "^0.2.3",
|
||||
"@backstage/plugin-catalog": "^0.2.3",
|
||||
"@backstage/plugin-catalog-backend": "^0.2.2",
|
||||
"@backstage/plugin-circleci": "^0.2.2",
|
||||
"@backstage/plugin-explore": "^0.2.1",
|
||||
"@backstage/plugin-github-actions": "^0.2.1",
|
||||
"@backstage/plugin-lighthouse": "^0.2.2",
|
||||
"@backstage/plugin-github-actions": "^0.2.2",
|
||||
"@backstage/plugin-lighthouse": "^0.2.3",
|
||||
"@backstage/plugin-proxy-backend": "^0.2.1",
|
||||
"@backstage/plugin-register-component": "^0.2.1",
|
||||
"@backstage/plugin-register-component": "^0.2.2",
|
||||
"@backstage/plugin-rollbar-backend": "^0.1.3",
|
||||
"@backstage/plugin-scaffolder": "^0.3.0",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.3.1",
|
||||
"@backstage/plugin-scaffolder": "^0.3.1",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.3.2",
|
||||
"@backstage/plugin-tech-radar": "^0.3.0",
|
||||
"@backstage/plugin-techdocs": "^0.2.2",
|
||||
"@backstage/plugin-techdocs-backend": "^0.2.1",
|
||||
"@backstage/plugin-techdocs": "^0.2.3",
|
||||
"@backstage/plugin-techdocs-backend": "^0.2.2",
|
||||
"@backstage/plugin-user-settings": "^0.2.2",
|
||||
"@backstage/test-utils": "^0.1.3",
|
||||
"@backstage/theme": "^0.2.1",
|
||||
|
||||
@@ -93,7 +93,7 @@ export function exitWithError(err: Error & { code?: unknown }) {
|
||||
*/
|
||||
export function waitFor(fn: () => boolean, maxSeconds: number = 120) {
|
||||
let count = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<void>((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<void>((resolve, reject) =>
|
||||
child.once('exit', code => {
|
||||
if (code) {
|
||||
reject(new Error(`Child exited with code ${code}`));
|
||||
|
||||
@@ -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<void>(resolve => {
|
||||
storage.observe$<String>('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<void>(resolve => {
|
||||
storage.observe$('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[
|
||||
catalogApiRef,
|
||||
({
|
||||
async getEntityByName() {},
|
||||
} as Partial<CatalogApi>) as CatalogApi,
|
||||
],
|
||||
])}
|
||||
>
|
||||
<ApiEntityPage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useNavigate()).toHaveBeenCalledWith('/api-docs'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<Box display="inline-flex" alignItems="center" height="1em">
|
||||
{title}
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
<Header
|
||||
title={<EntityPageTitle title={headerTitle} entity={entity} />}
|
||||
pageTitleOverride={headerTitle}
|
||||
type={headerType}
|
||||
/>
|
||||
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<Content>
|
||||
<Alert severity="error">{error.toString()}</Alert>
|
||||
</Content>
|
||||
)}
|
||||
|
||||
{entity && (
|
||||
<>
|
||||
<Content>
|
||||
<ApiDefinitionCard apiEntity={entity as ApiEntity} />
|
||||
</Content>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -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<Entity>[] = [
|
||||
render: (entity: any) => (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(entityRoute.path, {
|
||||
optionalNamespaceAndName: [
|
||||
entity.metadata.namespace,
|
||||
entity.metadata.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':'),
|
||||
kind: entity.kind,
|
||||
selectedTabId: 'overview',
|
||||
})}
|
||||
to={generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
{entity.metadata.name}
|
||||
</Link>
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Router } from './catalog';
|
||||
export * from './catalog';
|
||||
export * from './components';
|
||||
export { plugin } from './plugin';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
|
||||
});
|
||||
}
|
||||
|
||||
private getUserPhoto(accessToken: string): Promise<string> {
|
||||
private getUserPhoto(accessToken: string): Promise<string | undefined> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApiEntityPage } from './ApiEntityPage';
|
||||
export { createOidcProvider } from './provider';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<UserinfoResponse, Client>;
|
||||
client: Client;
|
||||
};
|
||||
|
||||
export type OidcAuthProviderOptions = OAuthProviderOptions & {
|
||||
metadataUrl: string;
|
||||
tokenSignedResponseAlg?: string;
|
||||
};
|
||||
|
||||
export class OidcAuthProvider implements OAuthHandlers {
|
||||
private readonly implementation: Promise<OidcImpl>;
|
||||
|
||||
constructor(options: OidcAuthProviderOptions) {
|
||||
this.implementation = this.setupStrategy(options);
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
|
||||
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<OAuthResponse> {
|
||||
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<OidcImpl> {
|
||||
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<OAuthResponse, PrivateInfo>,
|
||||
) => {
|
||||
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<OAuthResponse> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Entity> {
|
||||
// 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<boolean> {
|
||||
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<Entity> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user