diff --git a/.github/workflows/cli-win.yml b/.github/workflows/cli-win.yml index 52ec1b8b27..00c7c26a6c 100644 --- a/.github/workflows/cli-win.yml +++ b/.github/workflows/cli-win.yml @@ -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 diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 62f91e3259..71b207c0d3 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43c1f0d7b8..dca9e678dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..3ec51b4210 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/docker/run.sh b/docker/run.sh index ac120f6597..c6800cd0d8 100755 --- a/docker/run.sh +++ b/docker/run.sh @@ -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 diff --git a/docs/README.md b/docs/README.md index 89160ce9e5..86ca88b75c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,10 @@ # Documentation -Check out or see the table of content below. +Check out 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) diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index eec3aec78b..32830c9bb3 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.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) diff --git a/docs/getting-started/create-a-plugin.md b/docs/getting-started/create-a-plugin.md index 778623f07a..93c8a4c760 100644 --- a/docs/getting-started/create-a-plugin.md +++ b/docs/getting-started/create-a-plugin.md @@ -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 diff --git a/docs/getting-started/structure-of-a-plugin.md b/docs/getting-started/structure-of-a-plugin.md index 591ffc27d2..fcfb2c0d46 100644 --- a/docs/getting-started/structure-of-a-plugin.md +++ b/docs/getting-started/structure-of-a-plugin.md @@ -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. diff --git a/docs/journey.md b/docs/journey.md new file mode 100644 index 0000000000..e38130afb7 --- /dev/null +++ b/docs/journey.md @@ -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({ + 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; +}; +``` + +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. + +
+ + +# 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. + +
diff --git a/lerna.json b/lerna.json index 2feea0137f..aa535f7aeb 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.7" + "version": "0.1.1-alpha.8" } diff --git a/packages/app/package.json b/packages/app/package.json index 6e314c3fe0..fca15815e4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,28 +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-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" }, @@ -34,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", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index d348049157..ed0518485a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -18,7 +18,7 @@ import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; import * as plugins from './plugins'; -import apis from './apis'; +import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; const app = createApp({ diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index ec0088e219..9bfc618216 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -15,11 +15,11 @@ */ import { - ApiHolder, ApiRegistry, alertApiRef, errorApiRef, AlertApiForwarder, + ConfigApi, ErrorApiForwarder, ErrorAlerter, featureFlagsApiRef, @@ -44,57 +44,66 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar'; import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci'; import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; -const builder = ApiRegistry.builder(); +import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles'; -const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); -const errorApi = builder.add( - errorApiRef, - new ErrorAlerter(alertApi, new ErrorApiForwarder()), -); +export const apis = (config: ConfigApi) => { + // eslint-disable-next-line no-console + console.log(`Creating APIs for ${config.getString('app.title')}`); -builder.add(storageApiRef, WebStorage.create({ errorApi })); -builder.add(circleCIApiRef, new CircleCIApi()); -builder.add(featureFlagsApiRef, new FeatureFlags()); + const builder = ApiRegistry.builder(); -builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); + const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); + const errorApi = builder.add( + errorApiRef, + new ErrorAlerter(alertApi, new ErrorApiForwarder()), + ); -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); + builder.add(storageApiRef, WebStorage.create({ errorApi })); + builder.add(circleCIApiRef, new CircleCIApi()); + builder.add(featureFlagsApiRef, new FeatureFlags()); -builder.add( - googleAuthApiRef, - GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); + builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); -builder.add( - githubAuthApiRef, - GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); + const oauthRequestApi = builder.add( + oauthRequestApiRef, + new OAuthRequestManager(), + ); -builder.add( - techRadarApiRef, - new TechRadar({ - width: 1500, - height: 800, - }), -); + builder.add( + googleAuthApiRef, + GoogleAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); -builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: 'http://localhost:3000', - basePath: '/catalog/api', - }), -); + builder.add( + githubAuthApiRef, + GithubAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); -export default builder.build() as ApiHolder; + builder.add( + techRadarApiRef, + new TechRadar({ + width: 1500, + height: 800, + }), + ); + + builder.add( + catalogApiRef, + new CatalogClient({ + apiOrigin: 'http://localhost:3000', + basePath: '/catalog/api', + }), + ); + + builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')); + + return builder.build(); +}; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 0e30c15254..8b1ce76cbd 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -19,6 +19,9 @@ import PropTypes from 'prop-types'; import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; import ExploreIcon from '@material-ui/icons/Explore'; +import BuildIcon from '@material-ui/icons/BuildRounded'; +import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; +import MapIcon from '@material-ui/icons/MyLocation'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; @@ -88,6 +91,9 @@ const Root: FC<{}> = ({ children }) => ( {/* End global nav */} + + + diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 25a5bcfc9d..2cf2bde9d5 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; export { plugin as Sentry } from '@backstage/plugin-sentry'; +export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index b7d5cf7c11..97e9626b22 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "main": "dist", "types": "src/index.ts", "private": false, @@ -37,7 +37,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", diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 48f266aec8..07ef857d50 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import winston from 'winston'; +import * as winston from 'winston'; import { getRootLogger, setRootLogger } from './rootLogger'; describe('rootLogger', () => { diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 8058e22947..11db57c1ed 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -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; } diff --git a/packages/backend-common/src/logging/voidLogger.ts b/packages/backend-common/src/logging/voidLogger.ts index 1837b0e412..762b3c0dcb 100644 --- a/packages/backend-common/src/logging/voidLogger.ts +++ b/packages/backend-common/src/logging/voidLogger.ts @@ -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() })], }); diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.test.ts b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts index 11f24c276a..6aed54540f 100644 --- a/packages/backend-common/src/middleware/requestLoggingHandler.test.ts +++ b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts @@ -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', () => { diff --git a/packages/backend/README.md b/packages/backend/README.md index 48dd7f09fe..64078006e7 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location to the backend. These are places where the backend can find some entity descriptor data to consume and serve. -To get started, you can issue the following after starting the backend: +To get started, you can issue the following after starting the backend, from inside +the `plugins/catalog-backend` directory: ```bash -curl -i \ - -H "Content-Type: application/json" \ - -d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \ - localhost:7000/catalog/locations +yarn mock-catalog-data ``` -After a short while, you should start seeing data on `localhost:7000/catalog/entities`. - -If you changed the `type` to `file` in the command above, and set the `target` -to the absolute path of a YAML file on disk, you could consume your own experimental data. +You should then start seeing data on `localhost:7000/catalog/entities`. The catalog currently runs in-memory only, so feel free to try it out, but it will need to be re-populated on next startup. diff --git a/packages/backend/package.json b/packages/backend/package.json index 3a97ab71ee..9079c3d503 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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, @@ -17,13 +17,13 @@ "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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 09e244545b..0a9d852222 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -74,6 +74,8 @@ async function main() { }); } -main(); - module.hot?.accept(); +main().catch(error => { + console.error(`Backend failed to start up, ${error}`); + process.exit(1); +}); diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index a9e4fa8ce6..6f82836a79 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -32,7 +32,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( diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index d2a63560e3..170fb36281 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -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", diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index ca189c042e..cf23bcdc88 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -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(), + ]), ]); } diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts index d64053f7cb..eb7c0e8378 100644 --- a/packages/catalog-model/src/entity/policies/index.ts +++ b/packages/catalog-model/src/entity/policies/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy'; export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy'; export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy'; diff --git a/packages/catalog-model/src/kinds/ComponentV1beta1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts similarity index 70% rename from packages/catalog-model/src/kinds/ComponentV1beta1.ts rename to packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts index b041bf7967..2f5e8e079c 100644 --- a/packages/catalog-model/src/kinds/ComponentV1beta1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1beta1.ts @@ -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; constructor() { - this.schema = yup.object>({ - metadata: yup - .object({ - name: yup.string().required(), - }) - .required(), + this.schema = yup.object>({ spec: yup .object({ type: yup.string().required(), @@ -51,10 +43,7 @@ export class ComponentV1beta1Policy implements EntityPolicy { } async enforce(envelope: Entity): Promise { - if ( - envelope.apiVersion !== 'backstage.io/v1beta1' || - envelope.kind !== 'Component' - ) { + if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) { throw new Error('Unsupported apiVersion / kind'); } diff --git a/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts b/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts new file mode 100644 index 0000000000..e68bb00aa0 --- /dev/null +++ b/packages/catalog-model/src/kinds/LocationEntityV1beta1.ts @@ -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; + + constructor() { + this.schema = yup.object>({ + spec: yup + .object({ + type: yup.string().required(), + target: yup.string().notRequired(), + targets: yup.array(yup.string()).notRequired(), + }) + .required(), + }); + } + + async enforce(envelope: Entity): Promise { + if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) { + throw new Error('Unsupported apiVersion / kind'); + } + + return await this.schema.validate(envelope, { strict: true }); + } +} diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index ed79fed61d..2d2a0744c5 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -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'; diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 4f45e232b2..86db4b2e6a 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -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: [ { diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 9c461a1b24..7435b79264 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -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/' instead.", }, ...require('module').builtinModules, ], + // Avoid cross-package imports + patterns: ['**/../../**/*/src/**'], }, ], }, diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 3f37a0df4c..4567d8c593 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -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); } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 0dbf9c2f71..785e059219 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "private": false, "publishConfig": { "access": "public" @@ -69,7 +69,7 @@ "replace-in-file": "^6.0.0", "rollup": "2.10.x", "rollup-plugin-dts": "^1.4.6", - "rollup-plugin-esbuild": "^1.4.1", + "rollup-plugin-esbuild": "^2.0.0", "rollup-plugin-image-files": "^1.4.2", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^3.1.1", diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index a088202fa8..e5343d3ccc 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -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'; @@ -41,6 +41,7 @@ export async function buildBundle(options: BuildOptions) { checksEnabled: false, isDev: false, isBackend: false, + baseUrl: resolveBaseUrl(options.config), }); const compiler = webpack(config); diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 69685a9ab7..23098318ec 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -21,6 +21,7 @@ import StartServerPlugin from 'start-server-webpack-plugin'; import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; import { optimization } from './optimization'; +import { Config } from '@backstage/config'; import { BundlingPaths } from './paths'; import { transforms } from './transforms'; import { BundlingOptions } from './types'; @@ -30,6 +31,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, diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index a004bee1a9..ddc6ee88fa 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -15,25 +15,17 @@ */ 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; @@ -42,6 +34,7 @@ export async function serveBundle(options: ServeOptions) { ...options, isDev: true, isBackend: false, + baseUrl: url, }); const compiler = webpack(config); @@ -53,33 +46,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; - const urls = latestPrepareUrls( - protocol, - host, - port, - config.output?.publicPath, - ); - openBrowser(urls.localUrlForBrowser); + openBrowser(url.href); resolve(); }); }); diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 62737374bd..076faf8151 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -23,6 +23,7 @@ export type BundlingOptions = { config: Config; appConfigs: AppConfig[]; isBackend: boolean; + baseUrl: URL; }; export type ServeOptions = BundlingPathsOptions & { diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts index 9d60fa09f4..30680ef3b9 100644 --- a/packages/cli/src/lib/packager/config.ts +++ b/packages/cli/src/lib/packager/config.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import chalk from 'chalk'; import fs from 'fs-extra'; import { relative as relativePath } from 'path'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; @@ -42,7 +43,9 @@ export const makeConfigs = async ( if (!declarationsExist) { const path = relativePath(paths.targetDir, typesInput); throw new Error( - `No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`, + `No declaration files found at ${path}, be sure to run ${chalk.bgRed.white( + 'yarn tsc', + )} to generate .d.ts files before packaging`, ); } @@ -50,6 +53,7 @@ export const makeConfigs = async ( if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) { const output = new Array(); + const mainFields = ['module', 'main']; if (options.outputs.has(Output.cjs)) { output.push({ @@ -66,6 +70,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 +83,7 @@ export const makeConfigs = async ( peerDepsExternal({ includeDependencies: true, }), - resolve({ - mainFields: ['browser', 'module', 'main'], - }), + resolve({ mainFields }), commonjs({ include: ['node_modules/**', '../../node_modules/**'], exclude: ['**/*.stories.*', '**/*.test.*'], diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index 2c35dee754..346e09a76a 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -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", diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs index 0bc8fd3d2f..c590b68324 100644 --- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs +++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs @@ -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}}", diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 7018dc40a8..de6b26363a 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -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}" diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 822fb53d04..6738611a17 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -15,4 +15,4 @@ */ export { loadConfig } from './loader'; -export type { LoadConfigOptions } from './types'; +export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/lib/env.test.ts similarity index 98% rename from packages/config-loader/src/loader.test.ts rename to packages/config-loader/src/lib/env.test.ts index 4c7a120c63..6a0a115b24 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/lib/env.test.ts @@ -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', () => { diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts new file mode 100644 index 0000000000..83f86d4212 --- /dev/null +++ b/packages/config-loader/src/lib/env.ts @@ -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] : []; +} diff --git a/plugins/catalog/src/data/component.ts b/packages/config-loader/src/lib/index.ts similarity index 72% rename from plugins/catalog/src/data/component.ts rename to packages/config-loader/src/lib/index.ts index 68be7588f0..226932784b 100644 --- a/plugins/catalog/src/data/component.ts +++ b/packages/config-loader/src/lib/index.ts @@ -13,13 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityMeta } from '@backstage/catalog-model'; -import { ReactNode } from 'react'; -export type Component = { - name: string; - namespace?: string; - kind: string; - metadata: EntityMeta; - description: ReactNode; -}; +export { resolveStaticConfig } from './resolver'; +export { readConfigFile } from './reader'; +export { readEnv } from './env'; +export { readSecret } from './secrets'; diff --git a/packages/config-loader/src/paths.test.ts b/packages/config-loader/src/lib/paths.test.ts similarity index 100% rename from packages/config-loader/src/paths.test.ts rename to packages/config-loader/src/lib/paths.test.ts diff --git a/packages/config-loader/src/paths.ts b/packages/config-loader/src/lib/paths.ts similarity index 100% rename from packages/config-loader/src/paths.ts rename to packages/config-loader/src/lib/paths.ts diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts new file mode 100644 index 0000000000..b90c53ee6b --- /dev/null +++ b/packages/config-loader/src/lib/reader.test.ts @@ -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'); + }); +}); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts new file mode 100644 index 0000000000..044bf68288 --- /dev/null +++ b/packages/config-loader/src/lib/reader.ts @@ -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 { + if (typeof obj !== 'object') { + return obj; + } else if (obj === null) { + return undefined; + } else if (Array.isArray(obj)) { + const arr = new Array(); + + 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; +} diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts new file mode 100644 index 0000000000..4a06f3ebca --- /dev/null +++ b/packages/config-loader/src/lib/resolver.ts @@ -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 { + // 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]; +} diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts new file mode 100644 index 0000000000..e7fe997a58 --- /dev/null +++ b/packages/config-loader/src/lib/secrets.test.ts @@ -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', + ); + }); +}); diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts new file mode 100644 index 0000000000..c4157120ae --- /dev/null +++ b/packages/config-loader/src/lib/secrets.ts @@ -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(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; +} = { + '.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 { + 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(); + throw new Error('Secret was left unhandled'); +} diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts new file mode 100644 index 0000000000..c0f8b99dcf --- /dev/null +++ b/packages/config-loader/src/lib/types.ts @@ -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; +export type ReadSecretFunc = (desc: JsonObject) => Promise; + +/** + * Common context that provides all the necessary hooks for reading configuration files. + */ +export type ReaderContext = { + env: { [name in string]?: string }; + readFile: ReadFileFunc; + readSecret: ReadSecretFunc; +}; diff --git a/packages/config-loader/src/lib/utils.ts b/packages/config-loader/src/lib/utils.ts new file mode 100644 index 0000000000..37145ec971 --- /dev/null +++ b/packages/config-loader/src/lib/utils.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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() { + return void 0 as T; +} diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index c8b5bb538d..1b6c8f34da 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -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 { - // 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 { + 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 { + 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; } diff --git a/packages/core-api/package.json b/packages/core-api/package.json index f08b03a77e..80a94b2e34 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.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", diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts new file mode 100644 index 0000000000..bbfab0ef35 --- /dev/null +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -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 - We want this to be async when added, but needs more work. +}; + +export const identifyApiRef = createApiRef({ + id: 'core.identity', + description: 'Provides access to the identity of the signed in user', +}); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 4cbc8bcfb1..7abab45780 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '../ApiRef'; +import { Observable } from '../..'; /** * This file contains declarations for common interfaces of auth-related APIs. @@ -167,6 +168,14 @@ export type ProfileInfo = { picture?: string; }; +export enum SessionState { + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +export type SessionStateApi = { + sessionState$(): Observable; +}; /** * Provides authentication towards Google APIs and identities. * @@ -176,7 +185,7 @@ export type ProfileInfo = { * email and expiration information. Do not rely on any other fields, as they might not be present. */ export const googleAuthApiRef = createApiRef< - OAuthApi & OpenIdConnectApi & ProfileInfoApi + OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi >({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', @@ -188,7 +197,7 @@ export const googleAuthApiRef = createApiRef< * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ * for a full list of supported scopes. */ -export const githubAuthApiRef = createApiRef({ +export const githubAuthApiRef = createApiRef({ id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 41fbc839f9..c5d4a15117 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -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'; diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index d75bceef97..f4a7092f79 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -17,10 +17,17 @@ import GithubIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; -import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth'; +import { + OAuthApi, + AccessTokenOptions, + SessionStateApi, + SessionState, +} from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth @@ -46,11 +53,11 @@ const DEFAULT_PROVIDER = { icon: GithubIcon, }; -class GithubAuth implements OAuthApi { +class GithubAuth implements OAuthApi, SessionStateApi { static create({ apiOrigin, basePath, - environment = 'dev', + environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, }: CreateOptions) { @@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi { return new GithubAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken(scope?: string, options?: AccessTokenOptions) { @@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } static normalizeScope(scope?: string): Set { diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index d65a2c71ee..183ea592b4 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -25,10 +25,14 @@ import { ProfileInfoApi, ProfileInfoOptions, ProfileInfo, + SessionStateApi, + SessionState, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth @@ -57,11 +61,12 @@ const DEFAULT_PROVIDER = { const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { +class GoogleAuth + implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { static create({ apiOrigin, basePath, - environment = 'dev', + environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, }: CreateOptions) { @@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { return new GoogleAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken( @@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async getIdToken(options: IdTokenOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.idToken; } @@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } async getProfile(options: ProfileInfoOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (!session) { return undefined; } diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 7cc37c2544..d7920e6bbd 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,11 +13,10 @@ * 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 } from './types'; +import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; @@ -38,7 +37,7 @@ import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; type FullAppOptions = { - apis: ApiHolder; + apis: Apis; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; @@ -47,15 +46,17 @@ type FullAppOptions = { }; export class PrivateAppImpl implements BackstageApp { - private readonly apis: ApiHolder; + private apis?: ApiHolder = undefined; private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private apisOrFactory: Apis; + constructor(options: FullAppOptions) { - this.apis = options.apis; + this.apisOrFactory = options.apis; this.icons = options.icons; this.plugins = options.plugins; this.components = options.components; @@ -64,6 +65,9 @@ export class PrivateAppImpl implements BackstageApp { } getApis(): ApiHolder { + if (!this.apis) { + throw new Error('Tried to access APIs before app was loaded'); + } return this.apis; } @@ -85,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( - , + } />, ); break; } case 'route': { - const { target, component, options = {} } = output; - const { exact = true } = options; + const { target, component: Component } = output; routes.push( } />, ); break; } case 'legacy-redirect-route': { - const { path, target, options = {} } = output; - const { exact = true } = options; - routes.push( - , - ); + const { path, target } = output; + routes.push(); break; } case 'redirect-route': { - const { from, to, options = {} } = output; - const { exact = true } = options; - routes.push( - , - ); + const { from, to } = output; + routes.push(); break; } case 'feature-flag': { @@ -150,10 +135,10 @@ export class PrivateAppImpl implements BackstageApp { } const rendered = ( - + {routes} - - + } /> + ); return () => rendered; @@ -196,6 +181,15 @@ export class PrivateAppImpl implements BackstageApp { [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], [configApiRef, configReader], ]); + + if (!this.apis) { + if ('get' in this.apisOrFactory) { + this.apis = this.apisOrFactory; + } else { + this.apis = this.apisOrFactory(configReader); + } + } + const apis = new ApiAggregator(this.apis, appApis); const { Router } = this.components; @@ -211,7 +205,7 @@ export class PrivateAppImpl implements BackstageApp { - {children} + {children} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 664c5238bc..e30c79dd73 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -18,19 +18,18 @@ import { ComponentType } from 'react'; import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; -import { AppTheme } from '../apis/definitions'; +import { AppTheme, ConfigApi } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; export type BootErrorPageProps = { step: 'load-config'; error: Error; }; - export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; - Router: ComponentType<{ basename?: string }>; + Router: ComponentType<{}>; }; /** @@ -41,13 +40,16 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; +// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently +export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder); + export type AppOptions = { /** * A holder of all APIs available in the app. * * Use for example ApiRegistry or ApiTestRegistry. */ - apis?: ApiHolder; + apis?: Apis; /** * Supply icons to override the default ones. diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index ee7cbab2ae..793c6f1708 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -131,15 +131,6 @@ describe('RefreshingAuthSessionManager', () => { }); it('should remove session and reload', async () => { - // This is a workaround that is used by Facebook and the Jest core team - // It is a limitation with the newest versions of JSDOM, and newer browser standards - // where window.location and all of its properties are read-only. So we re-construct it! - // See https://github.com/facebook/jest/issues/890#issuecomment-209698782 - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new RefreshingAuthSessionManager({ connector: { removeSession }, @@ -147,7 +138,7 @@ describe('RefreshingAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 3df21af3f3..f7d5bcf7ca 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } async getCurrentSession() { diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..de308acb0c --- /dev/null +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -0,0 +1,32 @@ +/* + * 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 { BehaviorSubject } from '..'; +import { SessionState } from '../../apis'; + +export class SessionStateTracker { + private signedIn: boolean = false; + observable = new BehaviorSubject(SessionState.SignedOut); + + setIsSignedId(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.observable.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } +} diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts index 7d47fa91df..6280750875 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -84,11 +84,6 @@ describe('StaticAuthSessionManager', () => { }); it('should remove session and reload', async () => { - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new StaticAuthSessionManager({ connector: { removeSession }, @@ -96,7 +91,7 @@ describe('StaticAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index 6e6db47a99..5ecbbc0c4e 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -64,7 +64,7 @@ export class StaticAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } } diff --git a/packages/core/package.json b/packages/core/package.json index 8a476a58d5..a412af3fa1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.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", diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx index f7bbe45abd..aaca389f82 100644 --- a/packages/core/src/components/Button/Button.stories.tsx +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -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 ( <> -  has props for both material-ui's component as well as for diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.jsx index 2563d367c2..115835be3f 100644 --- a/packages/core/src/components/Button/Button.test.jsx +++ b/packages/core/src/components/Button/Button.test.jsx @@ -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(' - {testString}{' '} - , + , ), ); + expect(() => getByText(testString)).toThrow(); await act(async () => fireEvent.click(getByText(buttonLabel))); expect(getByText(testString)).toBeInTheDocument(); diff --git a/packages/core/src/components/Link/Link.test.jsx b/packages/core/src/components/Link/Link.test.jsx index fcbc92a67a..4bb78ef6d4 100644 --- a/packages/core/src/components/Link/Link.test.jsx +++ b/packages/core/src/components/Link/Link.test.jsx @@ -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('', () => { @@ -27,10 +27,10 @@ describe('', () => { const linkText = 'Navigate!'; const { getByText } = render( wrapInTestApp( - + {linkText} - {testString} - , + {testString}

} /> + , ), ); expect(() => getByText(testString)).toThrow(); diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx index 859650e6b0..8e7e3f179e 100644 --- a/packages/core/src/components/Link/Link.tsx +++ b/packages/core/src/components/Link/Link.tsx @@ -19,7 +19,7 @@ import { Link as MaterialLink } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; type Props = ComponentProps & - ComponentProps; + ComponentProps & { component?: React.FC }; /** * Thin wrapper on top of material-ui's Link component diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx index b94c7409fc..d6620295c1 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx @@ -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(theme => ({ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => { const classes = useStyles(); - const history = useHistory(); + const navigate = useNavigate(); return ( @@ -53,7 +53,7 @@ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => { Looks like someone dropped the mic! - + navigate(-1)}> Go back ... or if you think this is a bug, please file an{' '} diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 0a875c9035..5c2909f82e 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -58,8 +58,11 @@ const useStyles = makeStyles(theme => { // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs fontWeight: 'bold', whiteSpace: 'nowrap', - lineHeight: 1.0, + lineHeight: 'auto', flex: '3 1 auto', + width: '110px', + overflow: 'hidden', + 'text-overflow': 'ellipsis', }, iconContainer: { boxSizing: 'border-box', @@ -118,7 +121,8 @@ export const SidebarItem: FC = ({ icon: Icon, text, to = '#', - disableSelected = false, + // TODO: isActive is not in v6 + // disableSelected = false, hasNotifications = false, onClick, children, @@ -145,8 +149,7 @@ export const SidebarItem: FC = ({ Boolean(match && !disableSelected)} - exact + end to={to} onClick={onClick} > @@ -158,8 +161,7 @@ export const SidebarItem: FC = ({ Boolean(match && !disableSelected)} - exact + end to={to} onClick={onClick} > diff --git a/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx b/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx new file mode 100644 index 0000000000..b81ba2aaf7 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx @@ -0,0 +1,73 @@ +/* + * 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 { + ApiRef, + OAuthApi, + SessionStateApi, + useApi, + Subscription, + IconComponent, + SessionState, +} from '@backstage/core-api'; +import React, { FC, useState, useEffect } from 'react'; +import { ProviderSettingsItem } from './ProviderSettingsItem'; + +type OAuthProviderSidebarProps = { + title: string; + icon: IconComponent; + apiRef: ApiRef; +}; + +export const OAuthProviderSettings: FC = ({ + title, + icon, + apiRef, +}) => { + const api = useApi(apiRef); + const [signedIn, setSignedIn] = useState(false); + + useEffect(() => { + const checkSession = async () => { + const session = await api.getAccessToken('', { optional: true }); + setSignedIn(!!session); + }; + let subscription: Subscription; + const observeSession = () => { + subscription = api + .sessionState$() + .subscribe((sessionState: SessionState) => { + setSignedIn(sessionState === SessionState.SignedIn); + }); + }; + + checkSession(); + observeSession(); + return () => { + subscription.unsubscribe(); + }; + }, [api]); + + return ( + api.getAccessToken()} + /> + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx b/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx new file mode 100644 index 0000000000..139d79213f --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + ApiRef, + OpenIdConnectApi, + SessionStateApi, + useApi, + Subscription, + IconComponent, + SessionState, +} from '@backstage/core-api'; +import React, { FC, useState, useEffect } from 'react'; +import { ProviderSettingsItem } from './ProviderSettingsItem'; + +export type OIDCProviderSidebarProps = { + title: string; + icon: IconComponent; + apiRef: ApiRef; +}; + +export const OIDCProviderSettings: FC = ({ + title, + icon, + apiRef, +}) => { + const api = useApi(apiRef); + const [signedIn, setSignedIn] = useState(false); + + useEffect(() => { + const checkSession = async () => { + const session = await api.getIdToken({ optional: true }); + setSignedIn(!!session); + }; + + let subscription: Subscription; + const observeSession = () => { + subscription = api + .sessionState$() + .subscribe((sessionState: SessionState) => { + setSignedIn(sessionState === SessionState.SignedIn); + }); + }; + + checkSession(); + observeSession(); + return () => { + subscription.unsubscribe(); + }; + }, [api]); + + return ( + api.getIdToken()} + /> + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx new file mode 100644 index 0000000000..8fbc945cc2 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx @@ -0,0 +1,49 @@ +/* + * 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 React, { FC } from 'react'; +import { OAuthApi, OpenIdConnectApi, IconComponent } from '@backstage/core-api'; +import { SidebarItem } from '../Items'; +import { IconButton, Tooltip } from '@material-ui/core'; +import StarBorder from '@material-ui/icons/StarBorder'; +import PowerButton from '@material-ui/icons/PowerSettingsNew'; + +export const ProviderSettingsItem: FC<{ + title: string; + icon: IconComponent; + signedIn: boolean; + api: OAuthApi | OpenIdConnectApi; + signInHandler: Function; +}> = ({ title, icon, signedIn, api, signInHandler }) => { + return ( + + (signedIn ? api.logout() : signInHandler())}> + + + + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx b/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx new file mode 100644 index 0000000000..ebf1d0877d --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx @@ -0,0 +1,112 @@ +/* + * 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 React, { FC, useState, useRef, useEffect } from 'react'; +import { makeStyles, Avatar, Divider } from '@material-ui/core'; +import { + ProfileInfo, + useApi, + googleAuthApiRef, + Subscription, + SessionState, +} from '@backstage/core-api'; +import { SidebarItem } from '../Items'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import AccountCircleIcon from '@material-ui/icons/AccountCircle'; + +const useStyles = makeStyles({ + avatar: { + width: 24, + height: 24, + }, +}); + +export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({ + open, + setOpen, +}) => { + const [profile, setProfile] = useState(); + const ref = useRef(); // for scrolling down when collapse item opens + const googleAuth = useApi(googleAuthApiRef); + const classes = useStyles(); + + const handleClick = () => { + setOpen(!open); + setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300); + }; + + useEffect(() => { + const fetchProfile = async () => { + await googleAuth + .getProfile({ optional: true }) + .then((userProfile?: ProfileInfo) => { + setProfile(userProfile); + }); + }; + + let subscription: Subscription; + const observeSession = () => { + subscription = googleAuth + .sessionState$() + .subscribe(async (sessionState: SessionState) => { + if (sessionState === SessionState.SignedIn) { + await fetchProfile(); + } else { + setProfile(undefined); + } + }); + }; + + fetchProfile(); + observeSession(); + return () => { + subscription.unsubscribe(); + }; + }, [googleAuth]); + + // Handle main auth info that is shown on the collapsible SidebarItem + let avatar; + let displayName = 'Guest'; + if (profile) { + const email = profile.email; + const name = profile.name; + const imageUrl = profile.picture; + const emailTrimmed = email.split('@')[0]; + const displayEmail = + emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1); + displayName = name ?? displayEmail; + avatar = imageUrl + ? () => ( + + ) + : () => ; + } + + return ( + <> + + + {open ? : } + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/index.ts b/packages/core/src/layout/Sidebar/Settings/index.ts new file mode 100644 index 0000000000..6557ace53a --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { ProviderSettingsItem } from './ProviderSettingsItem'; +export { OAuthProviderSettings } from './OAuthProviderSettings'; +export { OIDCProviderSettings } from './OIDCProviderSettings'; +export { UserProfile } from './UserProfile'; diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx index a450a09d88..906317fe9c 100644 --- a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx @@ -22,6 +22,7 @@ import { SidebarDivider, SidebarSearchField, SidebarSpace, + SidebarUserSettings, } from '.'; import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; @@ -54,5 +55,6 @@ export const SampleSidebar = () => ( + ); diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 5179356cb8..8ca2580df9 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -14,174 +14,40 @@ * limitations under the License. */ -import React, { useState, useContext, useEffect, useRef } from 'react'; +import React, { useContext, useEffect } from 'react'; import Collapse from '@material-ui/core/Collapse'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; -import StarBorder from '@material-ui/icons/StarBorder'; import Star from '@material-ui/icons/Star'; import { SidebarContext } from './config'; -import { SidebarItem } from './Items'; -import AccountCircleIcon from '@material-ui/icons/AccountCircle'; -import Divider from '@material-ui/core/Divider'; +import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api'; import { - useApi, - googleAuthApiRef, - githubAuthApiRef, - ProfileInfo, -} from '@backstage/core-api'; -import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core'; -import PowerButton from '@material-ui/icons/PowerSettingsNew'; - -type Provider = { - title: string; - api: any; - identity?: boolean; - isSignedIn: boolean; - icon: any; -}; - -const useProviders = () => { - const googleAuth = useApi(googleAuthApiRef); - const githubAuth = useApi(githubAuthApiRef); - const [providers, setProviders] = useState([ - { - title: 'Google', - api: googleAuth, - identity: true, - isSignedIn: false, - icon: Star, - }, - { - title: 'Github', - api: githubAuth, - isSignedIn: false, - icon: StarBorder, - }, - ]); - - const setIsSignedIn = async () => { - const signInChecks = await Promise.all( - providers.map(provider => - provider.identity - ? provider.api.getIdToken({ optional: true }) - : provider.api.getAccessToken('', { optional: true }), - ), - ); - - signInChecks.map((result, i) => { - providers[i].isSignedIn = !!result; - }); - - setProviders(providers); - }; - - setIsSignedIn(); - - return providers; -}; - -const useStyles = makeStyles({ - avatar: { - width: 24, - height: 24, - }, -}); + OAuthProviderSettings, + OIDCProviderSettings, + UserProfile as SidebarUserProfile, +} from './Settings'; export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); const [open, setOpen] = React.useState(false); - const ref = useRef(); // for scrolling down when collapse item opens - const providers = useProviders(); - const [profile, setProfile] = useState(); - const classes = useStyles(); - - // TODO(soapraj): List all the providers supported by the app and let user log in from here - // TODO(soapraj): How to observe if the user is logged in - useEffect(() => { - const identityProvider = providers.find( - (provider: Provider) => provider.identity, - ); - identityProvider?.api - .getProfile({ optional: true }) - .then((userProfile: ProfileInfo) => { - setProfile(userProfile); - }); - }, [providers, open]); - - const handleClick = () => { - setOpen(!open); - setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300); - }; // Close the provider list when sidebar collapse useEffect(() => { if (!sidebarOpen && open) setOpen(false); }, [open, sidebarOpen]); - // Handle main auth info that is shown on the collapsible SidebarItem - let avatar; - let displayName; - if (profile) { - const email = profile.email; - const name = profile.name; - const imageUrl = profile.picture; - const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1); - const emailTrimmed = email.split('@')[0]; - const displayEmail = - emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1); - displayName = name ?? displayEmail; - avatar = imageUrl - ? () => - : () => ( - - {avatarFallback[0]} - - ); - } - return ( <> - - - {open ? : } - - - {providers.map((provider: Provider) => ( - - - provider.isSignedIn - ? provider.api.logout() - : provider.api.getAccessToken() - } - > - - - - - - ))} + + + + ); diff --git a/packages/core/src/layout/Sidebar/index.ts b/packages/core/src/layout/Sidebar/index.ts index ec7e532333..355ead14e0 100644 --- a/packages/core/src/layout/Sidebar/index.ts +++ b/packages/core/src/layout/Sidebar/index.ts @@ -34,3 +34,4 @@ export { export type { SidebarContextType } from './config'; export { SidebarThemeToggle } from './SidebarThemeToggle'; export { SidebarUserSettings } from './UserSettings'; +export * from './Settings'; diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 01753db4d8..5e6d26111d 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -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", diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index cacc5af354..d3150400cf 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -5,10 +5,12 @@ import { oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, + githubAuthApiRef, AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, GoogleAuth, + GithubAuth, } from '@backstage/core'; const builder = ApiRegistry.builder(); @@ -31,4 +33,13 @@ builder.add( }), ); +builder.add( + githubAuthApiRef, + GithubAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + export const apis = builder.build(); diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 132bf29c72..c02557910b 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -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", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f48fe65c30..a82ec779b8 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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", diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 34a20be63b..19c02c2158 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -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 1 - Route 2 - , + + Route 1

} /> + Route 2

} /> +
, { 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(); }); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 6cd3672558..c474678fd1 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -92,7 +92,9 @@ export function wrapInTestApp( return ( - + {/* 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 */} + } /> ); } diff --git a/packages/theme/package.json b/packages/theme/package.json index 8b017d4cf9..95a7605c05 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -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}" diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index f45a1cbf7a..9ee11eca52 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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", diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts new file mode 100644 index 0000000000..4fb80ca3e8 --- /dev/null +++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts @@ -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 { + const provider = this.getProviderForEnv(req); + provider.start(req, res); + } + + async frameHandler( + req: express.Request, + res: express.Response, + ): Promise { + const provider = this.getProviderForEnv(req); + provider.frameHandler(req, res); + } + + async refresh(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req); + if (provider.refresh) { + provider.refresh(req, res); + } + } + + async logout(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req); + provider.logout(req, res); + } +} diff --git a/plugins/auth-backend/src/providers/OAuthProvider.test.ts b/plugins/auth-backend/src/lib/OAuthProvider.test.ts similarity index 79% rename from plugins/auth-backend/src/providers/OAuthProvider.test.ts rename to plugins/auth-backend/src/lib/OAuthProvider.test.ts index 362653b5f7..bb261d1380 100644 --- a/plugins/auth-backend/src/providers/OAuthProvider.test.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.test.ts @@ -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', diff --git a/plugins/auth-backend/src/providers/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts similarity index 64% rename from plugins/auth-backend/src/providers/OAuthProvider.ts rename to plugins/auth-backend/src/lib/OAuthProvider.ts index a2be783155..62b565b60d 100644 --- a/plugins/auth-backend/src/providers/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.ts @@ -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 = ( @@ -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 { @@ -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 { 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, + }); + }; } diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts similarity index 100% rename from plugins/auth-backend/src/providers/PassportStrategyHelper.test.ts rename to plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts similarity index 97% rename from plugins/auth-backend/src/providers/PassportStrategyHelper.ts rename to plugins/auth-backend/src/lib/PassportStrategyHelper.ts index 7b7e467281..f9e32a2b6f 100644 --- a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts @@ -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, diff --git a/plugins/auth-backend/src/providers/config.ts b/plugins/auth-backend/src/providers/config.ts deleted file mode 100644 index 5ec73b7827..0000000000 --- a/plugins/auth-backend/src/providers/config.ts +++ /dev/null @@ -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', - }, - }, -]; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 8576e36096..4cc9212620 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -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; }; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 09622d9303..0f8f185015 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -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); } diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 066e16e330..971f588dce 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -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); } diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 50bea3495e..621b75078c 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -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); } diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index b8252ddc97..419a041637 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -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; } +export type SAMLEnvironmentProviderConfig = { + [key: string]: SAMLProviderConfig; +}; + export type AuthProviderFactory = ( - config: AuthProviderConfig, + globalConfig: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, ) => AuthProviderRouteHandlers; export type AuthInfoBase = { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 9de5fb80a5..20c35a4084 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -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; } diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 051bc50417..2d6b365f90 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -1,11 +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 + +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 diff --git a/plugins/catalog-backend/examples/example-components.yaml b/plugins/catalog-backend/examples/example-components.yaml new file mode 100644 index 0000000000..4580fa7179 --- /dev/null +++ b/plugins/catalog-backend/examples/example-components.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: podcast-api + description: Podcast API +spec: + type: service + lifecycle: experimental + owner: tools@example.com +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: artist-lookup + description: Artist Lookup +spec: + type: service + lifecycle: experimental + owner: tools@example.com +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: searcher + description: Searcher +spec: + type: service + lifecycle: production + owner: tools@example.com +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: playback-order + description: Playback Order +spec: + type: service + lifecycle: production + owner: tools@example.com +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: shuffle-api + description: Shuffle API +spec: + type: service + lifecycle: production + owner: tools@example.com +--- +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: queue-proxy + description: Queue Proxy +spec: + type: website + lifecycle: production + owner: tools@example.com diff --git a/plugins/catalog-backend/examples/example-location.yaml b/plugins/catalog-backend/examples/example-location.yaml new file mode 100644 index 0000000000..5c507b29b5 --- /dev/null +++ b/plugins/catalog-backend/examples/example-location.yaml @@ -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 diff --git a/plugins/catalog-backend/fixtures/one_component.yaml b/plugins/catalog-backend/fixtures/one_component.yaml deleted file mode 100644 index 421f66f7a8..0000000000 --- a/plugins/catalog-backend/fixtures/one_component.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: backstage.io/v1beta1 -kind: Component -metadata: - name: component3 -spec: - type: service diff --git a/plugins/catalog-backend/fixtures/two_components.yaml b/plugins/catalog-backend/fixtures/two_components.yaml deleted file mode 100644 index 2fcacb8492..0000000000 --- a/plugins/catalog-backend/fixtures/two_components.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: backstage.io/v1beta1 -kind: Component -metadata: - name: playlist-proxy -spec: - type: service ---- -apiVersion: backstage.io/v1beta1 -kind: Component -metadata: - name: artist-web -spec: - type: website diff --git a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts b/plugins/catalog-backend/migrations/20200511113813_init.js similarity index 95% rename from plugins/catalog-backend/src/database/migrations/20200511113813_init.ts rename to plugins/catalog-backend/migrations/20200511113813_init.js index 5f136670f4..0e4992b329 100644 --- a/plugins/catalog-backend/src/database/migrations/20200511113813_init.ts +++ b/plugins/catalog-backend/migrations/20200511113813_init.js @@ -14,9 +14,12 @@ * limitations under the License. */ -import * as Knex from 'knex'; +// @ts-check -export async function up(knex: Knex): Promise { +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { return ( knex.schema // @@ -114,9 +117,12 @@ export async function up(knex: Knex): Promise { .comment('The corresponding value to match on'); }) ); -} +}; -export async function down(knex: Knex): Promise { +/** + * @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 { }) .dropTable('entities') .dropTable('locations'); -} +}; diff --git a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts b/plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js similarity index 80% rename from plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts rename to plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js index 6700f5748e..163b38945a 100644 --- a/plugins/catalog-backend/src/database/migrations/20200520140700_location_update_log_table.ts +++ b/plugins/catalog-backend/migrations/20200520140700_location_update_log_table.js @@ -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 { +// @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 { .onDelete('CASCADE'); table.string('entity_name').nullable(); }); -} +}; -export async function down(knex: Knex): Promise { +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { return knex.schema.dropTableIfExists('location_update_log'); -} +}; diff --git a/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts b/plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js similarity index 85% rename from plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts rename to plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js index 6da9eeed9b..e51be30b81 100644 --- a/plugins/catalog-backend/src/database/migrations/20200527114117_location_update_log_latest_view.ts +++ b/plugins/catalog-backend/migrations/20200527114117_location_update_log_latest_view.js @@ -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 { +// @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 { await knex.schema.raw( `CREATE VIEW location_update_log_latest AS ${groupedQuery.toString()};`, ); -} +}; -export async function down(knex: Knex): Promise { +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { return knex.schema.raw(`DROP VIEW location_update_log_latest;`); -} +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 761c042723..d98afbe4d3 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -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", @@ -13,11 +13,11 @@ "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data" + "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" diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 2342f09fa4..c9e4e35d7b 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -5,5 +5,5 @@ curl \ --header 'Content-Type: application/json' \ --data-raw '{ "type": "github", - "target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml" + "target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml" }' diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index eb9cb2439c..30b480f348 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => { updateEntity: jest.fn(), entities: jest.fn(), entity: jest.fn(), + entityByUid: jest.fn(), removeEntity: jest.fn(), addLocation: jest.fn(), removeLocation: jest.fn(), diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 1ec1ebf6e5..6ab660a587 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -15,6 +15,9 @@ */ import type { Entity } from '@backstage/catalog-model'; +import { LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { NotFoundError } from '@backstage/backend-common'; + import type { Database, DbEntityResponse, EntityFilters } from '../database'; import type { EntitiesCatalog } from './types'; @@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { - await this.database.removeEntity(tx, uid); + const entityResponse = await this.database.entityByUid(tx, uid); + if (!entityResponse) { + throw new NotFoundError(`Entity with ID ${uid} was not found`); + } + const location = + entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const colocatedEntities = location + ? await this.database.entities(tx, [ + { + key: LOCATION_ANNOTATION, + values: [location], + }, + ]) + : [entityResponse]; + for (const dbResponse of colocatedEntities) { + await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!); + } + + if (entityResponse.locationId) { + await this.database.removeLocation(tx, entityResponse?.locationId!); + } + return undefined; }); } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts index 907bdea2d5..8c7897fa2c 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts @@ -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); }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index a1e76679f7..f515829b91 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { } async removeLocation(id: string): Promise { - await this.database.removeLocation(id); + await this.database.transaction(tx => this.database.removeLocation(tx, id)); } async locations(): Promise { diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index f1723e3733..169880e3c9 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -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', @@ -105,8 +88,7 @@ describe('CommonDatabase', () => { expect(locations).toEqual([output]); const location = await db.location(locations[0].id); expect(location).toEqual(output); - - await db.removeLocation(locations[0].id); + await db.transaction(tx => db.removeLocation(tx, locations[0].id)); await expect(db.locations()).resolves.toEqual([]); await expect(db.location(locations[0].id)).rejects.toThrow( @@ -116,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([ @@ -190,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); @@ -212,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', @@ -294,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([ @@ -317,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' } }, { @@ -334,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'] }, ]), @@ -356,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' } }, { @@ -373,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'] }, ]), diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 882995c142..24e9fd3d5e 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -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; let builder = tx('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)); @@ -319,6 +350,21 @@ export class CommonDatabase implements Database { return toEntityResponse(rows[0]); } + async entityByUid( + txOpaque: unknown, + id: string, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const rows = await tx('entities').where({ id }).select(); + + if (rows.length !== 1) { + return undefined; + } + + return toEntityResponse(rows[0]); + } + async removeEntity(txOpaque: unknown, uid: string): Promise { const tx = txOpaque as Knex.Transaction; @@ -341,10 +387,14 @@ export class CommonDatabase implements Database { }); } - async removeLocation(id: string): Promise { - const result = await this.database('locations') - .where({ id }) - .del(); + async removeLocation(txOpaque: unknown, id: string): Promise { + const tx = txOpaque as Knex.Transaction; + + await tx('entities') + .where({ location_id: id }) + .update({ location_id: null }); + + const result = await tx('locations').where({ id }).del(); if (!result) { throw new NotFoundError(`Found no location with ID ${id}`); @@ -432,4 +482,46 @@ export class CommonDatabase implements Database { // we got around to writing the entries } } + + private async ensureNoSimilarNames( + tx: Knex.Transaction, + data: Entity, + ): Promise { + 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); + } + } + } + } } diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts index f0fbe92600..28751f4830 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.ts @@ -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 = {}, ): Promise { 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 = {}, ): Promise { 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 { + 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); } } diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 17da373ae9..fc81e124ba 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -130,11 +130,13 @@ export type Database = { namespace?: string, ): Promise; + entityByUid(tx: unknown, uid: string): Promise; + removeEntity(tx: unknown, uid: string): Promise; addLocation(location: Location): Promise; - removeLocation(id: string): Promise; + removeLocation(tx: unknown, id: string): Promise; location(id: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index e806ecfbdb..eee16ea0de 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -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(), ]; } diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts new file mode 100644 index 0000000000..a5c9718574 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -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 { + 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; + } +} diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index a3da182416..2919e73396 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -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(); }); } diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 93c8b86779..5c7ccb23f5 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -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(); diff --git a/plugins/catalog-backend/tsconfig.json b/plugins/catalog-backend/tsconfig.json index 6f1acf8dce..9ca38dca27 100644 --- a/plugins/catalog-backend/tsconfig.json +++ b/plugins/catalog-backend/tsconfig.json @@ -9,6 +9,7 @@ "target": "es2019", "module": "commonjs", "esModuleInterop": true, + "allowJs": true, "lib": ["es2019"], "types": ["node", "jest", "webpack-env"] } diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 106f6c713a..eb8672bc42 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.8", "main": "dist/index.esm.js", "main:src": "src/index.ts", "types": "src/index.ts", @@ -22,25 +22,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.7", - "@backstage/core": "^0.1.1-alpha.7", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.7", - "@backstage/plugin-sentry": "^0.1.1-alpha.7", - "@backstage/theme": "^0.1.1-alpha.7", + "@backstage/catalog-model": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.8", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.8", + "@backstage/plugin-sentry": "^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", - "node-cache": "^5.1.1", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router": "^5.2.0", - "react-router-dom": "^5.2.0", - "react-use": "^14.2.0" + "react-router": "^6.0.0-alpha.5", + "react-router-dom": "^6.0.0-alpha.5", + "react-use": "^14.2.0", + "swr": "^0.2.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.7", - "@backstage/dev-utils": "^0.1.1-alpha.7", - "@backstage/test-utils": "^0.1.1-alpha.7", + "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/dev-utils": "^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/react-hooks": "^3.3.0", @@ -49,7 +49,9 @@ "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.0.4", "jest-fetch-mock": "^3.0.3", - "react-test-renderer": "^16.13.1" + "msw": "^0.19.0", + "react-test-renderer": "^16.13.1", + "whatwg-fetch": "^3.0.0" }, "files": [ "dist/**/*.{js,d.ts}" diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 027fd41069..719513529c 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -14,15 +14,84 @@ * limitations under the License. */ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; -import mockFetch from 'jest-fetch-mock'; +import { Entity } from '@backstage/catalog-model'; +const server = setupServer(); describe('CatalogClient', () => { - it('builds entity search filters properly', async () => { - mockFetch.mockResponse('[]'); - const client = new CatalogClient({ apiOrigin: '', basePath: '' }); - const entities = await client.getEntities({ a: '1', ö: '=' }); - expect(entities).toEqual([]); - expect(mockFetch).toBeCalledWith('/entities?a=1&%C3%B6=%3D'); + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + const mockApiOrigin = 'http://backstage:9191'; + const mockBasePath = '/i-am-a-mock-base'; + let client = new CatalogClient({ + apiOrigin: mockApiOrigin, + basePath: mockBasePath, + }); + + beforeEach(() => { + client = new CatalogClient({ + apiOrigin: mockApiOrigin, + basePath: mockBasePath, + }); + }); + + describe('getEntiies', () => { + const defaultResponse: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test1', + namespace: 'test1', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'Test2', + namespace: 'test1', + }, + }, + ]; + + beforeEach(() => { + server.use( + rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + }); + + it('should entities from correct endpoint', async () => { + const entities = await client.getEntities(); + expect(entities).toEqual(defaultResponse); + }); + + it('builds entity search filters properly', async () => { + expect.assertions(2); + server.use( + rest.get( + `${mockApiOrigin}${mockBasePath}/entities`, + (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'a=1&b=2&b=3&%C3%B6=%3D', + ); + return res(ctx.json([])); + }, + ), + ); + + const entities = await client.getEntities({ + a: '1', + b: ['2', '3'], + ö: '=', + }); + + expect(entities).toEqual([]); + }); }); }); diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index dcaab53307..3804315ace 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -19,15 +19,9 @@ import { Location, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import Cache from 'node-cache'; -import { DescriptorEnvelope } from '../types'; import { CatalogApi, EntityCompoundName } from './types'; export class CatalogClient implements CatalogApi { - // TODO(blam): This cache is just temporary until we have GraphQL. - // And client side caching using things like React Apollo or Relay. - // There's a lot of loading states that cause flickering around the app which aren't needed. - private cache: Cache; private apiOrigin: string; private basePath: string; @@ -40,7 +34,6 @@ export class CatalogClient implements CatalogApi { }) { this.apiOrigin = apiOrigin; this.basePath = basePath; - this.cache = new Cache({ stdTTL: 10 }); } private async getRequired(path: string): Promise { @@ -78,16 +71,21 @@ export class CatalogClient implements CatalogApi { } async getEntities( - filter?: Record, - ): Promise { - const cachedValue = this.cache.get( - `get:${JSON.stringify(filter)}`, - ); - if (cachedValue) return cachedValue; - + filter?: Record, + ): Promise { let path = `/entities`; if (filter) { - path += `?${new URLSearchParams(filter).toString()}`; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filter)) { + if (Array.isArray(value)) { + for (const v of value) { + params.append(key, v); + } + } else { + params.append(key, value); + } + } + path += `?${params.toString()}`; } return await this.getRequired(path); @@ -134,4 +132,20 @@ export class CatalogClient implements CatalogApi { .map(r => r.data) .find(l => locationCompound === `${l.type}:${l.target}`); } + + async removeEntityByUid(uid: string): Promise { + const response = await fetch( + `${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`, + { + method: 'DELETE', + }, + ); + if (!response.ok) { + const payload = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${payload}`, + ); + } + return undefined; + } } diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 9edf0358d1..3ac8a5d103 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createApiRef } from '@backstage/core'; import { Entity, Location } from '@backstage/catalog-model'; @@ -33,9 +34,10 @@ export interface CatalogApi { getEntityByName( compoundName: EntityCompoundName, ): Promise; - getEntities(filter?: Record): Promise; + getEntities(filter?: Record): Promise; addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; + removeEntityByUid(uid: string): Promise; } export type AddLocationResponse = { location: Location; entities: Entity[] }; diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx index 07879770b7..5ccac8f772 100644 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { useApi } from '@backstage/core'; import { catalogApiRef } from '../../api/types'; diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index 6246726fab..b78450d549 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; -import { FilterGroupItem } from '../../types'; +import { EntityFilterType } from '../../data/filters'; describe('Catalog Filter', () => { it('should render the different groups', async () => { @@ -41,11 +41,11 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: FilterGroupItem.ALL, + id: EntityFilterType.ALL, label: 'First Label', }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Second Label', }, ], @@ -68,12 +68,12 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: FilterGroupItem.ALL, + id: EntityFilterType.ALL, label: 'First Label', count: 100, }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Second Label', count: 400, }, @@ -97,12 +97,12 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: FilterGroupItem.ALL, + id: EntityFilterType.ALL, label: 'First Label', count: 100, }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Second Label', count: 400, }, @@ -136,12 +136,12 @@ describe('Catalog Filter', () => { name: 'Test Group 1', items: [ { - id: FilterGroupItem.ALL, + id: EntityFilterType.ALL, label: 'First Label', count: () => BACKSTAGE!, }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Second Label', count: 400, }, diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 6541a4df8d..5335b33ffa 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { Card, @@ -25,9 +26,9 @@ import { makeStyles, } from '@material-ui/core'; import type { IconComponent } from '@backstage/core'; -import { FilterGroupItem } from '../../types'; +import { EntityFilterType } from '../../data/filters'; export type CatalogFilterItem = { - id: FilterGroupItem; + id: EntityFilterType; label: string; icon?: IconComponent; count?: number | React.FC; diff --git a/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx index 20e1be1a9a..5e79682783 100644 --- a/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { useStarredEntities } from '../../hooks/useStarredEntites'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index d995556546..d30361d0e7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, @@ -28,6 +27,7 @@ import React from 'react'; import { catalogApiRef } from '../..'; import { CatalogApi } from '../../api/types'; import { CatalogPage } from './CatalogPage'; +import { Entity } from '@backstage/catalog-model'; describe('CatalogPage', () => { const mockErrorApi = new MockErrorApi(); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index eac753d9fa..c8074b1cc9 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -35,9 +35,8 @@ import Star from '@material-ui/icons/Star'; import StarOutline from '@material-ui/icons/StarBorder'; import React, { FC, useCallback, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { useAsync } from 'react-use'; import { catalogApiRef } from '../..'; -import { dataResolvers, defaultFilter, filterGroups } from '../../data/filters'; +import { defaultFilter, entityFilters, filterGroups } from '../../data/filters'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; import { @@ -45,6 +44,7 @@ import { CatalogFilterItem, } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; +import useStaleWhileRevalidate from 'swr'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -61,20 +61,22 @@ const useStyles = makeStyles(theme => ({ export const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); - const { - starredEntities, - toggleStarredEntity, - isStarredEntity, - } = useStarredEntities(); + const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); + const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); - const { value, error, loading } = useAsync( - () => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }), - [selectedFilter.id, starredEntities.size], + const { data: entities, error } = useStaleWhileRevalidate( + ['catalog/all', entityFilters[selectedFilter.id]], + async () => catalogApi.getEntities(), ); + const data = + entities?.filter(e => + entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }), + ) ?? []; + const onFilterSelected = useCallback( selected => setSelectedFilter(selected), [], @@ -146,6 +148,10 @@ export const CatalogPage: FC<{}> = () => { id: 'documentation', label: 'Documentation', }, + { + id: 'other', + label: 'Other', + }, ]; return ( @@ -182,7 +188,7 @@ export const CatalogPage: FC<{}> = () => { > Create Service - All your components + All your software catalog entities
@@ -194,8 +200,8 @@ export const CatalogPage: FC<{}> = () => {
diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 6d38319fe9..c04e4c95a8 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity } from '@backstage/catalog-model'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; @@ -50,12 +51,12 @@ describe('CatalogTable component', () => { ), ); const errorMessage = await rendered.findByText( - /Error encountered while fetching components./, + /Error encountered while fetching catalog entities./, ); expect(errorMessage).toBeInTheDocument(); }); - it('should display component names when loading has finished and no error occurred', async () => { + it('should display entity names when loading has finished and no error occurred', async () => { const rendered = render( wrapInTestApp( = ({ return (
- Error encountered while fetching components. {error.toString()} + Error encountered while fetching catalog entities. {error.toString()}
); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx similarity index 72% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 8a1f513f67..68306944a3 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentContextMenu } from './ComponentContextMenu'; -import { render } from '@testing-library/react'; + +import { render, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; +import { EntityContextMenu } from './EntityContextMenu'; describe('ComponentContextMenu', () => { - it('should call onUnregisterComponent on button click', async () => { + it('should call onUnregisterEntity on button click', async () => { await act(async () => { const mockCallback = jest.fn(); const menu = render( - , + , ); const button = await menu.findByTestId('menu-button'); - button.click(); - const unregister = await menu.findByText('Unregister component'); + fireEvent.click(button); + const unregister = await menu.findByText('Unregister entity'); expect(unregister).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx similarity index 88% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 14c68ec7f9..fd52b97cb1 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { IconButton, ListItemIcon, @@ -34,13 +35,11 @@ const useStyles = makeStyles({ }, }); -type ComponentContextMenuProps = { - onUnregisterComponent: () => void; +type Props = { + onUnregisterEntity: () => void; }; -export const ComponentContextMenu: FC = ({ - onUnregisterComponent, -}) => { +export const EntityContextMenu: FC = ({ onUnregisterEntity }) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -53,7 +52,7 @@ export const ComponentContextMenu: FC = ({ }; return ( -
+ <> = ({ { onClose(); - onUnregisterComponent(); + onUnregisterEntity(); }} > - Unregister component + Unregister entity @@ -91,6 +90,6 @@ export const ComponentContextMenu: FC = ({ -
+ ); }; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx similarity index 77% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx rename to plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx index ef12e52363..e0027431ff 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx @@ -13,21 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity } from '@backstage/catalog-model'; import { render } from '@testing-library/react'; import React from 'react'; -import { ComponentMetadataCard } from './ComponentMetadataCard'; +import { EntityMetadataCard } from './EntityMetadataCard'; -describe('ComponentMetadataCard component', () => { - it('should display component name if provided', async () => { +describe('EntityMetadataCard component', () => { + it('should display entity name if provided', async () => { const testEntity: Entity = { apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'test' }, }; - const rendered = await render( - , - ); + const rendered = await render(); expect(await rendered.findByText('test')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx similarity index 93% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx rename to plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx index 60c9284121..d7a3bc81f6 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity } from '@backstage/catalog-model'; import { InfoCard, StructuredMetadataTable } from '@backstage/core'; import React, { FC } from 'react'; @@ -21,7 +22,7 @@ type Props = { entity: Entity; }; -export const ComponentMetadataCard: FC = ({ entity }) => ( +export const EntityMetadataCard: FC = ({ entity }) => ( diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx similarity index 61% rename from plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx rename to plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index 75393bd10e..aeb9fc543b 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -13,32 +13,39 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentPage } from './ComponentPage'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + const mockNavigate = jest.fn(); + return { + ...actual, + useNavigate: jest.fn(() => mockNavigate), + useParams: jest.fn(), + }; +}); + +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { wrapInTestApp } from '@backstage/test-utils'; import { render, wait } from '@testing-library/react'; import * as React from 'react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { catalogApiRef, CatalogApi } from '../../api/types'; - -const getTestProps = (name: string) => { - return { - match: { - params: { - optionalNamespaceAndName: name, - kind: 'Component', - }, - }, - history: { - push: jest.fn(), - }, - }; -}; +import { CatalogApi, catalogApiRef } from '../../api/types'; +import { EntityPage } from './EntityPage'; +const { + useParams, + useNavigate, +}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock( + 'react-router-dom', +); const errorApi = { post: () => {} }; -describe('ComponentPage', () => { - it('should redirect to component table page when name is not provided', async () => { - const props = getTestProps(''); +describe('EntityPage', () => { + it('should redirect to catalog page when name is not provided', async () => { + useParams.mockReturnValue({ + kind: 'Component', + optionalNamespaceAndName: '', + }); + render( wrapInTestApp( { ], ])} > - + , ), ); - await wait(() => - expect(props.history.push).toHaveBeenCalledWith('/catalog'), - ); + await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); }); }); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx similarity index 75% rename from plugins/catalog/src/components/ComponentPage/ComponentPage.tsx rename to plugins/catalog/src/components/EntityPage/EntityPage.tsx index 17ad2d880d..e74276697b 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -31,24 +31,12 @@ import { Alert } from '@material-ui/lab'; import React, { FC, useEffect, useState } from 'react'; import { useAsync } from 'react-use'; import { catalogApiRef } from '../..'; -import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu'; -import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard'; -import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { useParams, useNavigate } from 'react-router-dom'; const REDIRECT_DELAY = 1000; - -type ComponentPageProps = { - match: { - params: { - optionalNamespaceAndName: string; - kind: string; - }; - }; - history: { - push: (url: string) => void; - }; -}; - function headerProps( kind: string, namespace: string | undefined, @@ -68,8 +56,12 @@ function headerProps( }; } -export const ComponentPage: FC = ({ match, history }) => { - const { optionalNamespaceAndName, kind } = match.params; +export const EntityPage: FC<{}> = () => { + const { optionalNamespaceAndName, kind } = useParams() as { + optionalNamespaceAndName: string; + kind: string; + }; + const navigate = useNavigate(); const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); const errorApi = useApi(errorApiRef); @@ -83,26 +75,24 @@ export const ComponentPage: FC = ({ match, history }) => { useEffect(() => { if (!error && !loading && !entity) { - errorApi.post(new Error('Component not found!')); + errorApi.post(new Error('Entity not found!')); setTimeout(() => { - history.push('/'); + navigate('/'); }, REDIRECT_DELAY); } - }, [errorApi, history, error, loading, entity]); + }, [errorApi, navigate, error, loading, entity]); if (!name) { - history.push('/catalog'); + navigate('/catalog'); return null; } - const removeComponent = async () => { + const cleanUpAfterRemoval = async () => { setConfirmationDialogOpen(false); - // await componentFactory.removeComponentByName(componentName); - history.push('/'); + navigate('/'); }; const showRemovalDialog = () => setConfirmationDialogOpen(true); - const hideRemovalDialog = () => setConfirmationDialogOpen(false); // TODO - Replace with proper tabs implementation const tabs = [ @@ -143,9 +133,7 @@ export const ComponentPage: FC = ({ match, history }) => { // TODO: Switch theme and type props based on component type (website, library, ...)
- {entity && ( - - )} + {entity && }
{loading && } @@ -163,7 +151,7 @@ export const ComponentPage: FC = ({ match, history }) => { - + = ({ match, history }) => { - setConfirmationDialogOpen(false)} /> )} diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx similarity index 80% rename from plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx rename to plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index aa8f99e01e..e4eec3c21f 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -15,7 +15,7 @@ */ import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; -import { Progress, useApi } from '@backstage/core'; +import { Progress, useApi, alertApiRef } from '@backstage/core'; import { Button, Dialog, @@ -33,7 +33,7 @@ import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api/types'; -type ComponentRemovalDialogProps = { +type Props = { open: boolean; onConfirm: () => any; onClose: () => any; @@ -50,7 +50,7 @@ function useColocatedEntities(entity: Entity): AsyncState { }, [catalogApi, entity]); } -export const ComponentRemovalDialog: FC = ({ +export const UnregisterEntityDialog: FC = ({ open, onConfirm, onClose, @@ -59,11 +59,24 @@ export const ComponentRemovalDialog: FC = ({ const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const removeEntity = async () => { + const uid = entity.metadata.uid; + try { + await catalogApi.removeEntityByUid(uid!); + } catch (err) { + alertApi.post({ message: err.message }); + } + + onConfirm(); + }; return ( - Are you sure you want to unregister this component? + Are you sure you want to unregister this entity? {loading ? : null} @@ -90,21 +103,23 @@ export const ComponentRemovalDialog: FC = ({
  • - {entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]} + {entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
- To undo, just re-register the component in Backstage. + To undo, just re-register the entity in Backstage. ) : null}
- + + All clusters + + + + ); + } + + return ( + +
+ +
+ {content} +
+ ); +}; + +export default ClusterList; diff --git a/packages/config-loader/src/types.ts b/plugins/gitops-profiles/src/components/ClusterList/index.ts similarity index 82% rename from packages/config-loader/src/types.ts rename to plugins/gitops-profiles/src/components/ClusterList/index.ts index d25a2d3e8b..e4260e5374 100644 --- a/packages/config-loader/src/types.ts +++ b/plugins/gitops-profiles/src/components/ClusterList/index.ts @@ -14,7 +14,4 @@ * limitations under the License. */ -export type LoadConfigOptions = { - // Config path, defaults to app-config.yaml in project root - configPath?: string; -}; +export { default } from './ClusterList'; diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx new file mode 100644 index 0000000000..d454278255 --- /dev/null +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -0,0 +1,102 @@ +/* + * 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 React, { FC, useEffect, useState } from 'react'; +import { + Content, + Header, + Page, + pageTheme, + Table, + Progress, + HeaderLabel, + useApi, +} from '@backstage/core'; + +import { Link } from '@material-ui/core'; +import { useParams } from 'react-router-dom'; +import { useLocalStorage } from 'react-use'; +import { gitOpsApiRef, Status } from '../../api'; +import { transformRunStatus } from '../ProfileCatalog'; + +const ClusterPage: FC<{}> = () => { + const params = useParams() as { owner: string; repo: string }; + const [loginInfo] = useLocalStorage<{ + token: string; + username: string; + name: string; + }>('githubLoginDetails'); + + const [pollingLog, setPollingLog] = useState(true); + const [runStatus, setRunStatus] = useState([]); + const [runLink, setRunLink] = useState(''); + const [showProgress, setShowProgress] = useState(true); + + const api = useApi(gitOpsApiRef); + + const columns = [ + { field: 'status', title: 'Status' }, + { field: 'message', title: 'Message' }, + ]; + + useEffect(() => { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: loginInfo.token, + gitHubUser: loginInfo.username, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } + return () => {}; + }, [pollingLog, api, loginInfo, params]); + + return ( + +
+ +
+ +