Merge branch 'master' of github.com:spotify/backstage into blam/msw

* 'master' of github.com:spotify/backstage: (63 commits)
  packages/cli: add lint rule to avoid cross-package src imports
  fix(catalog-backend): make location delete work again
  Add Other component category (#1292)
  packages/core-api: add initial definition of IdentityApi
  additional sqli protection
  build(deps): bump eslint from 7.1.0 to 7.2.0 (#1285)
  return 204 when there's no response data
  build(deps-dev): bump @types/codemirror from 0.0.95 to 0.0.96 (#1284)
  build(deps-dev): bump @types/morgan from 1.9.0 to 1.9.1 (#1287)
  build(deps): bump chalk from 4.0.0 to 4.1.0 (#1286)
  catalog: suffix entiy kind typeswith Entity
  feat(catalog): new kind LocationRef
  packages/cli: assume browser build if esm output is included
  packages/backend-common: import winston as namespace
  plugins/catalog-backend: typecheck migration files
  plugins/catalog-backend: move migrations to JS with type annotations
  packages/cli: fix jest module mapper to only match entire module name
  github/workflows: use env app config to set e2e test port
  packages/cli: use app.baseUrl to configure dev server
  packages/config-loader: more docs!
  ...
This commit is contained in:
blam
2020-06-15 14:53:24 +02:00
131 changed files with 2380 additions and 977 deletions
+1 -1
View File
@@ -61,5 +61,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
PORT: 3001
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
+1 -1
View File
@@ -64,5 +64,5 @@ jobs:
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
PORT: 3001
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
+1 -1
View File
@@ -68,4 +68,4 @@ This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By
# Security Issues?
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
See [SECURITY](SECURITY.md).
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.x | :white_check_mark: |
## Reporting a Vulnerability
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
+1 -1
View File
@@ -22,7 +22,7 @@ function inject_config() {
>&2 echo "Runtime app config: $config"
local main_js
main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)"
main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/*.chunk.js)"
echo "Writing runtime config to ${main_js}"
# escape ' and " twice, for both sed and json
+4 -4
View File
@@ -1,10 +1,10 @@
# Documentation
Check out <https://backstage.io> or see the table of content below.
Check out <https://backstage.io> or see the table of contents below.
- [Architecture and terminology](architecture-terminology.md)
- [Getting started](getting-started/README.md)
- [Architecture and Terminology](architecture-terminology.md)
- [Getting Started](getting-started/README.md)
- [References](reference/README.md)
- [Publishing](publishing.md)
- [Designing for Backstage](design.md)
- [How to add an auth provider](auth/add-auth-provider.md)
- [How to Add an Auth Provider](auth/add-auth-provider.md)
+57 -2
View File
@@ -1,5 +1,60 @@
# Getting started with Backstage
## Running Backstage Locally
To get up and running with a local Backstage to evaluate it, let's clone it off
of GitHub and run an initial build. First make sure that you have at least node
version 12 installed locally.
```bash
# Start from your local development folder
git clone git@github.com:spotify/backstage.git
cd backstage
# Fetch our dependencies and run an initial build
yarn install
yarn tsc
yarn build
```
Phew! Now you have a local repository that's ready to run and to add any open
source contributions into.
We are now going to launch two things: an example Backstage frontend app, and an
example Backstage backend that the frontend talks to. You are going to need two
terminal windows, both starting from the Backstage project root.
In the first window, run
```bash
cd packages/backend
yarn start
```
That starts up a backend instance on port 7000.
In the other window, we will first populate the catalog with some nice mock data
to look at, and then launch the frontend. These commands are run from the
project root, not inside the backend directory.
```bash
yarn lerna run mock-catalog-data
yarn start
```
That starts up the frontend on port 3000, and should automatically open a
browser window showing it.
Congratulations! That should be it. Let us know how it went
[on discord](https://discord.gg/EBHEGzX), file issues for any
[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md)
or
[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME),
or
[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md)
you have, and feel free to
[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)!
## Creating a Plugin
The value of Backstage grows with every new plugin that gets added. Here is a
@@ -7,8 +62,8 @@ collection of tutorials that will guide you through setting up and extending an
instance of Backstage with your own plugins.
- [Development Environment](development-environment.md)
- [Create a Backstage plugin](create-a-plugin.md)
- [Structure of a plugin](structure-of-a-plugin.md)
- [Create a Backstage Plugin](create-a-plugin.md)
- [Structure of a Plugin](structure-of-a-plugin.md)
- [Utility APIs](utility-apis.md)
- Using Backstage components (TODO)
+2 -2
View File
@@ -1,8 +1,8 @@
# Backstage Plugins
# Create a Backstage Plugin
A Backstage Plugin adds functionality to Backstage.
## Create a plugin
## Create a Plugin
To create a new plugin, make sure you've run `yarn install` and installed
dependencies, then run the following on your command line (invoking the
@@ -1,4 +1,4 @@
# Structure of a newly created plugin
# Structure of a Plugin
Nice, you have a new plugin! We'll soon see how we can develop it into doing
great things. But first off, let's look at what we get out of the box.
+273
View File
@@ -0,0 +1,273 @@
# Purpose
This RFC describes a possible journey of a future Backstage plugin developer as
they build a plugin that touches many different aspects of a Backstage. The
story invents many new things that are not part of Backstage today, but are
things that I'm suggesting we should add as long term or north star goals. The
idea is to discuss what parts of the story makes sense to aim for, and what we'd
want to do differently or not at all. The "chapters" are numbered to make it a
bit easier to comment on parts of the story.
# The Protagonist
Sam is an experienced developer that has worked with Backstage for a while, and
knows the best practices and tools available to build plugins. Sam also likes
music and wants to have a theme tune for every service in Backstage.
# The End
Sam built a Spotify plugin for Backstage that allows service owners to define a
theme tune for their service. The theme tune plays whenever a user visits the
service page in Backstage. The plugin is published to NPM and available for any
organization to easily install and add to their Backstage installation.
# 1. A New Plugin
Sam chooses to develop this plugin in a standalone project and creates a new
plugin using `npx @backstage/cli create-plugin`, which detects that it's not
being run in an existing project and therefore creates a separate plugin repo.
Spinning up the frontend with `yarn start`, Sam goes to work with getting the
base functionality of a Spotify web player going. By installing a couple of
dependencies and whipping together a nice UI, the player is pretty much done.
# 2. The Auth Menace
Sam realizes users need to be authenticated towards the Spotify API to be able
to play music, and Backstage doesn't support Spotify login yet. Sam adds the
`@backstage/plugin-auth-backend` as local development middleware in the project,
and provides the necessary wrapping logic and configuration for the
`passport-spotify` strategy. The Spotify auth provider is now available in the
local development backend, and by adding a frontend `SpotifyAuth` Utility API
that implements the `OAuthApi` type, it's now working in the frontend too.
```ts
const spotifyAuthApiRef = createApiRef<OAuthApi>({
id: 'core.auth.spotify',
description: 'Provides authentication towards Spotify APIs',
});
```
Sam realizes that Spotify auth might be useful to others, and that it would be
more convenient if it was a part of the Backstage Core. After submitting and
merging a Pull Request with the additions to the
`@backstage/plugin-auth-backend` and `@backstage/core` packages, Spotify auth is
now available for everyone to use. Since the Backstage Core team also adds it to
the public demo server, Sam can now get rid of it in the local setup and rely on
the shared development auth providers instead.
The only thing left now is making sure that users of the plugin provide Spotify
auth in the app. Sam ensures this by adding `spotifyAuthApiRef` to the plugin's
list of required APIs, as well as listing it in the requirements section in the
README.
```md
## Requirements
This plugin requires the following APIs to function:
- `spotifyAuthApiRef` from `@backstage/core@^1.1.0`
```
# 3. The Catalog Awakens
Sam now has a working player and a method for users to log in to listen to
music, but the goal is to provide theme songs for services. Sam adds this
functionality by defining a new metadata annotation called
`sam.wise/spotify-track-id`. The annotation's value is a Spotify track ID and
can be defined in a component like this:
```yaml
apiVersion: backstage.io/v1
kind: Component
metadata:
name: my-component
annotations:
sam.wise/spotify-track-id: '4uLU6hMCjMI75M1A2tKUQC'
spec:
type: service
```
Sam creates a JSON schema that documents the annotation and allows it to be used
in validation and documentation for organizations that choose to adopt the
plugin.
```json
{
"sam.wise/spotify-track-id": {
"$id": "https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id",
"type": "string",
"title": "Spotify Track Annotation",
"description": "Spotify track ID to associated with the entity",
"examples": ["4uLU6hMCjMI75M1A2tKUQC"]
}
}
```
# 4. The Rise of Widgets
Sam also wraps the music player in an entity page widget. This allows anyone
that wants to use the plugin to add the player to any of their entity layout
templates, which will make it show up for every entity of that kind.
```tsx
export const PlayerWidget = plugin.createEntityWidget({
component: WebPlayer,
locations: ['header', 'card', 'footer'],
cardSize: [2, 4],
});
```
The widget receives information about the entity in whose page it's being
embedded, which makes it simple to grab the track id from the annotations and
hook up the player.
Sam also modifies the standalone plugin development setup to include this new
widget inside a basic entity page, adding it to a couple of different places
where users of the plugin may want to put the player, just to make sure they all
work.
# 5. The First User
At this point the only things that anyone that wants to use Sam's plugin needs
to do is to add
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
to their catalog schema, import and add the `PlayerWidget` on the desired entity
template pages, and make sure they're providing Spotify auth.
Sam soon sees the first "Used by" show up on GitHub, and feedback starts rolling
in. Users really like the plugin, and some a requesting the possibility to
select a theme tune when creating a new component. Sam jumps on the idea and
adds a new creation hook that is exported by the plugin. The hook can be
installed either in a single, all, or component templates that match a label. It
adds a field as a part of the component creation process with a nice search box
that allow users to search for a track that they want to use as the theme tune.
# 6. Return to the Repo
Sam is pretty content at this point, but would like to make it easier for users
to change the track after creation, preferable using the same search box that
was made for the creation form. By adding an edit button to the `PlayerWidget`,
and a nice empty state, Sam is able to provide the appropriate hooks in the GUI
to open up a search dialog.
To save the selected track, Sam uses the `RepoApi` to suggest a change to the
entity definition file. This will create a Pull Request for organizations that
use GitHub, a Merge Request for users of GitLab, and so on.
```ts
const repoApi = useApi(repoApiRef);
const alertApi = useApi(alertApiRef);
const onSave = async () => {
const { url } = await repoApi.createChangeRequest({
title: `Change theme tune to ${track.title} by ${track.artist}`,
changes: [
{
path: entityYamlPath,
content: newEntityYamlContent,
},
],
});
alertApi.post({ message: `Requested change, ${url}` });
};
```
Now it's much simpler for users to change the theme tune, as they no longer need
to go look up a track ID and edit a yaml file. Instead, they can now stay inside
Backstage and search for the track and request the change from there. In
addition, the requested change can be reviewed by the regular process of each
organization.
# 7. The User Strikes Back
Sam's plugin is pretty popular at this point, and has been picked up and used by
many organizations. But some users start voicing concerns that they have too
many different hand-crafted annotations in their entity descriptions, and would
like to be able to avoid some of them. They really like the theme tunes though,
and wish they could keep them without having to put them in the entity
description, even if that means it won't go through the regular source control
review process.
One day Sam receives a Pull Request for the plugin. It adds an option to use the
Database provided by `@backstage/backend-common` to store the track ID. It's all
packaged into a new backend plugin that will also extend the catalog backend
with schema and functionality to automatically load the value of the
`sam.wise/spotify-track-id` annotation from the backend plugin and database. The
backend plugin also extends the common GraphQL schema with a mutation that
updates the track ID in the database.
On the frontend the Pull Request doesn't change much. It defines the save action
the was previously using the `RepoApi` in it's own API.
```ts
type ThemeTuneStorageApi = {
save(entity: Entity, trackId: string): Promise<void>;
};
```
The plugin also provides two different implementations of the API, one that uses
the old behavior of the `RepoApi`, and a new one that calls the `GraphQL` API.
The new API relies on the `IdentityApi` as a mechanism for authorizing changes,
instead of source control reviews. The `IdentityApi` provides a token that is
included in the request to the backend, which then must match the owner of the
component for which the user is trying to change the theme tune.
> Author breaking the 4th wall here. I actually think every GraphQL request
> should include the ID token of the user, but invented a reason to include it
> here anyway.
The API is selected based on a configuration parameter for the plugin, but
defaults to the original `RepoApi` behavior.
```ts
if (config.getBoolean('storeTrackInDatabase')) {
return new GraphQLThemeTuneStore(graphqlClient, identityApi, alertApi);
} else {
return new RepoThemeTuneStore(repoApi, alertApi);
}
```
Sam is amazed by the pure awesomeness of this change, replies with a "👍" and
hits merge.
# 8. Attack of the Clones
Sam just released v1.8.4 of the plugin, and at this point it's so popular that a
couple of other plugins has started depending on the `sam.wise/spotify-track-id`
annotation. One such plugin being the `spotify-album-art` plugin that can
display the album art of the theme tune as the background of the entity header.
Sam thinks it's all pretty cool, but doesn't like that the annotation that was
once an internal concern of the plugin is now becoming a standard in the
community.
In order to standardize the annotation in Backstage, Sam submits a Pull Request
to the Backstage Core repo. The request suggest a new well-known metadata
annotation called `spotify.com/track-id`, with the same schema definition as
Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin
as existing usages. The Backstage maintainers merge the Pull Request, after
checking with the folks over at Spotify that they're cool with the annotation,
and faffing about over some minor grammar mistake in the annotation description.
With the annotation now available inside Backstage Core, Sam releases v2 of the
plugin, which uses the new annotation. It can still consume the old annotation
for backwards compatibility, but new users of the plugin no longer need to add
the
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
extension to their catalog schema, as it's now part of the core schema. The new
release of Sam's plugin specifies a dependency on Backstage with a minimum
version set to the same release as the one were the annotation was added to the
core schema.
<details>
<summary></summary>
# 9. Revenge of the Sam
Sam, now in full control of all theme tunes in Backstage, releases v2.0.1, which
switches all tracks to 4uLU6hMCjMI75M1A2tKUQC. Sam wanted to do something more
nefarious, but since Backstage sandboxes sensitive actions and is mostly
read-only with strict CSP, Sam's hands were tied.
</details>
+1 -1
View File
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1-alpha.7"
"version": "0.1.1-alpha.8"
}
+16 -17
View File
@@ -1,29 +1,29 @@
{
"name": "example-app",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
"@backstage/plugin-explore": "^0.1.1-alpha.7",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7",
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.7",
"@backstage/plugin-welcome": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
"@backstage/plugin-circleci": "^0.1.1-alpha.8",
"@backstage/plugin-explore": "^0.1.1-alpha.8",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8",
"@backstage/plugin-home-page": "^0.1.1-alpha.8",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.8",
"@backstage/plugin-register-component": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.8",
"@backstage/plugin-welcome": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -35,7 +35,6 @@
"@types/jest": "^25.2.2",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": false,
@@ -35,7 +35,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import winston from 'winston';
import * as winston from 'winston';
import { getRootLogger, setRootLogger } from './rootLogger';
describe('rootLogger', () => {
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import winston, { Logger } from 'winston';
import * as winston from 'winston';
let rootLogger: Logger = winston.createLogger({
let rootLogger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format:
process.env.NODE_ENV === 'production'
@@ -35,10 +35,10 @@ let rootLogger: Logger = winston.createLogger({
],
});
export function getRootLogger(): Logger {
export function getRootLogger(): winston.Logger {
return rootLogger;
}
export function setRootLogger(newLogger: Logger) {
export function setRootLogger(newLogger: winston.Logger) {
rootLogger = newLogger;
}
@@ -15,12 +15,12 @@
*/
import { PassThrough } from 'stream';
import winston, { Logger } from 'winston';
import * as winston from 'winston';
/**
* A logger that just throws away all messages.
*/
export function getVoidLogger(): Logger {
export function getVoidLogger(): winston.Logger {
return winston.createLogger({
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
});
@@ -16,7 +16,7 @@
import express from 'express';
import request from 'supertest';
import winston from 'winston';
import * as winston from 'winston';
import { requestLoggingHandler } from './requestLoggingHandler';
describe('requestLoggingHandler', () => {
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"private": true,
@@ -10,20 +10,20 @@
},
"scripts": {
"build": "tsc",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"nodemon -r esm\\\"",
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm\\\"",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean",
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.7",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.7",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.8",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.8",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.8",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"knex": "^0.21.1",
@@ -31,7 +31,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47",
+4 -1
View File
@@ -65,4 +65,7 @@ async function main() {
});
}
main();
main().catch(error => {
console.error(`Backend failed to start up, ${error}`);
process.exit(1);
});
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function createPlugin({
}: PluginEnvironment) {
const locationReader = new LocationReaders(logger);
const db = await DatabaseManager.createDatabase(database, logger);
const db = await DatabaseManager.createDatabase(database, { logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const higherOrderOperation = new HigherOrderOperations(
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"main:src": "src/index.ts",
@@ -26,7 +26,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"@types/yup": "^0.28.2",
+9 -3
View File
@@ -15,15 +15,18 @@
*/
import {
DefaultNamespaceEntityPolicy,
Entity,
FieldFormatEntityPolicy,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
} from './entity';
import { ComponentV1beta1Policy } from './kinds';
import {
ComponentEntityV1beta1Policy,
LocationEntityV1beta1Policy,
} from './kinds';
import { EntityPolicy } from './types';
import { DefaultNamespaceEntityPolicy } from './entity/policies/DefaultNamespaceEntityPolicy';
// Helper that requires that all of a set of policies can be successfully
// applied
@@ -68,7 +71,10 @@ export class EntityPolicies implements EntityPolicy {
new FieldFormatEntityPolicy(),
new ReservedFieldsEntityPolicy(),
]),
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
EntityPolicies.anyOf([
new ComponentEntityV1beta1Policy(),
new LocationEntityV1beta1Policy(),
]),
]);
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
@@ -15,33 +15,25 @@
*/
import * as yup from 'yup';
import type { Entity, EntityMeta } from '../entity/Entity';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Component';
export interface ComponentV1beta1 extends Entity {
export interface ComponentEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
metadata: EntityMeta & {
name: string;
};
spec: {
type: string;
};
}
export class ComponentV1beta1Policy implements EntityPolicy {
export class ComponentEntityV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<ComponentV1beta1>>({
metadata: yup
.object({
name: yup.string().required(),
})
.required(),
this.schema = yup.object<Partial<ComponentEntityV1beta1>>({
spec: yup
.object({
type: yup.string().required(),
@@ -51,10 +43,7 @@ export class ComponentV1beta1Policy implements EntityPolicy {
}
async enforce(envelope: Entity): Promise<Entity> {
if (
envelope.apiVersion !== 'backstage.io/v1beta1' ||
envelope.kind !== 'Component'
) {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
@@ -0,0 +1,56 @@
/*
* 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 * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = 'backstage.io/v1beta1';
const KIND = 'Location';
export interface LocationEntityV1beta1 extends Entity {
apiVersion: typeof API_VERSION;
kind: typeof KIND;
spec: {
type: string;
target?: string;
targets?: string[];
};
}
export class LocationEntityV1beta1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<LocationEntityV1beta1>>({
spec: yup
.object({
type: yup.string().required(),
target: yup.string().notRequired(),
targets: yup.array(yup.string()).notRequired(),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
throw new Error('Unsupported apiVersion / kind');
}
return await this.schema.validate(envelope, { strict: true });
}
}
+9 -4
View File
@@ -14,8 +14,13 @@
* limitations under the License.
*/
export { ComponentEntityV1beta1Policy } from './ComponentEntityV1beta1';
export type {
ComponentV1beta1,
ComponentV1beta1 as Component,
} from './ComponentV1beta1';
export { ComponentV1beta1Policy } from './ComponentV1beta1';
ComponentEntityV1beta1 as ComponentEntity,
ComponentEntityV1beta1,
} from './ComponentEntityV1beta1';
export { LocationEntityV1beta1Policy } from './LocationEntityV1beta1';
export type {
LocationEntityV1beta1 as LocationEntity,
LocationEntityV1beta1,
} from './LocationEntityV1beta1';
+2
View File
@@ -52,6 +52,8 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
// Avoid cross-package imports
'no-restricted-imports': [2, { patterns: ['**/../../**/*/src/**'] }],
},
overrides: [
{
+3 -2
View File
@@ -57,18 +57,19 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
'no-restricted-imports': [
2,
{
paths: [
{
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
name: '@material-ui/icons',
message: "Please import '@material-ui/icons/<Icon>' instead.",
},
...require('module').builtinModules,
],
// Avoid cross-package imports
patterns: ['**/../../**/*/src/**'],
},
],
},
+1 -1
View File
@@ -41,7 +41,7 @@ async function getConfig() {
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public"
+2 -1
View File
@@ -23,7 +23,7 @@ import {
printFileSizesAfterBuild,
} from 'react-dev-utils/FileSizeReporter';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import { createConfig } from './config';
import { createConfig, resolveBaseUrl } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths } from './paths';
import chalk from 'chalk';
@@ -40,6 +40,7 @@ export async function buildBundle(options: BuildOptions) {
...options,
checksEnabled: false,
isDev: false,
baseUrl: resolveBaseUrl(options.config),
});
const compiler = webpack(config);
+13
View File
@@ -18,6 +18,7 @@ import webpack from 'webpack';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { Config } from '@backstage/config';
import { BundlingPaths } from './paths';
import { transforms } from './transforms';
import { optimization } from './optimization';
@@ -28,6 +29,18 @@ import { BundlingOptions } from './types';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
export function resolveBaseUrl(config: Config): URL {
const baseUrl = config.getString('app.baseUrl');
if (!baseUrl) {
throw new Error('app.baseUrl must be set in config');
}
try {
return new URL(baseUrl, 'http://localhost:3000');
} catch (error) {
throw new Error(`Invalid app.baseUrl, ${error}`);
}
}
export function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
+8 -29
View File
@@ -15,30 +15,22 @@
*/
import fs from 'fs-extra';
import yn from 'yn';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
import { createConfig } from './config';
import { createConfig, resolveBaseUrl } from './config';
import { ServeOptions } from './types';
import { resolveBundlingPaths } from './paths';
export async function serveBundle(options: ServeOptions) {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const url = resolveBaseUrl(options.config);
const port = await choosePort(host, defaultPort);
if (!port) {
throw new Error(`Invalid or no port set: '${port}'`);
}
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
const paths = resolveBundlingPaths(options);
const pkgPath = paths.targetPackageJson;
const pkg = await fs.readJson(pkgPath);
const config = createConfig(paths, { ...options, isDev: true });
const config = createConfig(paths, { ...options, isDev: true, baseUrl: url });
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, {
@@ -49,33 +41,20 @@ export async function serveBundle(options: ServeOptions) {
historyApiFallback: true,
clientLogLevel: 'warning',
stats: 'errors-warnings',
https: protocol === 'https',
host,
https: url.protocol === 'https:',
host: url.hostname,
port,
proxy: pkg.proxy,
});
await new Promise((resolve, reject) => {
server.listen(port, host, (err?: Error) => {
server.listen(port, url.hostname, (err?: Error) => {
if (err) {
reject(err);
return;
}
// TODO: This signature is available in 10.2.1 but doesn't have types published yet
const latestPrepareUrls = prepareUrls as (
protocol: string,
host: string,
port: number,
path?: string,
) => ReturnType<typeof prepareUrls>;
const urls = latestPrepareUrls(
protocol,
host,
port,
config.output?.publicPath,
);
openBrowser(urls.localUrlForBrowser);
openBrowser(url.href);
resolve();
});
});
+1
View File
@@ -22,6 +22,7 @@ export type BundlingOptions = {
isDev: boolean;
config: Config;
appConfigs: AppConfig[];
baseUrl: URL;
};
export type ServeOptions = BundlingPathsOptions & {
+4 -3
View File
@@ -50,6 +50,7 @@ export const makeConfigs = async (
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
const output = new Array<OutputOptions>();
const mainFields = ['module', 'main'];
if (options.outputs.has(Output.cjs)) {
output.push({
@@ -66,6 +67,8 @@ export const makeConfigs = async (
chunkFileNames: 'esm/[name]-[hash].js',
format: 'module',
});
// Assume we're building for the browser if ESM output is included
mainFields.unshift('browser');
}
configs.push({
@@ -77,9 +80,7 @@ export const makeConfigs = async (
peerDepsExternal({
includeDependencies: true,
}),
resolve({
mainFields: ['browser', 'module', 'main'],
}),
resolve({ mainFields }),
commonjs({
include: ['node_modules/**', '../../node_modules/**'],
exclude: ['**/*.stories.*', '**/*.test.*'],
@@ -12,7 +12,7 @@
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -21,7 +21,6 @@
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"@types/testing-library__jest-dom": "^5.0.4",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
@@ -27,7 +27,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
"react-router-dom": "6.0.0-alpha.5"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
+4 -2
View File
@@ -32,11 +32,13 @@
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"fs-extra": "^9.0.0",
"yaml": "^1.9.2"
"yaml": "^1.9.2",
"yup": "^0.28.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
"@types/node": "^12.0.0",
"@types/yup": "^0.28.2"
},
"files": [
"dist/**/*.{js,d.ts}"
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { loadConfig } from './loader';
export type { LoadConfigOptions } from './types';
export type { LoadConfigOptions } from './loader';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { readEnv } from './loader';
import { readEnv } from './env';
describe('readEnv', () => {
it('should return empty config for empty env', () => {
+91
View File
@@ -0,0 +1,91 @@
/*
* 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 { AppConfig, JsonObject } from '@backstage/config';
const ENV_PREFIX = 'APP_CONFIG_';
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
/**
* Read runtime configuration from the environment.
*
* Only environment variables prefixed with APP_CONFIG_ will be considered.
*
* For each variable, the prefix will be removed, and rest of the key will
* be split by '_'. Each part will then be used as keys to build up a nested
* config object structure. The treatment of the entire environment variable
* is case-sensitive.
*
* The value of the variable should be JSON serialized, as it will be parsed
* and the type will be kept intact. For example "true" and true are treated
* differently, as well as "42" and 42.
*
* For example, to set the config app.title to "My Title", use the following:
*
* APP_CONFIG_app_title='"My Title"'
*/
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
}
return config ? [config] : [];
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};
export { resolveStaticConfig } from './resolver';
export { readConfigFile } from './reader';
export { readEnv } from './env';
export { readSecret } from './secrets';
@@ -0,0 +1,123 @@
/*
* 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 { readConfigFile } from './reader';
import { ReaderContext, ReadSecretFunc } from './types';
function memoryFiles(files: { [path: string]: string }) {
return async (path: string) => {
if (path in files) {
return files[path];
}
throw new Error(`File not found, ${path}`);
};
}
describe('readConfigFile', () => {
it('should read a plain config file', async () => {
const readFile = memoryFiles({
'./app-config.yaml':
'app: { title: "Test", x: 1, y: [null, true], z: null }',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).resolves.toEqual({
app: {
title: 'Test',
x: 1,
y: [true],
},
});
});
it('should error out if the config file has invalid syntax', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { title: ]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
});
it('should error out if config is not an object', async () => {
const readFile = memoryFiles({
'./app-config.yaml': '[]',
});
const config = readConfigFile('./app-config.yaml', {
readFile,
} as ReaderContext);
await expect(config).rejects.toThrow('Expected object at config root');
});
it('should read secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).resolves.toEqual({
app: 'secret',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
});
it('should require secrets to be objects', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
expect(readSecret).not.toHaveBeenCalled();
await expect(config).rejects.toThrow(
'Expected object at secret .app.$secret',
);
});
it('should forward secret reading errors', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: {} }',
});
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
const config = readConfigFile('./app-config.yaml', {
env: {},
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
});
});
+80
View File
@@ -0,0 +1,80 @@
/*
* 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 yaml from 'yaml';
import { isObject } from './utils';
import { JsonValue, JsonObject } from '@backstage/config';
import { ReaderContext } from './types';
/**
* Reads and parses, and validates, and transforms a single config file.
* The transformation rewrites any special values, like the $secret key.
*/
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
async function transform(
obj: JsonValue,
path: string,
): Promise<JsonValue | undefined> {
if (typeof obj !== 'object') {
return obj;
} else if (obj === null) {
return undefined;
} else if (Array.isArray(obj)) {
const arr = new Array<JsonValue>();
for (const [index, value] of obj.entries()) {
const out = await transform(value, `${path}[${index}]`);
if (out !== undefined) {
arr.push(out);
}
}
return arr;
}
if ('$secret' in obj) {
if (!isObject(obj.$secret)) {
throw TypeError(`Expected object at secret ${path}.$secret`);
}
try {
return await ctx.readSecret(obj.$secret);
} catch (error) {
throw new Error(`Invalid secret at ${path}: ${error.message}`);
}
}
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
}
}
return out;
}
const finalConfig = await transform(config, '');
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return finalConfig;
}
@@ -0,0 +1,43 @@
/*
* 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 { resolve as resolvePath } from 'path';
import { findRootPath } from './paths';
type ResolveOptions = {
// Same as configPath in LoadConfigOptions
configPath?: string;
};
/**
* Resolves all configuration files that should be loaded in the given environment.
*/
export async function resolveStaticConfig(
options: ResolveOptions,
): Promise<string[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
}
return [configPath];
}
@@ -0,0 +1,114 @@
/*
* 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 { readSecret } from './secrets';
import { ReaderContext } from './types';
const ctx: ReaderContext = {
env: {
SECRET: 'my-secret',
},
readSecret: jest.fn(),
async readFile(path) {
const content = ({
'my-secret': 'secret',
'my-data.json': '{"a":{"b":{"c":42}}}',
'my-data.yaml': 'some:\n yaml:\n key: 7',
'my-data.yml': 'different: { key: hello }',
} as { [key: string]: string })[path];
if (!content) {
throw new Error('File not found!');
}
return content;
},
};
describe('readSecret', () => {
it('should read file secrets', async () => {
await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
'secret',
);
await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
'File not found!',
);
});
it('should read present env secrets', async () => {
await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
undefined,
);
});
it('should read data secrets', async () => {
await expect(
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
).resolves.toBe('42');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
).resolves.toBe('7');
await expect(
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
).resolves.toBe('hello');
await expect(
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
).rejects.toThrow('File not found!');
});
it('should reject invalid secrets', async () => {
await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
'secret must be a `object` type, but the final value was: `"hello"`.',
);
await expect(readSecret({}, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
'path is a required field',
);
await expect(
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
).rejects.toThrow('No data secret parser available for extension .js');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
});
it('should have 100% test coverage', async () => {
let firstVisit = true;
const secret = {};
const proto = {
get file() {
if (!firstVisit) {
Object.setPrototypeOf(secret, {});
}
firstVisit = false;
return 'a-file';
},
};
Object.setPrototypeOf(secret, proto);
await expect(readSecret(secret, ctx)).rejects.toThrow(
'Secret was left unhandled',
);
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* 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 * as yup from 'yup';
import yaml from 'yaml';
import { extname } from 'path';
import { JsonObject, JsonValue } from '@backstage/config';
import { isObject, isNever } from './utils';
import { ReaderContext } from './types';
// Reads a file and forwards the contents as is, assuming ut8 encoding
type FileSecret = {
// Path to the secret file, relative to the config file.
file: string;
};
// Reads the secret from an environment variable.
type EnvSecret = {
// The name of the environment file.
env: string;
};
// Reads a secret from a json-like file and extracts a value at a path.
// The supported extensions are define in dataSecretParser below.
type DataSecret = {
// Path to the data secret file, relative to the config file.
data: string;
// The path to the value inside the data file.
// Either a '.' separated list, or an array of path segments.
path: string | string[];
};
type Secret = FileSecret | EnvSecret | DataSecret;
// Schema for each type of secret description
const secretLoaderSchemas = {
file: yup.object({
file: yup.string().required(),
}),
env: yup.object({
env: yup.string().required(),
}),
data: yup.object({
data: yup.string().required(),
path: yup.lazy(value => {
if (typeof value === 'string') {
return yup.string().required();
}
return yup.array().of(yup.string().required()).required();
}),
}),
};
// The top-level secret schema, which figures out what type of secret it is.
const secretSchema = yup.lazy<object>(value => {
if (typeof value !== 'object' || value === null) {
return yup.object().required().label('secret');
}
const loaderTypes = Object.keys(
secretLoaderSchemas,
) as (keyof typeof secretLoaderSchemas)[];
for (const key of loaderTypes) {
if (key in value) {
return secretLoaderSchemas[key];
}
}
throw new yup.ValidationError(
`Secret must contain one of '${loaderTypes.join("', '")}'`,
value,
'$secret',
);
});
// Parsers for each type of data secret file.
const dataSecretParser: {
[ext in string]: (content: string) => Promise<JsonObject>;
} = {
'.json': async content => JSON.parse(content),
'.yaml': async content => yaml.parse(content),
'.yml': async content => yaml.parse(content),
};
/**
* Transforms a secret description into the actual secret value.
*/
export async function readSecret(
data: JsonObject,
ctx: ReaderContext,
): Promise<string | undefined> {
const secret = secretSchema.validateSync(data, { strict: true }) as Secret;
if ('file' in secret) {
return ctx.readFile(secret.file);
}
if ('env' in secret) {
return ctx.env[secret.env];
}
if ('data' in secret) {
const ext = extname(secret.data);
const parser = dataSecretParser[ext];
if (!parser) {
throw new Error(`No data secret parser available for extension ${ext}`);
}
const content = await ctx.readFile(secret.data);
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
let value: JsonValue = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
throw new Error(
`Value is not an object at ${errPath} in ${secret.data}`,
);
}
value = value[part];
}
return String(value);
}
isNever<typeof secret>();
throw new Error('Secret was left unhandled');
}
+29
View File
@@ -0,0 +1,29 @@
/*
* 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 { JsonObject } from '@backstage/config';
export type ReadFileFunc = (path: string) => Promise<string>;
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
/**
* Common context that provides all the necessary hooks for reading configuration files.
*/
export type ReaderContext = {
env: { [name in string]?: string };
readFile: ReadFileFunc;
readSecret: ReadSecretFunc;
};
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue, JsonObject } from '@backstage/config';
export function isObject(obj: JsonValue | undefined): obj is JsonObject {
if (typeof obj !== 'object') {
return false;
} else if (Array.isArray(obj)) {
return false;
}
return obj !== null;
}
// A thing to make sure we've narrowed the type down to never
export function isNever<T extends never>() {
return void 0 as T;
}
+52 -73
View File
@@ -15,87 +15,46 @@
*/
import fs from 'fs-extra';
import yaml from 'yaml';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, dirname } from 'path';
import { AppConfig, JsonObject } from '@backstage/config';
import { findRootPath } from './paths';
import { LoadConfigOptions } from './types';
import {
resolveStaticConfig,
readConfigFile,
readEnv,
readSecret,
} from './lib';
const ENV_PREFIX = 'APP_CONFIG_';
export type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
// Update the same pattern in config package if this is changed
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
// Whether to read secrets or omit them, defaults to false.
shouldReadSecrets?: boolean;
};
export function readEnv(env: {
[name: string]: string | undefined;
}): AppConfig[] {
let config: JsonObject | undefined = undefined;
class Context {
constructor(
private readonly options: {
env: { [name in string]?: string };
rootPath: string;
shouldReadSecrets: boolean;
},
) {}
for (const [name, value] of Object.entries(env)) {
if (!value) {
continue;
}
if (name.startsWith(ENV_PREFIX)) {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
}
if (index < keyParts.length - 1) {
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
if (typeof obj !== 'object' || Array.isArray(obj)) {
const subKey = keyParts.slice(0, index + 1).join('_');
throw new TypeError(
`Could not nest config for key '${key}' under existing value '${subKey}'`,
);
}
} else {
if (part in obj) {
throw new TypeError(
`Refusing to override existing config at key '${key}'`,
);
}
try {
const parsedValue = JSON.parse(value);
if (parsedValue === null) {
throw new Error('value may not be null');
}
obj[part] = parsedValue;
} catch (error) {
throw new TypeError(
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
);
}
}
}
}
get env() {
return this.options.env;
}
return config ? [config] : [];
}
export async function readStaticConfig(
options: LoadConfigOptions,
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
let { configPath } = options;
if (!configPath) {
configPath = resolvePath(
findRootPath(fs.realpathSync(process.cwd())),
'app-config.yaml',
);
async readFile(path: string): Promise<string> {
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
}
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
async readSecret(desc: JsonObject): Promise<string | undefined> {
if (!this.options.shouldReadSecrets) {
return undefined;
}
return readSecret(desc, this);
}
}
@@ -105,7 +64,27 @@ export async function loadConfig(
const configs = [];
configs.push(...readEnv(process.env));
configs.push(...(await readStaticConfig(options)));
const configPaths = await resolveStaticConfig(options);
try {
for (const configPath of configPaths) {
const config = await readConfigFile(
configPath,
new Context({
env: process.env,
rootPath: dirname(configPath),
shouldReadSecrets: Boolean(options.shouldReadSecrets),
}),
);
configs.push(config);
}
} catch (error) {
throw new Error(
`Failed to read static configuration file: ${error.message}`,
);
}
return configs;
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,18 +31,18 @@
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
/**
* The Identity API used to identify and get information about the signed in user.
*/
export type IdentityApi = {
/**
* The ID of the signed in user. This ID is not meant to be presented to the user, but used
* as an opaque string to pass on to backends or use in frontend logic.
*
* TODO: The intention of the user ID is to be able to tie the user to an identity
* that is known by the catalog and/or identity backend. It should for example
* be possible to fetch all owned components using this ID.
*/
getUserId(): string;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): string | undefined;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
};
export const identifyApiRef = createApiRef<IdentityApi>({
id: 'core.identity',
description: 'Provides access to the identity of the signed in user',
});
@@ -27,5 +27,6 @@ export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -57,7 +57,7 @@ class GithubAuth implements OAuthApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'dev',
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
@@ -66,7 +66,7 @@ class GoogleAuth
static create({
apiOrigin,
basePath,
environment = 'dev',
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
+13 -33
View File
@@ -13,9 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, FC, useMemo } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { Route, Routes, Navigate } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types';
import { BackstagePlugin } from '../plugin';
@@ -90,50 +89,31 @@ export class PrivateAppImpl implements BackstageApp {
for (const output of plugin.output()) {
switch (output.type) {
case 'legacy-route': {
const { path, component, options = {} } = output;
const { exact = true } = options;
const { path, component: Component } = output;
routes.push(
<Route
key={path}
path={path}
component={component}
exact={exact}
/>,
<Route key={path} path={path} element={<Component />} />,
);
break;
}
case 'route': {
const { target, component, options = {} } = output;
const { exact = true } = options;
const { target, component: Component } = output;
routes.push(
<Route
key={`${plugin.getId()}-${target.path}`}
path={target.path}
component={component}
exact={exact}
element={<Component />}
/>,
);
break;
}
case 'legacy-redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
routes.push(
<Redirect key={path} path={path} to={target} exact={exact} />,
);
const { path, target } = output;
routes.push(<Navigate key={path} to={target} />);
break;
}
case 'redirect-route': {
const { from, to, options = {} } = output;
const { exact = true } = options;
routes.push(
<Redirect
key={from.path}
path={from.path}
to={to.path}
exact={exact}
/>,
);
const { from, to } = output;
routes.push(<Navigate key={from.path} to={to.path} />);
break;
}
case 'feature-flag': {
@@ -155,10 +135,10 @@ export class PrivateAppImpl implements BackstageApp {
}
const rendered = (
<Switch>
<Routes>
{routes}
<Route component={NotFoundErrorPage} />
</Switch>
<Route element={<NotFoundErrorPage />} />
</Routes>
);
return () => rendered;
@@ -225,7 +205,7 @@ export class PrivateAppImpl implements BackstageApp {
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>
<Router basename={pathname}>{children}</Router>
<Router>{children}</Router>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
+1 -2
View File
@@ -25,12 +25,11 @@ export type BootErrorPageProps = {
step: 'load-config';
error: Error;
};
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
BootErrorPage: ComponentType<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{ basename?: string }>;
Router: ComponentType<{}>;
};
/**
+7 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,13 +31,12 @@
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"@types/react-router-dom": "^5.1.5",
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
@@ -48,15 +47,15 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "6.0.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
@@ -15,12 +15,7 @@
*/
import React, { FunctionComponentFactory } from 'react';
import { Button } from './Button';
import {
MemoryRouter,
Route,
useLocation,
Link as RouterLink,
} from 'react-router-dom';
import { MemoryRouter, Route, useLocation } from 'react-router-dom';
import { createRouteRef } from '@backstage/core-api';
const Location = () => {
@@ -70,14 +65,7 @@ export const PassProps = () => {
return (
<>
<Button
to={routeRef.path}
/** react-router-dom related prop */
component={RouterLink}
/** material-ui related prop */
color="secondary"
variant="outlined"
>
<Button to={routeRef.path} color="secondary" variant="outlined">
This link
</Button>
&nbsp;has props for both material-ui's component as well as for
@@ -15,11 +15,10 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Button } from './Button';
import { MemoryRouter, Route } from 'react-router';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
describe('<Button />', () => {
it('navigates using react-router', async () => {
@@ -27,12 +26,13 @@ describe('<Button />', () => {
const buttonLabel = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Routes>
<Route path="/test" element={<p>{testString}</p>} />
<Button to="/test">{buttonLabel}</Button>
<Route path="/test">{testString}</Route>{' '}
</MemoryRouter>,
</Routes>,
),
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(buttonLabel)));
expect(getByText(testString)).toBeInTheDocument();
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Link } from './Link';
import { MemoryRouter, Route } from 'react-router';
import { Route, Routes } from 'react-router';
import { act } from 'react-dom/test-utils';
describe('<Link />', () => {
@@ -27,10 +27,10 @@ describe('<Link />', () => {
const linkText = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Routes>
<Link to="/test">{linkText}</Link>
<Route path="/test">{testString}</Route>
</MemoryRouter>,
<Route path="/test" element={<p>{testString}</p>} />
</Routes>,
),
);
expect(() => getByText(testString)).toThrow();
+1 -1
View File
@@ -19,7 +19,7 @@ import { Link as MaterialLink } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
type Props = ComponentProps<typeof MaterialLink> &
ComponentProps<typeof RouterLink>;
ComponentProps<typeof RouterLink> & { component?: React.FC<any> };
/**
* Thin wrapper on top of material-ui's Link component
@@ -19,7 +19,7 @@ import { Typography, Link, Grid } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import { MicDrop } from './MicDrop';
import { useHistory } from 'react-router';
import { useNavigate } from 'react-router';
interface IErrorPageProps {
status: string;
@@ -40,7 +40,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
const classes = useStyles();
const history = useHistory();
const navigate = useNavigate();
return (
<Grid container className={classes.container}>
@@ -53,7 +53,7 @@ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
Looks like someone dropped the mic!
</Typography>
<Typography variant="h6">
<Link data-testid="go-back-link" onClick={history.goBack}>
<Link data-testid="go-back-link" onClick={() => navigate(-1)}>
Go back
</Link>
... or if you think this is a bug, please file an{' '}
+4 -5
View File
@@ -121,7 +121,8 @@ export const SidebarItem: FC<SidebarItemProps> = ({
icon: Icon,
text,
to = '#',
disableSelected = false,
// TODO: isActive is not in v6
// disableSelected = false,
hasNotifications = false,
onClick,
children,
@@ -148,8 +149,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
<NavLink
className={clsx(classes.root, classes.closed)}
activeClassName={classes.selected}
isActive={match => Boolean(match && !disableSelected)}
exact
end
to={to}
onClick={onClick}
>
@@ -161,8 +161,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
<NavLink
className={clsx(classes.root, classes.open)}
activeClassName={classes.selected}
isActive={match => Boolean(match && !disableSelected)}
exact
end
to={to}
onClick={onClick}
>
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/dev-utils",
"description": "Utilities for developing Backstage plugins.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/test-utils": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core": "^0.1.1-alpha.8",
"@backstage/test-utils": "^0.1.1-alpha.8",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
@@ -43,8 +43,8 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "storybook",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"description": "Storybook build for core package",
"private": true,
"scripts": {
@@ -14,7 +14,7 @@
]
},
"dependencies": {
"@backstage/theme": "^0.1.1-alpha.7"
"@backstage/theme": "^0.1.1-alpha.8"
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.17",
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/core-api": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@backstage/core-api": "^0.1.1-alpha.8",
"@backstage/test-utils-core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.8",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
@@ -41,8 +41,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
},
"devDependencies": {
"@types/jest": "^25.2.2",
@@ -17,7 +17,7 @@
import React, { FC, useEffect } from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp, renderInTestApp } from './appWrappers';
import { Route } from 'react-router';
import { Route, Routes } from 'react-router';
import { withLogCollector } from '@backstage/test-utils-core';
import {
useApi,
@@ -32,15 +32,15 @@ describe('wrapInTestApp', () => {
const { error } = await withLogCollector(['error'], async () => {
const rendered = render(
wrapInTestApp(
<>
<Route path="/route1">Route 1</Route>
<Route path="/route2">Route 2</Route>
</>,
<Routes>
<Route path="/route1" element={<p>Route 1</p>} />
<Route path="/route2" element={<p>Route 2</p>} />
</Routes>,
{ routeEntries: ['/route2'] },
),
);
expect(rendered.getByText('Route 2')).toBeInTheDocument();
expect(rendered.getByText('Route 2')).toBeInTheDocument();
// Wait for async actions to trigger the act() warnings that we assert below
await Promise.resolve();
});
@@ -92,7 +92,9 @@ export function wrapInTestApp(
return (
<AppProvider>
<Route component={Wrapper} />
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
* and work with nested routes if they exist too */}
<Route path="*" element={<Wrapper />} />
</AppProvider>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/theme",
"description": "material-ui theme for use with Backstage.",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"private": false,
"publishConfig": {
"access": "public",
@@ -32,7 +32,7 @@
"@material-ui/core": "^4.9.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7"
"@backstage/cli": "^0.1.1-alpha.8"
},
"files": [
"dist/**/*.{js,d.ts}"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -15,7 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
@@ -39,7 +39,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/body-parser": "^1.19.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3",
@@ -0,0 +1,62 @@
/*
* 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 { AuthProviderRouteHandlers } from '../providers/types';
import { NotFoundError } from '@backstage/backend-common';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(private readonly providers: EnvironmentHandlers) {}
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
const env = req.query.env?.toString();
if (!this.providers.hasOwnProperty(env)) {
throw new NotFoundError(
`No environment for ${env} found in this provider`,
);
}
return this.providers[env];
}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
if (provider.refresh) {
provider.refresh(req, res);
}
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
}
}
@@ -18,15 +18,12 @@ import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
removeRefreshTokenCookie,
setRefreshTokenCookie,
THOUSAND_DAYS_MS,
setNonceCookie,
TEN_MINUTES_MS,
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from './types';
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
});
});
describe('setNonceCookie', () => {
it('should set nonce cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setNonceCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-nonce',
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
});
});
describe('setRefreshTokenCookie', () => {
it('should set refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'REFRESH_TOKEN',
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
);
});
});
describe('removeRefreshTokenCookie', () => {
it('should remove refresh token cookie', () => {
const mockResponse = ({
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
removeRefreshTokenCookie(mockResponse, 'providera');
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'providera-refresh-token',
'',
expect.objectContaining({ maxAge: 0 }),
);
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, data);
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(2);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
}
}
const providerInstance = new MyAuthProvider();
const providerId = 'test-provider';
const oAuthProviderOptions = {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
query: {
scope: 'user',
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
} as unknown) as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
);
// redirect checks
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
cookies: {
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
expect(mockResponse.cookie).toHaveBeenCalledWith(
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({ path: '/auth/test-provider' }),
expect.objectContaining({
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
}),
);
});
it('does no set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
cookies: {
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
});
it('gets new access-token when refreshing', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId);
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
const oauthProvider = new OAuthProvider(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
const mockRequest = ({
header: () => 'XMLHttpRequest',
@@ -14,20 +14,29 @@
* limitations under the License.
*/
import express, { CookieOptions } from 'express';
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
} from './types';
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export const verifyNonce = (req: express.Request, provider: string) => {
const cookieNonce = req.cookies[`${provider}-nonce`];
export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
}
};
export const setNonceCookie = (res: express.Response, provider: string) => {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}/handler`,
httpOnly: true,
};
res.cookie(`${provider}-nonce`, nonce, options);
return nonce;
};
export const setRefreshTokenCookie = (
res: express.Response,
provider: string,
refreshToken: string,
) => {
const options: CookieOptions = {
maxAge: THOUSAND_DAYS_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, refreshToken, options);
};
export const removeRefreshTokenCookie = (
res: express.Response,
provider: string,
) => {
const options: CookieOptions = {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${provider}`,
httpOnly: true,
};
res.cookie(`${provider}-refresh-token`, '', options);
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
data: AuthResponse,
) => {
const jsonData = JSON.stringify(data);
@@ -104,7 +64,7 @@ export const postMessageResponse = (
<html>
<body>
<script>
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), 'http://localhost:3000')
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
</script>
</body>
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
};
export class OAuthProvider implements AuthProviderRouteHandlers {
private readonly provider: string;
private readonly providerHandlers: OAuthProviderHandlers;
private readonly disableRefresh: boolean;
private readonly domain: string;
private readonly basePath: string;
constructor(
providerHandlers: OAuthProviderHandlers,
provider: string,
disableRefresh?: boolean,
private readonly providerHandlers: OAuthProviderHandlers,
private readonly options: Options,
) {
this.provider = provider;
this.providerHandlers = providerHandlers;
this.disableRefresh = disableRefresh ?? false;
const url = new URL(options.baseUrl);
this.domain = url.hostname;
this.basePath = url.pathname;
}
async start(req: express.Request, res: express.Response): Promise<any> {
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
const nonce = setNonceCookie(res, this.provider);
this.setNonceCookie(res, nonce);
const options = {
scope,
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
prompt: 'consent',
state: nonce,
};
const { url, status } = await this.providerHandlers.start(req, options);
res.statusCode = status || 302;
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
): Promise<any> {
try {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.provider);
verifyNonce(req, this.options.providerId);
const { user, info } = await this.providerHandlers.handler(req);
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// throw error if missing refresh token
const { refreshToken } = info;
if (!refreshToken) {
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
// set new refresh token
setRefreshTokenCookie(res, this.provider, refreshToken);
this.setRefreshTokenCookie(res, refreshToken);
}
// post message back to popup if successful
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, {
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
error: {
name: error.name,
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.disableRefresh) {
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
removeRefreshTokenCookie(res, this.provider);
this.removeRefreshTokenCookie(res);
}
return res.send('logout!');
}
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send('Invalid X-Requested-With header');
}
if (!this.providerHandlers.refresh || this.disableRefresh) {
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
return res.send(
`Refresh token not supported for provider: ${this.provider}`,
`Refresh token not supported for provider: ${this.options.providerId}`,
);
}
try {
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
const refreshToken =
req.cookies[`${this.options.providerId}-refresh-token`];
// throw error if refresh token is missing in the request
if (!refreshToken) {
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return res.status(401).send(`${error.message}`);
}
}
private setNonceCookie = (res: express.Response, nonce: string) => {
res.cookie(`${this.options.providerId}-nonce`, nonce, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
) => {
res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
maxAge: THOUSAND_DAYS_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
private removeRefreshTokenCookie = (res: express.Response) => {
res.cookie(`${this.options.providerId}-refresh-token`, '', {
maxAge: 0,
secure: false,
sameSite: 'none',
domain: `${this.domain}`,
path: `${this.basePath}/${this.options.providerId}`,
httpOnly: true,
});
};
}
@@ -17,7 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
@@ -1,43 +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.
*/
export const providers = [
{
provider: 'google',
options: {
clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
},
},
{
provider: 'github',
options: {
clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
callbackURL: 'http://localhost:7000/auth/github/handler/frame',
},
disableRefresh: true,
},
{
provider: 'saml',
options: {
path: '/auth/saml/handler/frame',
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
];
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
saml: createSamlProvider,
};
export function createAuthProvider(providerId: string, config: any) {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
return factory(config);
}
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
const providerId = config.provider;
const provider = createAuthProvider(providerId, config);
const provider = factory(globalConfig, providerConfig, logger);
const router = Router();
router.get('/start', provider.start.bind(provider));
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
@@ -19,24 +19,30 @@ import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
AuthInfoBase,
AuthInfoPrivate,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GithubStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
this._strategy = new GithubStrategy(
{ ...this.providerConfig.options },
{ ...options },
(accessToken: any, _: any, params: any, profile: any, done: any) => {
done(undefined, {
profile,
@@ -59,8 +65,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(config: AuthProviderConfig) {
const provider = new GithubAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider, true);
return oauthProvider;
export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/github/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
providerId: 'github',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -22,7 +22,7 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../PassportStrategyHelper';
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthInfoBase,
@@ -30,19 +30,27 @@ import {
RedirectInfo,
AuthProviderConfig,
AuthInfoWithProfile,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
import { OAuthProvider } from '../../lib/OAuthProvider';
import passport from 'passport';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: GoogleStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: OAuthProviderOptions) {
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
{ ...this.providerConfig.options },
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
@@ -104,8 +112,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(config: AuthProviderConfig) {
const provider = new GoogleAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider);
return oauthProvider;
export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/google/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars',
);
}
logger.warn(
'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), {
providerId: 'google',
secure,
baseUrl,
appOrigin,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -19,16 +19,26 @@ import { Strategy as SamlStrategy } from 'passport-saml';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../PassportStrategyHelper';
import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
import { postMessageResponse } from '../OAuthProvider';
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
EnvironmentProviderConfig,
SAMLProviderConfig,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
export class SamlAuthProvider implements AuthProviderRouteHandlers {
private readonly strategy: SamlStrategy;
constructor(providerConfig: AuthProviderConfig) {
constructor(options: SAMLProviderOptions) {
this.strategy = new SamlStrategy(
{ ...providerConfig.options },
{ ...options },
(profile: any, done: any) => {
// TODO: There's plenty more validation and profile handling to do here,
// this provider is currently only intended to validate the provider pattern
@@ -57,12 +67,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
try {
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
payload: user,
});
} catch (error) {
return postMessageResponse(res, {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
error: {
name: error.name,
@@ -77,6 +87,36 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
}
}
export function createSamlProvider(config: AuthProviderConfig) {
return new SamlAuthProvider(config);
type SAMLProviderOptions = {
entryPoint: string;
issuer: string;
path: string;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as SAMLProviderConfig;
const opts = {
entryPoint: config.entryPoint,
issuer: config.issuer,
path: '/auth/saml/handler/frame',
};
if (!opts.entryPoint || !opts.issuer) {
logger.warn(
'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable',
);
continue;
}
envProviders[env] = new SamlAuthProvider(opts);
}
return new EnvironmentHandler(envProviders);
}
+31 -4
View File
@@ -15,11 +15,32 @@
*/
import express from 'express';
import { Logger } from 'winston';
export type OAuthProviderOptions = {
clientID: string;
clientSecret: string;
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
provider: string;
options: any;
disableRefresh?: boolean;
baseUrl: string;
};
export type OAuthProviderConfig = {
secure: boolean;
appOrigin: string; // http://localhost:3000
clientId: string;
clientSecret: string;
};
export interface OAuthProviderHandlers {
@@ -36,8 +57,14 @@ export interface AuthProviderRouteHandlers {
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
export type AuthProviderFactory = (
config: AuthProviderConfig,
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
+55 -8
View File
@@ -19,7 +19,6 @@ import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import { Logger } from 'winston';
import { providers } from './../providers/config';
import { createAuthProviderRouter } from '../providers';
export interface RouterOptions {
@@ -36,13 +35,61 @@ export async function createRouter(
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
// configure all the providers
for (const providerConfig of providers) {
const { provider } = providerConfig;
const providerRouter = createAuthProviderRouter(providerConfig);
logger.info(`Configuring provider, ${provider}`);
router.use(`/${provider}`, providerRouter);
}
// TODO: read from app config
const config = {
backend: {
baseUrl: 'http://localhost:7000',
},
auth: {
providers: {
google: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
},
production: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: '',
clientSecret: '',
},
},
github: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
},
},
saml: {
development: {
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
},
},
};
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${config.backend.baseUrl}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
providerId,
{ baseUrl },
providerConfig,
logger,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
return router;
}
+22 -6
View File
@@ -1,16 +1,32 @@
# Catalog Backend
WORK IN PROGRESS
This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
It comes with a builtin database backed implementation of the catalog, that can store
and serve your catalog for you.
It can also act as a bridge to your existing catalog solutions, either ingesting their
data to store in the database, or by effectively proxying calls to an external catalog
service.
## Getting Started
After starting the backend, you can issue the `yarn mock-catalog-data` command
in this directory to populate the catalog with some mock entities.
This backend plugin can be started in a standalone mode from directly in this package
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the catalog backend plugin itself.
To evaluate the catalog and have a greater amount of functionality available, instead do
```bash
# in one terminal window, run this from from the very root of the Backstage project
cd packages/backend
yarn start
# open another terminal window, and run the following from the very root of the Backstage project
yarn lerna run mock-catalog-data
```
This will launch the full example backend and populate its catalog with some mock entities.
## Links
@@ -0,0 +1,8 @@
---
apiVersion: backstage.io/v1beta1
kind: Location
metadata:
name: location-1
spec:
type: github
target: https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml
@@ -14,9 +14,12 @@
* limitations under the License.
*/
import * as Knex from 'knex';
// @ts-check
export async function up(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return (
knex.schema
//
@@ -114,9 +117,12 @@ export async function up(knex: Knex): Promise<any> {
.comment('The corresponding value to match on');
})
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema
.dropTable('entities_search')
.alterTable('entities', table => {
@@ -124,4 +130,4 @@ export async function down(knex: Knex): Promise<any> {
})
.dropTable('entities')
.dropTable('locations');
}
};
@@ -13,16 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('location_update_log', table => {
table.uuid('id').primary();
table.enum('status', ['success', 'fail']).notNullable();
table
.dateTime('created_at')
.defaultTo(knex.fn.now())
.notNullable();
table.dateTime('created_at').defaultTo(knex.fn.now()).notNullable();
table.string('message');
table
.uuid('location_id')
@@ -32,8 +33,11 @@ export async function up(knex: Knex): Promise<any> {
.onDelete('CASCADE');
table.string('entity_name').nullable();
});
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTableIfExists('location_update_log');
}
};
@@ -13,9 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Knex from 'knex';
export async function up(knex: Knex): Promise<any> {
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
// Need to first order by date of creation
const query = knex
.select()
@@ -28,8 +32,11 @@ export async function up(knex: Knex): Promise<any> {
await knex.schema.raw(
`CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`,
);
}
};
export async function down(knex: Knex): Promise<any> {
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.raw(`DROP VIEW location_update_log_latest;`);
}
};
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.7",
"version": "0.1.1-alpha.8",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -16,8 +16,8 @@
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"@backstage/backend-common": "^0.1.1-alpha.8",
"@backstage/catalog-model": "^0.1.1-alpha.8",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -34,7 +34,7 @@
"yup": "^0.28.5"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/cli": "^0.1.1-alpha.8",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -45,7 +45,8 @@
"tsc-watch": "^4.2.3"
},
"files": [
"dist"
"dist",
"migrations"
],
"nodemonConfig": {
"watch": "./dist"
@@ -14,29 +14,14 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from '../database';
import { DatabaseManager } from '../database';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
describe('DatabaseLocationsCatalog', () => {
let catalog: DatabaseLocationsCatalog;
beforeEach(async () => {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: path.resolve(__dirname, '../database/migrations'),
loadExtensions: ['.ts'],
});
const db = new CommonDatabase(knex, getVoidLogger());
const db = await DatabaseManager.createTestDatabase();
catalog = new DatabaseLocationsCatalog(db);
});
@@ -14,16 +14,10 @@
* limitations under the License.
*/
import {
ConflictError,
getVoidLogger,
NotFoundError,
} from '@backstage/backend-common';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { CommonDatabase } from './CommonDatabase';
import { DatabaseLocationUpdateLogStatus } from './types';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
@@ -31,22 +25,12 @@ import type {
} from './types';
describe('CommonDatabase', () => {
let knex: Knex;
let db: Database;
let entityRequest: DbEntityRequest;
let entityResponse: DbEntityResponse;
beforeEach(async () => {
knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
await knex.raw('PRAGMA foreign_keys = ON');
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.ts'],
});
db = await DatabaseManager.createTestDatabase();
entityRequest = {
entity: {
@@ -84,7 +68,6 @@ describe('CommonDatabase', () => {
});
it('manages locations', async () => {
const db = new CommonDatabase(knex, getVoidLogger());
const input: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
@@ -115,55 +98,76 @@ describe('CommonDatabase', () => {
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
});
it('rejects adding the same-named entity twice', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-kind entity twice', async () => {
entityRequest.entity.kind = 'some-kind';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.kind = 'SomeKind';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-named entity twice', async () => {
entityRequest.entity.metadata.name = 'some-name';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.name = 'SomeName';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
entityRequest.entity.metadata.namespace = undefined;
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = '';
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
entityRequest.entity.metadata.namespace = 'namespace1';
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
await expect(
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
});
});
describe('locationHistory', () => {
it('outputs the history correctly', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const location: Location = {
id: 'dd12620d-0436-422f-93bd-929aa0788123',
type: 'a',
target: 'b',
};
await catalog.addLocation(location);
await db.addLocation(location);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.SUCCESS,
);
await catalog.addLocationUpdateLogEvent(
await db.addLocationUpdateLogEvent(
'dd12620d-0436-422f-93bd-929aa0788123',
DatabaseLocationUpdateLogStatus.FAIL,
undefined,
'Something went wrong',
);
const result = await catalog.locationHistory(
const result = await db.locationHistory(
'dd12620d-0436-422f-93bd-929aa0788123',
);
expect(result).toEqual([
@@ -189,12 +193,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
expect(updated.entity.kind).toEqual(added.entity.kind);
@@ -211,77 +212,55 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.name! = 'new!';
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.metadata.name).toEqual('new!');
});
it('can update fields if kind, name, and namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
const updated = await catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
expect(updated.entity.apiVersion).toEqual('something.new');
});
it('rejects if kind, name, but not namespace match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.apiVersion = 'something.new';
delete added.entity.metadata.uid;
delete added.entity.metadata.generation;
added.entity.metadata.namespace = 'something.wrong';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(NotFoundError);
});
it('fails to update an entity if etag does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.etag = 'garbage';
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
it('fails to update an entity if generation does not match', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const added = await catalog.transaction(tx =>
catalog.addEntity(tx, entityRequest),
);
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
added.entity.metadata.generation! += 100;
await expect(
catalog.transaction(tx =>
catalog.updateEntity(tx, { entity: added.entity }),
),
db.transaction(tx => db.updateEntity(tx, { entity: added.entity })),
).rejects.toThrow(ConflictError);
});
});
describe('entities', () => {
it('can get all entities with empty filters list', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const e1: Entity = {
apiVersion: 'a',
kind: 'k1',
@@ -293,13 +272,11 @@ describe('CommonDatabase', () => {
metadata: { name: 'n' },
spec: { c: null },
};
await catalog.transaction(async tx => {
await catalog.addEntity(tx, { entity: e1 });
await catalog.addEntity(tx, { entity: e2 });
await db.transaction(async tx => {
await db.addEntity(tx, { entity: e1 });
await db.addEntity(tx, { entity: e2 });
});
const result = await catalog.transaction(async tx =>
catalog.entities(tx, []),
);
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
expect(result).toEqual(
expect.arrayContaining([
@@ -316,7 +293,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters (naive case)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -333,15 +309,15 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
await expect(
catalog.transaction(async tx =>
catalog.entities(tx, [
db.transaction(async tx =>
db.entities(tx, [
{ key: 'kind', values: ['k2'] },
{ key: 'spec.c', values: ['some'] },
]),
@@ -355,7 +331,6 @@ describe('CommonDatabase', () => {
});
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
const catalog = new CommonDatabase(knex, getVoidLogger());
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
{
@@ -372,14 +347,14 @@ describe('CommonDatabase', () => {
},
];
await catalog.transaction(async tx => {
await db.transaction(async tx => {
for (const entity of entities) {
await catalog.addEntity(tx, { entity });
await db.addEntity(tx, { entity });
}
});
const rows = await catalog.transaction(async tx =>
catalog.entities(tx, [
const rows = await db.transaction(async tx =>
db.entities(tx, [
{ key: 'apiVersion', values: ['a'] },
{ key: 'spec.c', values: [null, 'some'] },
]),
@@ -43,7 +43,6 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
delete output.uid;
delete output.etag;
delete output.generation;
return output;
}
@@ -70,7 +69,7 @@ function toEntityRow(
generation: entity.metadata.generation!,
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name || null,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
metadata: serializeMetadata(entity.metadata),
spec: serializeSpec(entity.spec),
@@ -124,6 +123,7 @@ function generateEtag(): string {
export class CommonDatabase implements Database {
constructor(
private readonly database: Knex,
private readonly normalize: (value: string) => string,
private readonly logger: Logger,
) {}
@@ -158,6 +158,8 @@ export class CommonDatabase implements Database {
throw new InputError('May not specify generation for new entities');
}
await this.ensureNoSimilarNames(tx, request.entity);
const newEntity = lodash.cloneDeep(request.entity);
newEntity.metadata = {
...newEntity.metadata,
@@ -255,6 +257,8 @@ export class CommonDatabase implements Database {
}
}
await this.ensureNoSimilarNames(tx, newEntity);
// Store the updated entity; select on the old etag to ensure that we do
// not lose to another writer
const newRow = toEntityRow(request.locationId, newEntity);
@@ -278,23 +282,50 @@ export class CommonDatabase implements Database {
const tx = txOpaque as Knex.Transaction<any, any>;
let builder = tx<DbEntitiesRow>('entities');
for (const [index, filter] of (filters ?? []).entries()) {
for (const [indexU, filter] of (filters ?? []).entries()) {
const index = Number(indexU);
const key = filter.key.replace('*', '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
const matchIn: string[] = [];
const matchLike: string[] = [];
for (const value of filter.values) {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
matchLike.push(value.replace('*', '%'));
} else {
matchIn.push(value);
}
}
builder = builder
.leftOuterJoin(`entities_search as t${index}`, function join() {
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
`t${index}.value`,
filter.values.filter(x => x),
);
if (filter.values.some(x => !x)) {
this.orOnNull(`t${index}.value`);
}
.leftOuterJoin(`entities_search as t${index}`, function joins() {
this.on('entities.id', '=', `t${index}.entity_id`);
this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
})
.where(`t${index}.key`, '=', filter.key);
.where(function rules() {
if (matchIn.length) {
this.orWhereIn(`t${index}.value`, matchIn);
}
if (matchLike.length) {
for (const x of matchLike) {
this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
}
}
if (matchNulls) {
this.orWhereNull(`t${index}.value`);
}
});
}
const rows = await builder
.orderBy('namespace', 'name')
.select('entities.*')
.orderBy('kind', 'asc')
.orderBy('namespace', 'asc')
.orderBy('name', 'asc')
.groupBy('id');
return rows.map(row => toEntityResponse(row));
@@ -359,6 +390,10 @@ export class CommonDatabase implements Database {
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
await tx<DbEntitiesRow>('entities')
.where({ location_id: id })
.update({ location_id: null });
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
@@ -447,4 +482,46 @@ export class CommonDatabase implements Database {
// we got around to writing the entries
}
}
private async ensureNoSimilarNames(
tx: Knex.Transaction<any, any>,
data: Entity,
): Promise<void> {
const newKind = data.kind;
const newName = data.metadata.name;
const newNamespace = data.metadata.namespace;
const newKindNorm = this.normalize(newKind);
const newNameNorm = this.normalize(newName);
const newNamespaceNorm = this.normalize(newNamespace || '');
for (const item of await this.entities(tx)) {
if (data.metadata.uid === item.entity.metadata.uid) {
continue;
}
const oldKind = item.entity.kind;
const oldName = item.entity.metadata.name;
const oldNamespace = item.entity.metadata.namespace;
const oldKindNorm = this.normalize(oldKind);
const oldNameNorm = this.normalize(oldName);
const oldNamespaceNorm = this.normalize(oldNamespace || '');
if (
oldKindNorm === newKindNorm &&
oldNameNorm === newNameNorm &&
oldNamespaceNorm === newNamespaceNorm
) {
// Only throw if things were actually different - for completely equal
// things, we let the database handle the conflict
if (
oldKind !== newKind ||
oldName !== newName ||
oldNamespace !== newNamespace
) {
const message = `Kind, namespace, name are too similar to an existing entity`;
throw new ConflictError(message);
}
}
}
}
}
@@ -14,26 +14,43 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
);
export type CreateDatabaseOptions = {
logger: Logger;
fieldNormalizer: (value: string) => string;
};
const defaultOptions: CreateDatabaseOptions = {
logger: getVoidLogger(),
fieldNormalizer: makeValidator().normalizeEntityName,
};
export class DatabaseManager {
public static async createDatabase(
knex: Knex,
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
await knex.migrate.latest({
directory: path.resolve(__dirname, 'migrations'),
loadExtensions: ['.js'],
directory: migrationsDir,
});
return new CommonDatabase(knex, logger);
const { logger, fieldNormalizer } = { ...defaultOptions, ...options };
return new CommonDatabase(knex, fieldNormalizer, logger);
}
public static async createInMemoryDatabase(
logger: Logger,
options: Partial<CreateDatabaseOptions> = {},
): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
@@ -43,6 +60,22 @@ export class DatabaseManager {
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return DatabaseManager.createDatabase(knex, logger);
return DatabaseManager.createDatabase(knex, options);
}
public static async createTestDatabase(): Promise<Database> {
const knex = Knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
await knex.migrate.latest({
directory: migrationsDir,
});
const { logger, fieldNormalizer } = defaultOptions;
return new CommonDatabase(knex, fieldNormalizer, logger);
}
}
@@ -26,6 +26,7 @@ import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEn
import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor';
import { FileReaderProcessor } from './processors/FileReaderProcessor';
import { GithubReaderProcessor } from './processors/GithubReaderProcessor';
import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import * as result from './processors/results';
import {
LocationProcessor,
@@ -57,6 +58,7 @@ export class LocationReaders implements LocationReader {
new GithubReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
}
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
export class LocationRefProcessor implements LocationProcessor {
async processEntity(
entity: Entity,
_location: LocationSpec,
emit: LocationProcessorEmit,
): Promise<Entity> {
if (entity.kind === 'Location') {
const location = entity as LocationEntity;
if (location.spec.target) {
emit(
result.location(
{ type: location.spec.type, target: location.spec.target },
false,
),
);
}
if (location.spec.targets) {
for (const target of location.spec.targets) {
emit(result.location({ type: location.spec.type, target }, false));
}
}
}
return entity;
}
}
@@ -110,7 +110,7 @@ export async function createRouter(
.delete('/locations/:id', async (req, res) => {
const { id } = req.params;
await locationsCatalog.removeLocation(id);
res.status(200).send();
res.status(204).send();
});
}
@@ -36,7 +36,7 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const db = await DatabaseManager.createInMemoryDatabase({ logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders();
+1
View File
@@ -9,6 +9,7 @@
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"allowJs": true,
"lib": ["es2019"],
"types": ["node", "jest"]
}

Some files were not shown because too many files have changed in this diff Show More