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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -65,4 +65,7 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
main().catch(error => {
|
||||
console.error(`Backend failed to start up, ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
+5
-16
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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/**'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export type BundlingOptions = {
|
||||
isDev: boolean;
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
baseUrl: URL;
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { loadConfig } from './loader';
|
||||
export type { LoadConfigOptions } from './types';
|
||||
export type { LoadConfigOptions } from './loader';
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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<{}>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
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();
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
-60
@@ -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',
|
||||
+73
-75
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+11
-5
@@ -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
-9
@@ -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');
|
||||
}
|
||||
};
|
||||
+12
-5
@@ -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;`);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user