Merge remote-tracking branch 'upstream/master' into mcalus3/add-catalog-import-plugin

This commit is contained in:
Marek Calus
2020-11-26 08:10:02 +01:00
203 changed files with 5150 additions and 1081 deletions
+5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/core': patch
---
Clear sidebar search field once a search is executed
+7
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/backend-common': patch
---
Added readTree support to AzureUrlReader
-5
View File
@@ -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%
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-catalog-backend': patch
---
Add support for reading groups and users from the Microsoft Graph API.
-6
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-register-component': patch
---
Remove catalog link on validate popup
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Make versions:bump install new versions of dependencies that were within the specified range
+6
View File
@@ -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.
-6
View File
@@ -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.
+1
View File
@@ -131,6 +131,7 @@ npm
nvm
oauth
Oauth
oidc
Okta
Oldsberg
onboarding
+1
View File
@@ -96,6 +96,7 @@ typings/
.nuxt
dist
dist-types
dist-workspace
# Gatsby files
.cache/
+17 -1
View File
@@ -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.
+149 -3
View File
@@ -1,7 +1,153 @@
---
id: architecture
title: Architecture
description: Documentation on Architecture
title: TechDocs Architecture
description: Documentation on TechDocs Architecture
---
![TechDocs Big Picture](../../assets/techdocs/techdocs_big_picture.png)
## Basic (out-of-the-box)
When you deploy Backstage (with TechDocs enabled by default), you get a basic
out-of-the box experience.
![TechDocs Architecture diagram](../../assets/techdocs/architecture-basic.drawio.svg)
> Note: See below for our recommended deployment architecture which takes care
> of stability, scalability and speed.
When you open a TechDocs site in Backstage, the
[TechDocs Reader](./concepts.md#techdocs-reader) makes a request to
`techdocs-backend` with the entity ID and the path of the current page you are
looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to
render on the page in TechDocs/Backstage.
The static files consist of HTML, CSS and Images generated by MkDocs. We remove
all the Javascript before adding them to Backstage for security reasons. And
there are some additional techdocs metadata JSON files that TechDocs needs to
render a site.
The TechDocs Reader then applies a list of "Transformers" (see
[Concepts](./concepts.md)) which modify the generated static HTML files for a
number of use cases e.g. Remove certain headers, filter out some HTML tags, etc.
Currently, we use the Backstage server's (or techdocs-backend's) local file
system to store the generated files. Publishing to an external storage system
(AWS S3, GCS, etc.) is also possible, but has not been implemented yet.
A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch
files from its source repository for docs site generation. 1. By using Git
and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is
heavily in progress. Please reach out to us on Discord in the #docs-like-code
channel to talk about it.
## Recommended deployment
This is how we recommend deploying TechDocs in production environment.
![TechDocs Architecture diagram](../../assets/techdocs/architecture-recommended.drawio.svg)
The key difference in the recommended deployment approach is where the docs are
built.
We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We
recommend using a CI/CD pipeline with the repository that has a dedicated
step/job to build docs for TechDocs. The generated static files are then stored
in a cloud storage solution of your choice.
[Track progress here](https://github.com/backstage/backstage/issues/3096).
Similar to how it is done in the Basic setup, the TechDocs Reader requests
`techdocs-backend` plugin for the docs site. `techdocs-backend` then requests
your configured storage solution for the necessary files and returns them to
TechDocs Reader.
We will provide instructions, scripts and/or templates (e.g. GitHub actions) to
build docs in your CI/CD system.
[Track progress here.](https://github.com/backstage/backstage/issues/3400) You
will be able to use `techdocs-cli` to build docs and publish the generated docs
site files to your cloud storage system.
Note about caching: We have noticed internally that some storage providers can
be quite slow, which is why we are recommending a cache that sits between the
TechDocs Reader and the Storage.
_Feel free to suggest better ideas to us in #docs-like-code channel in Discord
or via a GitHub issue._
### Security consideration
Our biggest security concern is managing the access to the docs in the cloud
storage. We also want to have only one security solution for all different types
of storage (GCS, AWS, custom SFTP server, etc.) Restricting access to the
storage and only allowing `techdocs-backend` to fetch files is a good way to
achieve this.
This would also allow us to use the access control management Backstage when
that is ready.
[Track progress here.](https://github.com/backstage/backstage/issues/3218)
In theory, you can directly enable TechDocs Reader to read from your storage.
But, you will have to think about how to do it without the docs being public and
how access to user groups is managed.
For cloud storage access tokens, `techdocs-backend` only needs a token with Read
permissions. But in your CI/CD system, there needs to be a token with Write
permissions to publish the generated docs site files.
## FAQs
**Q: Why do you have separate "basic" and "recommended" deployment approaches?**
A: The basic or out-of-the-box setup is what you get when you create a new app
or do a git clone of the Backstage repository. We want the first experience to
_just work magically_ so that you can have your first experience with TechDocs
which is smooth. However, if you decide to deploy Backstage/TechDocs for
production use, the basic setup would work but there are going to be downsides
as you scale with the number of documentation sites and sizes of them. So you
would want to make sure the deployment is as stable as possible. Hence there is
a recommended approach. There can be even more deployment approaches to TechDocs
and we welcome such "Alternative" ideas from the community.
**Q: Why don't you recommend techdocs-backend local filesystem to serve static
files?**
A: It would make scaling a Backstage instance harder. Think about the case where
we have distributed Backstage deployments. Using a separate file storage system
for TechDocs makes it easier to do some operations like delete a docs site and
wipe its contents.
**Q: Why aren't docs built on the fly i.e. when users visits a page, generate
docs site in real-time?**
A: Generating the content from Markdown on the fly is not optimal (although that
is how the basic out-of-the-box setup is implemented). Storage solutions act as
a cache for the generated static content. TechDocs is also currently built on
MkDocs which does not allow us to build docs per-page, so we would have to build
all docs for a entity on every request.
# Future work
_Ideas here are far fetched and not in the project's milestone for near future
(~6 months)._
We currently depend on MkDocs to parse doc sites written in Markdown. And we
store the generated static assets and re-use it later to render in Backstage. A
better (futuristic) approach will be to directly parse whatever type of source
files you have in your docs repository and directly render in Backstage in
real-time.
# Features status
Status of all the features mentioned above.
**In place ✅**
- Basic setup with techdocs-backend file server as storage.
**Work in progress 🚧**
- Basic setup with cloud storage solution.
**Not implemented yet ❌**
- `techdocs-cli` is able to generate docs in CI/CD environment.
- `techdocs-cli` is able to publish docs site to any storage.
- `techdocs-backend` integration with Backstage access control management.
+1 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -37,7 +37,8 @@
]
},
"resolutions": {
"**/@roadiehq/backstage-plugin-*/@backstage/core": "*"
"**/@roadiehq/**/@backstage/core": "*",
"**/@roadiehq/**/@backstage/catalog-model": "*"
},
"version": "1.0.0",
"devDependencies": {
+37
View File
@@ -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
View File
@@ -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} />;
}
};
+7
View File
@@ -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',
+7
View File
@@ -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
+37 -9
View File
@@ -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.
+3 -3
View File
@@ -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;
+21
View File
@@ -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
+9 -9
View File
@@ -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",
+8
View File
@@ -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
+3 -3
View File
@@ -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"
},
+18
View File
@@ -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
+2 -2
View File
@@ -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",
+8 -8
View File
@@ -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.
+9
View File
@@ -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
+1
View File
@@ -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',
{
+6 -3
View File
@@ -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',
});
};
+16
View File
@@ -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
},
});
});
});
+183
View File
@@ -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()),
);
}
+117
View File
@@ -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();
}
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
});
});
+266
View File
@@ -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);
}
}
+18
View File
@@ -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;
}
+6
View File
@@ -1,5 +1,11 @@
# @backstage/core-api
## 0.2.2
### Patch Changes
- 9b9e86f8a: export oidc provider
## 0.2.1
### Patch Changes
+1 -1
View File
@@ -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);
+6
View File
@@ -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
+2 -2
View File
@@ -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,
},
}),
}),
];
+29 -12
View File
@@ -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}
+16 -16
View File
@@ -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",
+2 -2
View File
@@ -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);
+11
View File
@@ -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
+6 -6
View File
@@ -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>
+2 -1
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { Router } from './catalog';
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
+1 -3
View File
@@ -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);
},
});
-6
View File
@@ -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: '',
+11 -1
View File
@@ -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,
+12
View File
@@ -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
+34 -7
View File
@@ -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);
});
});
}
@@ -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,
});
});
+12 -1
View File
@@ -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());
+22
View File
@@ -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']);
});
}
};
+4 -4
View File
@@ -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